@cowwo/dsh-icon-custom 0.15.0 → 0.18.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 -7
- package/README.zh.md +23 -8
- package/client/client.js +857 -56
- package/lib/index.js +52 -0
- package/lib/running.js +32 -0
- package/lib/typert.host.js +72 -0
- package/package.json +3 -3
package/client/client.js
CHANGED
|
@@ -40,6 +40,7 @@ window.__ModuleLoader__.load({
|
|
|
40
40
|
badgeSourceManual: "手动测试",
|
|
41
41
|
badgeTestUnit: "0 = 不显示",
|
|
42
42
|
badgeSizeLabel: "红点大小:",
|
|
43
|
+
badgeSizeHint: "单独出现时按这三档决定大小;红黄两颗数字同时出现时(未读 + 进行中),它们会一起缩到能塞下的最大尺寸 —— 这时三档画出来是一样的,因为画布就这么大。",
|
|
43
44
|
badgeSizeSmall: "小",
|
|
44
45
|
badgeSizeMedium: "中",
|
|
45
46
|
badgeSizeLarge: "大",
|
|
@@ -71,16 +72,28 @@ window.__ModuleLoader__.load({
|
|
|
71
72
|
workspaceDotLabel: "在工作区和会话行上显示红点(实验)",
|
|
72
73
|
workspaceDotHint: "在侧栏工作区那一行的文件夹图标上点一个小红点(收起的工作区里有多少未读,就看它),有未读的会话在其标题右边也跟一个小红点。会话行上的点只在那一行真的显示着时才画:折叠起来的会话由工作区那一行的点和「待处理」清单代表。这一项是贴着页面结构做的,DSH 升级后可能失效;失效时只会变成\"不显示\",不会影响红点数字、清单、跳转这些功能,随时可以关掉它。",
|
|
73
74
|
appBadgeLabel: "在系统应用图标上显示红点",
|
|
74
|
-
appBadgeHint: "安装为应用后,把同一个数字也放到系统图标上:Windows 是任务栏图标上的角标,macOS 是 Dock 角标,iOS 是主屏图标上的数字。样子由系统决定——Windows 上 Chrome 画深色圆+白字,Edge 走 Windows 系统徽章通道(是否显示数字由 Edge/Windows 决定);插件会周期重设数字,但只在应用窗口开着时更新。",
|
|
75
|
+
appBadgeHint: "安装为应用后,把同一个数字也放到系统图标上:Windows 是任务栏图标上的角标,macOS 是 Dock 角标,iOS 是主屏图标上的数字。样子由系统决定——Windows 上 Chrome 画深色圆+白字,Edge 走 Windows 系统徽章通道(是否显示数字由 Edge/Windows 决定);插件会周期重设数字,但只在应用窗口开着时更新。Safari 的程序坞网页应用 / 主屏应用还要先允许通知(见下面的「通知权限」),否则角标不会出现。",
|
|
75
76
|
appBadgeFootnote: "角标只在应用窗口开着时由本插件更新;数字超过 99 时由系统显示为 99+。",
|
|
76
77
|
appBadgeProbeInstalled: "当前环境:已安装为应用,本机可以设置角标。是否真正显示由系统决定。",
|
|
77
78
|
appBadgeProbeTab: "当前环境:浏览器标签页(不是安装的应用)——安装为应用后才会显示角标。",
|
|
78
79
|
appBadgeProbeUnsupported: "当前环境:本机没有这个能力(当前浏览器不支持应用角标)。",
|
|
79
80
|
appBadgeProbeInsecure: "当前环境:不是安全上下文(请用 localhost 或 https 访问),系统角标不可用。",
|
|
81
|
+
notificationPermissionLabel: "通知权限:",
|
|
82
|
+
notificationPermissionGranted: "已授予",
|
|
83
|
+
notificationPermissionDefault: "未授予",
|
|
84
|
+
notificationPermissionDenied: "已被拒绝(要去系统设置的「通知」里打开)",
|
|
85
|
+
notificationPermissionUnsupported: "本机浏览器没有通知能力",
|
|
86
|
+
notificationPermissionButton: "允许通知",
|
|
87
|
+
notificationPermissionHint: "Chrome / Edge 的角标不需要这一步;Safari 不一样——macOS 的「添加到程序坞」网页应用和 iOS 的主屏应用,只有在你允许通知之后才会画出角标(Apple 把角标权限绑在通知权限上)。这里只申请权限,本插件目前不会给你发通知。",
|
|
80
88
|
badgeTestHint: "真实未读 = 有会话发生了上面勾选的情况、而且你还没看过它(打开该会话即视为已读)。你正开着的会话也算——窗口可能被最小化、页面可能切到后台,这里无从判断你在不在看,所以不做这个区分。只有子代理不计。手动模式:自己填数字试看效果。刷新页面后回到真实未读。",
|
|
81
89
|
clearDelayLabel: "进入会话后多久算已读:",
|
|
82
90
|
clearDelayUnit: "秒",
|
|
83
|
-
clearDelayHint: "填 0 = 进去就算已读(红点立即消失)。大于 0 时,你要在那个会话里连续待满这么多秒它才算看过:中途切走会清零重数,刷新页面也重新数。**计时只认\"你进入会话时已经存在的结束\"**——你人已经在里面的时候又跑完一轮,那一轮不算数:它会一直亮着,等你切走再进来才会清。上限 600
|
|
91
|
+
clearDelayHint: "填 0 = 进去就算已读(红点立即消失)。大于 0 时,你要在那个会话里连续待满这么多秒它才算看过:中途切走会清零重数,刷新页面也重新数。**计时只认\"你进入会话时已经存在的结束\"**——你人已经在里面的时候又跑完一轮,那一轮不算数:它会一直亮着,等你切走再进来才会清。上限 600 秒。**切到「插件/设置」页、或把标签页切到后台,都只是暂停计时,不会因此清掉红点**(它们不算离开);真正算离开的只有一件事:**去别的会话**。已读记在宿主($DSH_HOME/custom-favicon/unread-seen.json):任何一台设备上看过,所有设备都不再提醒;浏览器那份只是缓存,清站点数据不会把已读丢回去。",
|
|
92
|
+
/** 进行中黄点(0.18.0):官方 running 的第二次投影,与红点互不牵连。 */
|
|
93
|
+
runningMarkSection: "进行中黄点",
|
|
94
|
+
runningMarkLabel: "显示进行中标记(标题数字 / 文件夹与会话行 / 左上角 / 标签页)",
|
|
95
|
+
runningMarkCount: "{n} 个进行中",
|
|
96
|
+
runningMarkHint: "黄点就是官方的「进行中」:会话有回合正在跑、而且没有停下来等你(停下等你的时候,官方那一行显示琥珀色的「等待审批 / 等待回答」,黄点也就不亮)。它和未读红点完全独立、各画各的:侧栏「工作区」标题后面是黄色数字——此刻全局有几个会话在跑(和红数字上下排:右上红、右下黄);工作区文件夹图标上是黄点,有未读时红点在左、黄点在右;会话行上同样是红点在左、黄点在右;左上角 Logo 与标签页图标右下角也是同一个数字,红数字仍占右上角。事实全部来自官方(会话列表里每个会话的 `running`)——插件不落盘、不推断,所以刷新页面立刻是对的,进程重启也不会留下一个永远亮着的黄点。关掉这个开关,红点完全不受影响。"
|
|
84
97
|
};
|
|
85
98
|
const en = {
|
|
86
99
|
nav: "Favicon",
|
|
@@ -113,6 +126,7 @@ window.__ModuleLoader__.load({
|
|
|
113
126
|
badgeSourceManual: "Manual",
|
|
114
127
|
badgeTestUnit: "0 = hidden",
|
|
115
128
|
badgeSizeLabel: "Badge size:",
|
|
129
|
+
badgeSizeHint: "A number shown on its own uses this size; when the red and the yellow number share the icon (unread + running) they shrink together to the largest pair that fits — the three sizes then draw the same, because the canvas is the constraint.",
|
|
116
130
|
badgeSizeSmall: "Small",
|
|
117
131
|
badgeSizeMedium: "Medium",
|
|
118
132
|
badgeSizeLarge: "Large",
|
|
@@ -144,16 +158,28 @@ window.__ModuleLoader__.load({
|
|
|
144
158
|
workspaceDotLabel: "Dots on workspace and session rows (experimental)",
|
|
145
159
|
workspaceDotHint: "Adds a small red dot to the folder icon of each workspace row (a collapsed workspace shows how many of its sessions are unread there), and one beside the title of every unread session row. A session dot is only drawn while that row is really rendered: folded sessions are represented by their workspace row's dot and the pending panel. This one reads the page structure, so a DSH upgrade may break it; when it does it simply stops showing, never affecting the counts, the list, or navigation. Turn it off any time.",
|
|
146
160
|
appBadgeLabel: "Badge the system app icon",
|
|
147
|
-
appBadgeHint: "Once installed as an app, the same number also goes to the system icon: a taskbar badge on Windows, a Dock badge on macOS, a number on the iOS home-screen icon. The system decides how it looks — on Windows, Chrome draws a dark circle with white text, while Edge goes through the Windows badge channel (whether it shows the number is up to Edge/Windows). The plugin re-asserts the number periodically, but only while the app window is open.",
|
|
161
|
+
appBadgeHint: "Once installed as an app, the same number also goes to the system icon: a taskbar badge on Windows, a Dock badge on macOS, a number on the iOS home-screen icon. The system decides how it looks — on Windows, Chrome draws a dark circle with white text, while Edge goes through the Windows badge channel (whether it shows the number is up to Edge/Windows). The plugin re-asserts the number periodically, but only while the app window is open. Safari's Dock and home-screen web apps additionally need notifications allowed first (see the permission row below), or the badge never appears.",
|
|
148
162
|
appBadgeFootnote: "The badge is only updated by this plugin while the app window is open; above 99 the system shows 99+.",
|
|
149
163
|
appBadgeProbeInstalled: "This environment: installed as an app, so this device can set a badge. Whether it actually appears is up to the system.",
|
|
150
164
|
appBadgeProbeTab: "This environment: a browser tab (not an installed app) — install as an app to get a badge.",
|
|
151
165
|
appBadgeProbeUnsupported: "This environment: no app-badge capability in this browser.",
|
|
152
166
|
appBadgeProbeInsecure: "This environment: not a secure context (use localhost or https) — the system badge is unavailable.",
|
|
167
|
+
notificationPermissionLabel: "Notifications permission:",
|
|
168
|
+
notificationPermissionGranted: "granted",
|
|
169
|
+
notificationPermissionDefault: "not granted",
|
|
170
|
+
notificationPermissionDenied: "denied (enable it in the system notifications settings)",
|
|
171
|
+
notificationPermissionUnsupported: "this browser has no Notification API",
|
|
172
|
+
notificationPermissionButton: "Allow notifications",
|
|
173
|
+
notificationPermissionHint: "A badge needs no notification permission on Chrome / Edge, but Safari is different: a macOS web app added to the Dock (or an iOS home-screen web app) only draws its badge once you allow notifications — Apple ties the two together. This only asks for the permission; the plugin does not send notifications.",
|
|
153
174
|
badgeTestHint: "Real unread = a session ended for one of the checked reasons and you have not looked at it yet (opening a session marks it read). The session you are viewing counts too — the window may be minimised or the page in the background, so this half cannot tell whether you are looking, and does not pretend to. Only sub-agents never count. Manual = type a number to preview. Resets to Real on reload.",
|
|
154
175
|
clearDelayLabel: "Mark read after staying:",
|
|
155
176
|
clearDelayUnit: "seconds",
|
|
156
|
-
clearDelayHint: "0 = read as soon as you enter (the dot clears at once). Above 0 you must stay in that session for this many seconds before it counts as read: leaving it resets the clock, and so does a reload. The clock only ever covers **the ending that was already there when you entered** — a turn that finishes while you are sitting in the session does not count: it stays red until you leave and come back. Capped at 600 seconds.
|
|
177
|
+
clearDelayHint: "0 = read as soon as you enter (the dot clears at once). Above 0 you must stay in that session for this many seconds before it counts as read: leaving it resets the clock, and so does a reload. The clock only ever covers **the ending that was already there when you entered** — a turn that finishes while you are sitting in the session does not count: it stays red until you leave and come back. Capped at 600 seconds. A settings or plugins page, or a tab in the background, only **pauses** the clock — neither clears a dot (neither counts as leaving); the one thing that does count as leaving is **opening another session**. Read state lives on the Host ($DSH_HOME/custom-favicon/unread-seen.json): read on any device means read on every device, and the browser's own copy is only a cache — clearing site data no longer throws it away.",
|
|
178
|
+
/** Running mark (0.18.0): a second projection of the official `running`. */
|
|
179
|
+
runningMarkSection: "Running marks",
|
|
180
|
+
runningMarkLabel: "Show running marks (header number / folder and session rows / top-left / tab)",
|
|
181
|
+
runningMarkCount: "{n} running",
|
|
182
|
+
runningMarkHint: "The yellow mark IS the official \"running\": the session has a turn in flight and is not parked waiting for you (while it waits, the official row shows the amber \"waiting for approval / waiting for answer\" and the yellow goes dark). It is completely independent of the unread red dot, and each draws its own mark: after the sidebar's \"工作区\" label sits the yellow NUMBER of sessions running right now (stacked against the red number — red top-right, yellow bottom-right); the workspace folder icon gets a yellow dot with the red dot to its left; a session row is red left, yellow right; and the top-left logo plus the bottom-right of the tab icon carry the same number, with the red number keeping its top-right corner. Every fact comes from the official host (each session's `running` in the session list); the plugin stores nothing and infers nothing, so a reload is immediately right and a process restart never leaves a yellow mark stuck on. Turning this off does not affect the red dot at all."
|
|
157
183
|
};
|
|
158
184
|
//#endregion
|
|
159
185
|
|
|
@@ -308,6 +334,35 @@ window.__ModuleLoader__.load({
|
|
|
308
334
|
return () => { unreadConfigListeners.delete(fn); };
|
|
309
335
|
}
|
|
310
336
|
|
|
337
|
+
/**
|
|
338
|
+
* The running mark's own switch, and the yellow it paints with.
|
|
339
|
+
*
|
|
340
|
+
* Deliberately NOT a field on the unread rule: the two features answer
|
|
341
|
+
* different questions ("something ended and you have not looked" vs
|
|
342
|
+
* "something is running right now"), so neither switch may drag the other's
|
|
343
|
+
* storage or response shape along. The yellow is a literal like the red
|
|
344
|
+
* `#e5484d` already is — the theme has no yellow token, and the official
|
|
345
|
+
* amber `--dsw-alias-state-warn-primary` (#f59e0b) is taken by the row's
|
|
346
|
+
* "waiting for you" dot, which must stay tellable apart from this one.
|
|
347
|
+
*/
|
|
348
|
+
const RUNNING_YELLOW = "#f5c518";
|
|
349
|
+
/** Mirror of the host default (lib/running.js). */
|
|
350
|
+
const RUNNING_MARK_FALLBACK = true;
|
|
351
|
+
let runningConfig = { enabled: RUNNING_MARK_FALLBACK };
|
|
352
|
+
const runningConfigListeners = new Set();
|
|
353
|
+
function normalizeRunningConfig(input) {
|
|
354
|
+
const source = input !== null && typeof input === "object" ? input : {};
|
|
355
|
+
return { enabled: typeof source.enabled === "boolean" ? source.enabled : RUNNING_MARK_FALLBACK };
|
|
356
|
+
}
|
|
357
|
+
function emitRunningConfig(input) {
|
|
358
|
+
runningConfig = normalizeRunningConfig(input);
|
|
359
|
+
runningConfigListeners.forEach((fn) => { try { fn(runningConfig); } catch {} });
|
|
360
|
+
}
|
|
361
|
+
function subscribeRunningConfig(fn) {
|
|
362
|
+
runningConfigListeners.add(fn);
|
|
363
|
+
return () => { runningConfigListeners.delete(fn); };
|
|
364
|
+
}
|
|
365
|
+
|
|
311
366
|
/**
|
|
312
367
|
* The seen watermark: the HOST owns it, this browser keeps the working copy.
|
|
313
368
|
*
|
|
@@ -613,10 +668,25 @@ window.__ModuleLoader__.load({
|
|
|
613
668
|
* @param view - the screen report described above (may be absent).
|
|
614
669
|
* @returns the ids being viewed, in list order; `[]` when there are none.
|
|
615
670
|
*/
|
|
671
|
+
/**
|
|
672
|
+
* Which session this page is ON — the set that defines "entered" and "left".
|
|
673
|
+
*
|
|
674
|
+
* Only a change here counts as leaving a session, so this must NOT be emptied
|
|
675
|
+
* by pausing: a background tab, or a global panel (settings, plugins, …) that
|
|
676
|
+
* covers the conversation, keeps you on the same session. Emptying it would
|
|
677
|
+
* drop the entered-at snapshot, and taking a fresh one on return is exactly
|
|
678
|
+
* what acknowledges an ending you never left the session for — the bug behind
|
|
679
|
+
* "切一下标签页红点就没了".
|
|
680
|
+
*
|
|
681
|
+
* `view.displayed` (read from the DOM) is the precise answer when available;
|
|
682
|
+
* `list.current` and then retention are the fallbacks, which is also what keeps
|
|
683
|
+
* the answer meaningful while a panel covers the conversation.
|
|
684
|
+
* @param list - the session list snapshot (may be absent).
|
|
685
|
+
* @param view - `{ displayed }`: the session the conversation is rendering.
|
|
686
|
+
* @returns the ids being viewed, in list order; `[]` when there are none.
|
|
687
|
+
*/
|
|
616
688
|
function currentSessionIds(list, view) {
|
|
617
689
|
const screen = view !== null && typeof view === "object" ? view : {};
|
|
618
|
-
if (screen.visible === false) return [];
|
|
619
|
-
if (screen.panelActive === true) return [];
|
|
620
690
|
if (typeof screen.displayed === "string" && screen.displayed !== "") return [screen.displayed];
|
|
621
691
|
if (list === null || typeof list !== "object") return [];
|
|
622
692
|
if (typeof list.current === "string" && list.current !== "") return [list.current];
|
|
@@ -718,6 +788,84 @@ window.__ModuleLoader__.load({
|
|
|
718
788
|
return hits;
|
|
719
789
|
}
|
|
720
790
|
|
|
791
|
+
//#endregion
|
|
792
|
+
//#region running state
|
|
793
|
+
// The "who is running right now" fact is entirely OFFICIAL and entirely
|
|
794
|
+
// free: the browser's session list already carries each session's
|
|
795
|
+
// `running` (host `ctx.agents.get(id)?.status === 'running'`, pushed by
|
|
796
|
+
// `api-session/status`), and the official session row already draws its own
|
|
797
|
+
// grey spinner from it. This plugin adds no host plumbing, no projection
|
|
798
|
+
// and no storage — it only projects the same fact as a YELLOW mark in four
|
|
799
|
+
// places. See docs/adr/0010-running-mark-is-a-second-projection.md.
|
|
800
|
+
/**
|
|
801
|
+
* The sessions that are running right now.
|
|
802
|
+
*
|
|
803
|
+
* Two official facts are combined, in the shipped sidebar's own precedence:
|
|
804
|
+
* * running — `status.get(id)?.running`, falling back to the row's own
|
|
805
|
+
* `running` exactly like ui-workspace's `sessionNode` does;
|
|
806
|
+
* * waiting — a `pendingInteraction` makes the official row show the amber
|
|
807
|
+
* "waiting for approval / answer / plan review" instead of the spinner,
|
|
808
|
+
* so it is NOT running here either. That is why a session with the
|
|
809
|
+
* amber dot never also gets a yellow one.
|
|
810
|
+
*
|
|
811
|
+
* Sub-agent sessions are skipped for the same reason `collectUnread` skips
|
|
812
|
+
* them: they are not part of the browsing the user does (ui-workspace's
|
|
813
|
+
* `sessionVisible` hides them outright), so counting them would leave the
|
|
814
|
+
* mark lit by work nobody can open.
|
|
815
|
+
* @param list - the session list snapshot (may be absent).
|
|
816
|
+
* @param status - the official session-status map (may be absent).
|
|
817
|
+
* @returns the running session ids, in list order.
|
|
818
|
+
*/
|
|
819
|
+
function runningSessionIds(list, status) {
|
|
820
|
+
const out = [];
|
|
821
|
+
if (list === null || typeof list !== "object") return out;
|
|
822
|
+
const byId = list.byId !== null && typeof list.byId === "object" ? list.byId : {};
|
|
823
|
+
const order = Array.isArray(list.ids) ? list.ids : Object.keys(byId);
|
|
824
|
+
const lookup = status !== null && status !== undefined && typeof status.get === "function"
|
|
825
|
+
? (id) => status.get(id)
|
|
826
|
+
: () => undefined;
|
|
827
|
+
for (const id of order) {
|
|
828
|
+
if (typeof id !== "string" || id === "") continue;
|
|
829
|
+
const entry = byId[id];
|
|
830
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
831
|
+
if (entry.origin === "subagent") continue;
|
|
832
|
+
const row = lookup(id);
|
|
833
|
+
const known = row !== null && typeof row === "object";
|
|
834
|
+
if (known && row.pendingInteraction !== null && row.pendingInteraction !== undefined) continue;
|
|
835
|
+
const running = known && typeof row.running === "boolean" ? row.running : entry.running;
|
|
836
|
+
if (running !== true) continue;
|
|
837
|
+
out.push(id);
|
|
838
|
+
}
|
|
839
|
+
return out;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Roll the running sessions up to one count per workspace.
|
|
844
|
+
*
|
|
845
|
+
* Mirrors `dotsFromItems`: the ID anchors the number to the workspace row
|
|
846
|
+
* the browser itself keys (`data-row-key="workspace:<id>"`), the title is
|
|
847
|
+
* what the row displays, and a session whose workspace cannot be named is
|
|
848
|
+
* dropped rather than guessed at. Pure, so a test can drive it directly.
|
|
849
|
+
* @param ids - running session ids, from `runningSessionIds`.
|
|
850
|
+
* @param owners - sessionId → `{ id, title }` from the official workspace snapshot.
|
|
851
|
+
* @returns `[{ id, name, count }]`, busiest workspace first.
|
|
852
|
+
*/
|
|
853
|
+
function runningWorkspaceDots(ids, owners) {
|
|
854
|
+
const counts = new Map();
|
|
855
|
+
for (const id of Array.isArray(ids) ? ids : []) {
|
|
856
|
+
if (typeof id !== "string" || id === "") continue;
|
|
857
|
+
const owner = owners !== null && typeof owners === "object" ? owners[id] : undefined;
|
|
858
|
+
const workspaceId = owner !== null && typeof owner === "object" && typeof owner.id === "string" ? owner.id : "";
|
|
859
|
+
if (workspaceId === "") continue;
|
|
860
|
+
const title = typeof owner.title === "string" ? owner.title : "";
|
|
861
|
+
const existing = counts.get(workspaceId);
|
|
862
|
+
if (existing === undefined) counts.set(workspaceId, { id: workspaceId, name: title, count: 1 });
|
|
863
|
+
else existing.count++;
|
|
864
|
+
}
|
|
865
|
+
return [...counts.values()]
|
|
866
|
+
.sort((a, b) => b.count - a.count || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
867
|
+
}
|
|
868
|
+
|
|
721
869
|
//#endregion
|
|
722
870
|
//#region badge bridge
|
|
723
871
|
// In-package badge bridge. Two number sources:
|
|
@@ -731,6 +879,14 @@ window.__ModuleLoader__.load({
|
|
|
731
879
|
let realCount = 0;
|
|
732
880
|
/** The sessions behind `realCount`, for the sidebar list (newest first). */
|
|
733
881
|
let realItems = [];
|
|
882
|
+
/**
|
|
883
|
+
* How many sessions are running right now — the official `running` fact,
|
|
884
|
+
* already folded by `runningSessionIds`. It rides the SAME bridge as the
|
|
885
|
+
* unread number because it has the same two consumers (the brand mark and
|
|
886
|
+
* the tab icon) and the two must never disagree about which of them is
|
|
887
|
+
* being shown.
|
|
888
|
+
*/
|
|
889
|
+
let runningTotal = 0;
|
|
734
890
|
let manualCount = 0;
|
|
735
891
|
let badgeSize = "md";
|
|
736
892
|
const badgeListeners = new Set();
|
|
@@ -742,7 +898,7 @@ window.__ModuleLoader__.load({
|
|
|
742
898
|
return badgeSource === "manual" ? manualCount : realCount;
|
|
743
899
|
}
|
|
744
900
|
function badgeSnapshot() {
|
|
745
|
-
return { count: effectiveCount(), size: badgeSize, source: badgeSource, items: badgeSource === "manual" ? [] : realItems };
|
|
901
|
+
return { count: effectiveCount(), size: badgeSize, source: badgeSource, items: badgeSource === "manual" ? [] : realItems, running: runningTotal };
|
|
746
902
|
}
|
|
747
903
|
function notifyBadge() {
|
|
748
904
|
const snapshot = badgeSnapshot();
|
|
@@ -764,6 +920,22 @@ window.__ModuleLoader__.load({
|
|
|
764
920
|
realItems = Array.isArray(items) ? items : [];
|
|
765
921
|
notifyBadge();
|
|
766
922
|
}
|
|
923
|
+
/**
|
|
924
|
+
* The running sessions, as one number on the same bridge.
|
|
925
|
+
*
|
|
926
|
+
* Emitted from every render of the headless badge source, so the "did it
|
|
927
|
+
* actually change" guard is what keeps a re-render from repainting the tab
|
|
928
|
+
* icon and the brand mark for nothing. It is NOT routed through
|
|
929
|
+
* `emitRealBadge`: the unread number and the running number are independent
|
|
930
|
+
* facts, and a manual test number must not disturb the other.
|
|
931
|
+
* @param value - how many sessions are running; anything non-positive is 0.
|
|
932
|
+
*/
|
|
933
|
+
function emitRunningState(value) {
|
|
934
|
+
const next = normalizeCount(value);
|
|
935
|
+
if (next === runningTotal) return;
|
|
936
|
+
runningTotal = next;
|
|
937
|
+
notifyBadge();
|
|
938
|
+
}
|
|
767
939
|
function emitBadgeSize(value) {
|
|
768
940
|
badgeSize = Object.prototype.hasOwnProperty.call(BADGE_SCALES, value) ? value : "md";
|
|
769
941
|
notifyBadge();
|
|
@@ -957,6 +1129,202 @@ window.__ModuleLoader__.load({
|
|
|
957
1129
|
reconcileAppBadge(true);
|
|
958
1130
|
}
|
|
959
1131
|
|
|
1132
|
+
//#endregion
|
|
1133
|
+
//#region notifications permission (the WebKit half of app badging)
|
|
1134
|
+
// On WebKit the badge and the notifications permission are one feature:
|
|
1135
|
+
// Apple's own words are "when users allow a web app to send
|
|
1136
|
+
// notifications, that includes permissions for the web app to use
|
|
1137
|
+
// badging" — so a macOS web app added to the Dock (and an iOS
|
|
1138
|
+
// home-screen web app) draws NO badge until the user allows
|
|
1139
|
+
// notifications (sources: docs/adr/0009-notification-permission.md).
|
|
1140
|
+
//
|
|
1141
|
+
// This region does NOT gate `applyAppBadge`: Chromium needs none of it,
|
|
1142
|
+
// and the API only reports "not permitted" by drawing nothing, which is
|
|
1143
|
+
// undetectable. Keeping the call site unconditional is ADR 0005's rule;
|
|
1144
|
+
// what is added here is the PRECONDITION, made visible and requestable
|
|
1145
|
+
// from one user gesture.
|
|
1146
|
+
/** The three states the spec defines; anything else is "no API". */
|
|
1147
|
+
const NOTIFICATION_PERMISSION_STATES = ["granted", "denied", "default"];
|
|
1148
|
+
|
|
1149
|
+
/**
|
|
1150
|
+
* Normalize one reading of `Notification.permission` into a local fact.
|
|
1151
|
+
* @param value - the raw reading; only the spec strings count.
|
|
1152
|
+
* @returns `"granted" | "denied" | "default" | "unsupported"`.
|
|
1153
|
+
*/
|
|
1154
|
+
function normalizeNotificationPermission(value) {
|
|
1155
|
+
return typeof value === "string" && NOTIFICATION_PERMISSION_STATES.includes(value) ? value : "unsupported";
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* Normalize an already-shaped probe, so every reader of it takes dirty
|
|
1160
|
+
* input the same way: only the three spec states are real, and only a
|
|
1161
|
+
* strict `true` is a request entry point.
|
|
1162
|
+
* @param probe - a `{ state, canRequest }` probe, or anything else.
|
|
1163
|
+
* @returns the same two facts, normalized.
|
|
1164
|
+
*/
|
|
1165
|
+
function notificationPermissionFacts(probe) {
|
|
1166
|
+
const source = probe !== null && typeof probe === "object" ? probe : {};
|
|
1167
|
+
return {
|
|
1168
|
+
state: normalizeNotificationPermission(source.state),
|
|
1169
|
+
canRequest: source.canRequest === true
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* Shape a raw `{ permission, canRequest }` reading, mirroring
|
|
1175
|
+
* `badgeCapabilityProbe`: two independent local facts, no platform claim.
|
|
1176
|
+
* @param env - the raw reading; anything else reads false.
|
|
1177
|
+
* @returns the probe for this device.
|
|
1178
|
+
*/
|
|
1179
|
+
function notificationPermissionProbe(env) {
|
|
1180
|
+
const source = env !== null && typeof env === "object" ? env : {};
|
|
1181
|
+
return notificationPermissionFacts({
|
|
1182
|
+
state: normalizeNotificationPermission(source.permission),
|
|
1183
|
+
canRequest: source.canRequest === true
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/**
|
|
1188
|
+
* Read that fact off the window this code runs in.
|
|
1189
|
+
*
|
|
1190
|
+
* "unsupported" covers both a browser with no Notification API and a
|
|
1191
|
+
* permission string this code has never heard of — from the user's side
|
|
1192
|
+
* those are the same thing: there is nothing to grant here.
|
|
1193
|
+
* @returns the probe for this device.
|
|
1194
|
+
*/
|
|
1195
|
+
function notificationPermissionEnv() {
|
|
1196
|
+
const api = typeof Notification === "object" && Notification !== null ? Notification : null;
|
|
1197
|
+
return notificationPermissionProbe({
|
|
1198
|
+
permission: api === null ? undefined : api.permission,
|
|
1199
|
+
canRequest: api !== null && typeof api.requestPermission === "function"
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
/**
|
|
1204
|
+
* Which settings-page sentence describes this reading.
|
|
1205
|
+
* @param probe - a `notificationPermissionProbe` result.
|
|
1206
|
+
* @returns a locale key.
|
|
1207
|
+
*/
|
|
1208
|
+
function notificationPermissionKey(probe) {
|
|
1209
|
+
const facts = notificationPermissionFacts(probe);
|
|
1210
|
+
if (facts.state === "granted") return "notificationPermissionGranted";
|
|
1211
|
+
if (facts.state === "denied") return "notificationPermissionDenied";
|
|
1212
|
+
if (facts.state === "default") return "notificationPermissionDefault";
|
|
1213
|
+
return "notificationPermissionUnsupported";
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* Whether the "allow notifications" button can do anything.
|
|
1218
|
+
*
|
|
1219
|
+
* Only an undecided permission is worth a prompt: once granted there is
|
|
1220
|
+
* nothing left to ask, and once denied the browser refuses to prompt
|
|
1221
|
+
* again, so the settings page says where to change it instead of showing
|
|
1222
|
+
* a button that would silently do nothing.
|
|
1223
|
+
* @param probe - a `notificationPermissionProbe` result.
|
|
1224
|
+
* @returns true only when a prompt could still decide the fact.
|
|
1225
|
+
*/
|
|
1226
|
+
function notificationPermissionRequestable(probe) {
|
|
1227
|
+
const facts = notificationPermissionFacts(probe);
|
|
1228
|
+
return facts.state === "default" && facts.canRequest === true;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* Ask for the notifications permission — the ONE place that does.
|
|
1233
|
+
*
|
|
1234
|
+
* Must be called from a user gesture (the settings button's click):
|
|
1235
|
+
* WebKit and Chromium both drop a prompt that no activation asked for.
|
|
1236
|
+
* Resolves to a fresh probe and never rejects, so the caller can drop the
|
|
1237
|
+
* result straight into React state; the old callback form (Safari 15) is
|
|
1238
|
+
* why the returned promise is not the only path out.
|
|
1239
|
+
* @returns a Promise for the permission probe after the prompt.
|
|
1240
|
+
*/
|
|
1241
|
+
function requestNotificationPermission() {
|
|
1242
|
+
const api = typeof Notification === "object" && Notification !== null ? Notification : null;
|
|
1243
|
+
if (api === null || typeof api.requestPermission !== "function") return Promise.resolve(notificationPermissionEnv());
|
|
1244
|
+
return new Promise((resolve) => {
|
|
1245
|
+
// The prompt's own answer is authoritative when it comes back;
|
|
1246
|
+
// re-reading `.permission` covers the promise form and a rejection
|
|
1247
|
+
// (no user gesture, or not an installed web app on WebKit).
|
|
1248
|
+
const settle = (value) => {
|
|
1249
|
+
const next = notificationPermissionEnv();
|
|
1250
|
+
resolve({
|
|
1251
|
+
state: typeof value === "string" ? normalizeNotificationPermission(value) : next.state,
|
|
1252
|
+
canRequest: next.canRequest
|
|
1253
|
+
});
|
|
1254
|
+
};
|
|
1255
|
+
let returned;
|
|
1256
|
+
try {
|
|
1257
|
+
returned = api.requestPermission(settle);
|
|
1258
|
+
} catch {
|
|
1259
|
+
settle(undefined);
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
if (returned !== null && typeof returned === "object" && typeof returned.then === "function") {
|
|
1263
|
+
returned.then(settle, () => settle(undefined));
|
|
1264
|
+
}
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
/**
|
|
1269
|
+
* Follow the permission while this page is open.
|
|
1270
|
+
*
|
|
1271
|
+
* It can move outside the page (System Settings, the browser's own
|
|
1272
|
+
* settings, another tab's prompt), and a stale "not granted" beside a
|
|
1273
|
+
* granted permission is exactly the kind of lie this half avoids.
|
|
1274
|
+
* `permissions.query({ name: "notifications" })` is the precise channel;
|
|
1275
|
+
* a re-read on focus/visibilitychange is the portable one (WebKit does
|
|
1276
|
+
* not implement every permission descriptor), and both are cheap.
|
|
1277
|
+
* Publishes only when the fact actually changed.
|
|
1278
|
+
* @param fn - called with a fresh probe when the fact moved.
|
|
1279
|
+
* @returns an unsubscribe function.
|
|
1280
|
+
*/
|
|
1281
|
+
function subscribeNotificationPermission(fn) {
|
|
1282
|
+
if (typeof fn !== "function") return () => {};
|
|
1283
|
+
const win = typeof window !== "undefined" ? window : null;
|
|
1284
|
+
const navigatorObject = typeof navigator === "object" && navigator !== null ? navigator : null;
|
|
1285
|
+
const read = () => {
|
|
1286
|
+
const next = notificationPermissionEnv();
|
|
1287
|
+
return { next, id: `${next.state}:${next.canRequest === true}` };
|
|
1288
|
+
};
|
|
1289
|
+
// Seeded with the mount reading, so mounting never re-publishes the
|
|
1290
|
+
// value the settings page already rendered.
|
|
1291
|
+
let last = read().id;
|
|
1292
|
+
// Fire only on a real change: the settings page already rendered the
|
|
1293
|
+
// mount reading, and a focus event that changed nothing must not repaint.
|
|
1294
|
+
const publish = () => {
|
|
1295
|
+
const reading = read();
|
|
1296
|
+
if (reading.id === last) return;
|
|
1297
|
+
last = reading.id;
|
|
1298
|
+
try { fn(reading.next); } catch {}
|
|
1299
|
+
};
|
|
1300
|
+
/** @type {Array<() => void>} */
|
|
1301
|
+
const unsubscribes = [];
|
|
1302
|
+
if (win !== null && typeof win.addEventListener === "function") {
|
|
1303
|
+
for (const event of ["focus", "visibilitychange", "pageshow"]) {
|
|
1304
|
+
win.addEventListener(event, publish);
|
|
1305
|
+
unsubscribes.push(() => win.removeEventListener(event, publish));
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
try {
|
|
1309
|
+
if (navigatorObject !== null && navigatorObject.permissions && typeof navigatorObject.permissions.query === "function") {
|
|
1310
|
+
const pending = navigatorObject.permissions.query({ name: "notifications" });
|
|
1311
|
+
if (pending !== null && typeof pending === "object" && typeof pending.then === "function") {
|
|
1312
|
+
pending.then((status) => {
|
|
1313
|
+
if (status === null || typeof status !== "object" || typeof status.addEventListener !== "function") return;
|
|
1314
|
+
status.addEventListener("change", publish);
|
|
1315
|
+
unsubscribes.push(() => status.removeEventListener("change", publish));
|
|
1316
|
+
}).catch(() => {});
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
} catch {}
|
|
1320
|
+
return () => {
|
|
1321
|
+
for (const off of unsubscribes) {
|
|
1322
|
+
try { off(); } catch {}
|
|
1323
|
+
}
|
|
1324
|
+
unsubscribes.length = 0;
|
|
1325
|
+
};
|
|
1326
|
+
}
|
|
1327
|
+
|
|
960
1328
|
//#endregion
|
|
961
1329
|
//#region favicon compositing
|
|
962
1330
|
// The browser accepts exactly ONE image per <link rel="icon">, so a tab
|
|
@@ -1076,6 +1444,58 @@ window.__ModuleLoader__.load({
|
|
|
1076
1444
|
c2d.fillText(text, cx, cy + size * 0.01);
|
|
1077
1445
|
c2d.restore();
|
|
1078
1446
|
}
|
|
1447
|
+
/**
|
|
1448
|
+
* The common badge scale when BOTH tab-icon numbers are shown.
|
|
1449
|
+
*
|
|
1450
|
+
* A big red "2" beside a small yellow "1" reads as a rendering bug, so the
|
|
1451
|
+
* two pills share one size: the largest that fits them stacked inside the
|
|
1452
|
+
* canvas (four half-heights + the edge pads + the seam between them). The
|
|
1453
|
+
* constraint is absolute — it is the canvas that is small, not the setting —
|
|
1454
|
+
* so this returns a SCALE multiplier the caller folds into the normal one,
|
|
1455
|
+
* and a pill drawn alone keeps whatever size the setting asks for.
|
|
1456
|
+
* @param scale - the badge-size multiplier from the setting.
|
|
1457
|
+
* @returns a multiplier ≤ 1 to apply to it.
|
|
1458
|
+
*/
|
|
1459
|
+
function faviconBadgeFitScale(scale) {
|
|
1460
|
+
const k = typeof scale === "number" && scale > 0 ? scale : 1;
|
|
1461
|
+
// 0.205: four half-heights plus both white rings plus a seam, inside the
|
|
1462
|
+
// canvas. Anything larger and the two rings merge into one blob.
|
|
1463
|
+
return Math.min(1, 0.205 / (0.28 * k));
|
|
1464
|
+
}
|
|
1465
|
+
/**
|
|
1466
|
+
* The yellow "N running" badge on the tab icon: bottom-right.
|
|
1467
|
+
*
|
|
1468
|
+
* Mirrors `drawFaviconBadge` exactly — same geometry, same white ring, same
|
|
1469
|
+
* digit size for the same `scale` — so the two numbers can sit one above the
|
|
1470
|
+
* other as equals. Dark digits, because yellow needs dark ink.
|
|
1471
|
+
* @param c2d - the tab-icon canvas context.
|
|
1472
|
+
* @param size - canvas edge in px.
|
|
1473
|
+
* @param count - how many sessions are running.
|
|
1474
|
+
* @param scale - the badge-size multiplier (already fitted when paired).
|
|
1475
|
+
*/
|
|
1476
|
+
function drawFaviconRunningBadge(c2d, size, count, scale) {
|
|
1477
|
+
const text = badgeLabel(count);
|
|
1478
|
+
const r = size * 0.28 * (scale || 1);
|
|
1479
|
+
const wide = text.length > 1;
|
|
1480
|
+
const w = wide ? r * 2 + size * 0.16 * (text.length - 1) : r * 2;
|
|
1481
|
+
const h = r * 2;
|
|
1482
|
+
const cx = size - w / 2 - size * 0.02;
|
|
1483
|
+
const cy = size - h / 2 - size * 0.02;
|
|
1484
|
+
c2d.save();
|
|
1485
|
+
// White ring so the badge stays readable on any base artwork.
|
|
1486
|
+
c2d.fillStyle = "#ffffff";
|
|
1487
|
+
roundRect(c2d, cx - w / 2 - size * 0.03, cy - h / 2 - size * 0.03, w + size * 0.06, h + size * 0.06);
|
|
1488
|
+
c2d.fill();
|
|
1489
|
+
c2d.fillStyle = RUNNING_YELLOW;
|
|
1490
|
+
roundRect(c2d, cx - w / 2, cy - h / 2, w, h);
|
|
1491
|
+
c2d.fill();
|
|
1492
|
+
c2d.fillStyle = "#1f1f1f";
|
|
1493
|
+
c2d.font = `700 ${Math.round(h * (wide ? 0.56 : 0.68))}px system-ui, -apple-system, sans-serif`;
|
|
1494
|
+
c2d.textAlign = "center";
|
|
1495
|
+
c2d.textBaseline = "middle";
|
|
1496
|
+
c2d.fillText(text, cx, cy + size * 0.01);
|
|
1497
|
+
c2d.restore();
|
|
1498
|
+
}
|
|
1079
1499
|
function loadIconImage(href) {
|
|
1080
1500
|
return new Promise((resolve, reject) => {
|
|
1081
1501
|
const img = new Image();
|
|
@@ -1086,7 +1506,10 @@ window.__ModuleLoader__.load({
|
|
|
1086
1506
|
}
|
|
1087
1507
|
async function applyBadgeToFavicon(count, size) {
|
|
1088
1508
|
const base = desiredIconHref();
|
|
1089
|
-
|
|
1509
|
+
// Two independent marks ride one composite, so "nothing to draw" means
|
|
1510
|
+
// neither of them is wanted — not merely "no unread".
|
|
1511
|
+
const running = runningTotal > 0;
|
|
1512
|
+
if (count <= 0 && !running) {
|
|
1090
1513
|
// No badge to draw. With a custom icon active the page must carry it on
|
|
1091
1514
|
// EVERY link (a stale scheme-specific sibling outranks the one we set);
|
|
1092
1515
|
// with none, the shipped dark/light pair goes back untouched — the
|
|
@@ -1114,21 +1537,56 @@ window.__ModuleLoader__.load({
|
|
|
1114
1537
|
const dw = iw * scale;
|
|
1115
1538
|
const dh = ih * scale;
|
|
1116
1539
|
c2d.drawImage(img, (FAVICON_SIZE - dw) / 2, (FAVICON_SIZE - dh) / 2, dw, dh);
|
|
1117
|
-
|
|
1118
|
-
|
|
1540
|
+
// One size for both numbers: when they share the tab icon they shrink
|
|
1541
|
+
// together to the largest pair that fits, so neither reads as a bug and
|
|
1542
|
+
// the two can never overlap.
|
|
1543
|
+
const badgeUnit = badgeScale(size);
|
|
1544
|
+
const badgeK = count > 0 && running ? badgeUnit * faviconBadgeFitScale(badgeUnit) : badgeUnit;
|
|
1545
|
+
if (count > 0) drawFaviconBadge(c2d, FAVICON_SIZE, count, badgeK);
|
|
1546
|
+
if (running) drawFaviconRunningBadge(c2d, FAVICON_SIZE, runningTotal, badgeK); setIconEverywhere(canvas.toDataURL("image/png"), "image/png");
|
|
1119
1547
|
} catch (error) {
|
|
1120
1548
|
// Never silent: a failure here is invisible in the UI (the tab simply
|
|
1121
1549
|
// keeps its previous icon), which once cost a whole debugging session.
|
|
1122
1550
|
// Warn once per distinct reason so the 5s watchdog cannot spam.
|
|
1123
|
-
|
|
1124
|
-
if (why !== lastComposeError) {
|
|
1125
|
-
lastComposeError = why;
|
|
1126
|
-
try { console.warn("dsh-icon-custom: 标签页红点合成失败 / favicon badge compose failed:", why); } catch {}
|
|
1127
|
-
}
|
|
1551
|
+
warnFaviconCompose(error);
|
|
1128
1552
|
} finally {
|
|
1129
1553
|
if (seq === composeSeq) faviconComposing = false;
|
|
1130
1554
|
}
|
|
1131
1555
|
}
|
|
1556
|
+
/**
|
|
1557
|
+
* Report a failed tab-icon composition, once per distinct reason.
|
|
1558
|
+
*
|
|
1559
|
+
* The 5-second watchdog retries, so repeating the same line would bury the
|
|
1560
|
+
* console in noise; a NEW reason is exactly the signal worth seeing.
|
|
1561
|
+
* @param error - whatever the compose threw.
|
|
1562
|
+
*/
|
|
1563
|
+
function warnFaviconCompose(error) {
|
|
1564
|
+
const why = String((error && error.message) || error);
|
|
1565
|
+
if (why === lastComposeError) return;
|
|
1566
|
+
lastComposeError = why;
|
|
1567
|
+
try { console.warn("dsh-icon-custom: 标签页红点合成失败 / favicon badge compose failed:", why); } catch {}
|
|
1568
|
+
}
|
|
1569
|
+
/**
|
|
1570
|
+
* Fire-and-forget composition whose failures are still reported.
|
|
1571
|
+
*
|
|
1572
|
+
* `applyBadgeToFavicon` is async, so it never throws synchronously: a caller's
|
|
1573
|
+
* `try { ... } catch {}` (the badge listeners all have one) cannot see its
|
|
1574
|
+
* rejection, and the tab icon would simply never change with nothing in the
|
|
1575
|
+
* console — the exact "no warning, no badge" state. Every call site goes through
|
|
1576
|
+
* here instead.
|
|
1577
|
+
* @param count - sessions needing you; 0 restores the plain icon.
|
|
1578
|
+
* @param size - badge size key.
|
|
1579
|
+
*/
|
|
1580
|
+
function composeFavicon(count, size) {
|
|
1581
|
+
try {
|
|
1582
|
+
Promise.resolve(applyBadgeToFavicon(count, size)).catch(warnFaviconCompose);
|
|
1583
|
+
} catch (error) {
|
|
1584
|
+
// An `async` callee never throws synchronously, so this arm is dead TODAY —
|
|
1585
|
+
// it is here so a future refactor (an early-returning sync path) cannot
|
|
1586
|
+
// quietly reintroduce the silent "no warning, no badge" state.
|
|
1587
|
+
warnFaviconCompose(error);
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1132
1590
|
/**
|
|
1133
1591
|
* Re-assert the tab badge after anything outside this plugin disturbed the
|
|
1134
1592
|
* icon link — a cached page, a browser extension, a tab move, or a plugin
|
|
@@ -1141,21 +1599,21 @@ window.__ModuleLoader__.load({
|
|
|
1141
1599
|
// stale sibling is exactly what used to outrank the icon we set.
|
|
1142
1600
|
const hrefs = iconLinks().map((candidate) => candidate.getAttribute("href"));
|
|
1143
1601
|
const ours = hrefs.length > 0 && hrefs.every((value) => typeof value === "string" && value.slice(0, 5) === "data:");
|
|
1144
|
-
if (effectiveCount() <= 0) {
|
|
1602
|
+
if (effectiveCount() <= 0 && runningTotal <= 0) {
|
|
1145
1603
|
// Carries the custom icon, or puts the shipped pair back. Both writers
|
|
1146
1604
|
// only touch a link that actually differs, so this stays a no-op once
|
|
1147
1605
|
// the page is correct and cannot re-arm the head observer.
|
|
1148
|
-
|
|
1606
|
+
composeFavicon(0);
|
|
1149
1607
|
return;
|
|
1150
1608
|
}
|
|
1151
1609
|
if (ours || faviconComposing) return; // already showing, or a build is running
|
|
1152
|
-
|
|
1610
|
+
composeFavicon(effectiveCount(), badgeSize);
|
|
1153
1611
|
}
|
|
1154
1612
|
// A freshly applied base icon replaces whatever the browser holds, so the
|
|
1155
1613
|
// composite must be rebuilt from the NEW base instead of the stale one.
|
|
1156
1614
|
function refreshFaviconBase() {
|
|
1157
1615
|
faviconBase = null;
|
|
1158
|
-
|
|
1616
|
+
composeFavicon(effectiveCount(), badgeSize);
|
|
1159
1617
|
}
|
|
1160
1618
|
//#endregion
|
|
1161
1619
|
|
|
@@ -1354,6 +1812,18 @@ window.__ModuleLoader__.load({
|
|
|
1354
1812
|
*/
|
|
1355
1813
|
const WS_DOT_ATTR = "data-icon-custom-wsdot";
|
|
1356
1814
|
const ROW_DOT_ATTR = "data-icon-custom-rowdot";
|
|
1815
|
+
/** The running mark's two shapes: a dot after a session title (and on a folder icon), plus the header count. */
|
|
1816
|
+
const RUN_DOT_ATTR = "data-icon-custom-rundot";
|
|
1817
|
+
const RUN_WS_ATTR = "data-icon-custom-runws";
|
|
1818
|
+
/**
|
|
1819
|
+
* How far the folder icon's red dot steps left when the yellow dot joins it.
|
|
1820
|
+
*
|
|
1821
|
+
* An 8px dot plus a 2px seam: the pair then still sits over the icon's right
|
|
1822
|
+
* half instead of one of them hanging off into the workspace name. The red
|
|
1823
|
+
* steps back the moment the yellow is gone, so the unread marker keeps its
|
|
1824
|
+
* old place whenever it is alone.
|
|
1825
|
+
*/
|
|
1826
|
+
const WS_DOT_PAIR_SHIFT = 10;
|
|
1357
1827
|
const WS_ROW_MIN_H = 18;
|
|
1358
1828
|
const WS_ROW_MAX_H = 56;
|
|
1359
1829
|
const WS_ROW_MIN_W = 80;
|
|
@@ -1361,6 +1831,10 @@ window.__ModuleLoader__.load({
|
|
|
1361
1831
|
let workspaceDots = [];
|
|
1362
1832
|
/** One entry per unread session: `{ id, title }`, matched against the row title. */
|
|
1363
1833
|
let sessionDots = [];
|
|
1834
|
+
/** One entry per RUNNING session: `{ id, title }` — same shape, same row handle. */
|
|
1835
|
+
let runningSessionDots = [];
|
|
1836
|
+
/** One entry per workspace with running sessions: `{ id, name, count }`. */
|
|
1837
|
+
let runningWorkspaceMarks = [];
|
|
1364
1838
|
let workspaceDotFailures = 0;
|
|
1365
1839
|
let workspaceDotGivenUp = false;
|
|
1366
1840
|
let workspaceDotPainting = false;
|
|
@@ -1375,6 +1849,11 @@ window.__ModuleLoader__.load({
|
|
|
1375
1849
|
const next = marks !== null && typeof marks === "object" ? marks : {};
|
|
1376
1850
|
workspaceDots = Array.isArray(next.workspaces) ? next.workspaces : [];
|
|
1377
1851
|
sessionDots = Array.isArray(next.sessions) ? next.sessions : [];
|
|
1852
|
+
// The running marks ride the same store and the same paint pass as the
|
|
1853
|
+
// unread ones, but they are a different feature: they carry their own
|
|
1854
|
+
// arrays, and whichever switch is off empties only its own.
|
|
1855
|
+
runningSessionDots = Array.isArray(next.runningSessions) ? next.runningSessions : [];
|
|
1856
|
+
runningWorkspaceMarks = Array.isArray(next.runningWorkspaces) ? next.runningWorkspaces : [];
|
|
1378
1857
|
workspaceDotListeners.forEach((fn) => { try { fn(); } catch {} });
|
|
1379
1858
|
}
|
|
1380
1859
|
function subscribeSidebarMarks(fn) {
|
|
@@ -1388,7 +1867,7 @@ window.__ModuleLoader__.load({
|
|
|
1388
1867
|
|| null;
|
|
1389
1868
|
}
|
|
1390
1869
|
function clearWorkspaceDots() {
|
|
1391
|
-
try { document.querySelectorAll("[" + WS_DOT_ATTR + "],[" + ROW_DOT_ATTR + "]").forEach((node) => node.remove()); } catch {}
|
|
1870
|
+
try { document.querySelectorAll("[" + WS_DOT_ATTR + "],[" + ROW_DOT_ATTR + "],[" + RUN_DOT_ATTR + "],[" + RUN_WS_ATTR + "]").forEach((node) => node.remove()); } catch {}
|
|
1392
1871
|
for (const entry of workspaceDotRestores) { try { entry.el.style.position = entry.position; } catch {} }
|
|
1393
1872
|
workspaceDotRestores = [];
|
|
1394
1873
|
}
|
|
@@ -1591,6 +2070,11 @@ window.__ModuleLoader__.load({
|
|
|
1591
2070
|
const enabled = unreadConfig.workspaceDot !== false;
|
|
1592
2071
|
const wanted = enabled ? workspaceDots : [];
|
|
1593
2072
|
const wantedRows = enabled ? sessionDots : [];
|
|
2073
|
+
// A separate switch for a separate feature: the running marks keep working
|
|
2074
|
+
// when the unread dots are turned off, and vice versa.
|
|
2075
|
+
const runningEnabled = runningConfig.enabled !== false;
|
|
2076
|
+
const wantedRunningRows = runningEnabled ? runningSessionDots : [];
|
|
2077
|
+
const wantedRunningMarks = runningEnabled ? runningWorkspaceMarks : [];
|
|
1594
2078
|
const container = workspaceDotContainer();
|
|
1595
2079
|
workspaceDotPainting = true;
|
|
1596
2080
|
workspaceDotPaintedAt = Date.now();
|
|
@@ -1603,12 +2087,20 @@ window.__ModuleLoader__.load({
|
|
|
1603
2087
|
// matched twice for a moment) can no longer blink an existing dot away.
|
|
1604
2088
|
const wantedNames = new Set(wanted.map((entry) => workspaceDotKey(entry)));
|
|
1605
2089
|
const wantedIds = new Set(wantedRows.map((entry) => entry.id));
|
|
2090
|
+
const wantedRunningIds = new Set(wantedRunningRows.map((entry) => entry.id));
|
|
2091
|
+
const wantedRunningNames = new Set(wantedRunningMarks.map((entry) => workspaceDotKey(entry)));
|
|
1606
2092
|
document.querySelectorAll("[" + WS_DOT_ATTR + "]").forEach((node) => {
|
|
1607
2093
|
if (!node.isConnected || !wantedNames.has(node.getAttribute(WS_DOT_ATTR))) node.remove();
|
|
1608
2094
|
});
|
|
1609
2095
|
document.querySelectorAll("[" + ROW_DOT_ATTR + "]").forEach((node) => {
|
|
1610
2096
|
if (!node.isConnected || !wantedIds.has(node.getAttribute(ROW_DOT_ATTR))) node.remove();
|
|
1611
2097
|
});
|
|
2098
|
+
document.querySelectorAll("[" + RUN_DOT_ATTR + "]").forEach((node) => {
|
|
2099
|
+
if (!node.isConnected || !wantedRunningIds.has(node.getAttribute(RUN_DOT_ATTR))) node.remove();
|
|
2100
|
+
});
|
|
2101
|
+
document.querySelectorAll("[" + RUN_WS_ATTR + "]").forEach((node) => {
|
|
2102
|
+
if (!node.isConnected || !wantedRunningNames.has(node.getAttribute(RUN_WS_ATTR))) node.remove();
|
|
2103
|
+
});
|
|
1612
2104
|
if (container === null) { workspaceDotPainting = false; return; } // start-up race
|
|
1613
2105
|
const legacyTitleMatch = !sessionRowKeysInUse(container);
|
|
1614
2106
|
for (const entry of wanted) {
|
|
@@ -1624,7 +2116,6 @@ window.__ModuleLoader__.load({
|
|
|
1624
2116
|
}
|
|
1625
2117
|
if (target === null) { missed++; continue; }
|
|
1626
2118
|
const row = target.row;
|
|
1627
|
-
if (existingMarker(row, WS_DOT_ATTR, key) !== null) { placed++; continue; }
|
|
1628
2119
|
const icon = target.icon;
|
|
1629
2120
|
const rowRect = row.getBoundingClientRect();
|
|
1630
2121
|
const anchorRect = icon.getBoundingClientRect();
|
|
@@ -1632,24 +2123,95 @@ window.__ModuleLoader__.load({
|
|
|
1632
2123
|
workspaceDotRestores.push({ el: row, position: row.style.position });
|
|
1633
2124
|
row.style.position = "relative";
|
|
1634
2125
|
}
|
|
2126
|
+
// "Left red, right yellow": the yellow takes the corner this dot has
|
|
2127
|
+
// always used, and the red steps one dot to the left while the pair is
|
|
2128
|
+
// together (and steps back the moment the yellow is gone).
|
|
2129
|
+
const paired = existingMarker(row, RUN_WS_ATTR, key) !== null;
|
|
2130
|
+
const left = Math.round(anchorRect.right - rowRect.left - 4 - (paired ? WS_DOT_PAIR_SHIFT : 0));
|
|
2131
|
+
const top = Math.round(anchorRect.top - rowRect.top - 3);
|
|
2132
|
+
const existingDot = existingMarker(row, WS_DOT_ATTR, key);
|
|
2133
|
+
if (existingDot !== null) {
|
|
2134
|
+
if (existingDot.style.left !== left + "px") existingDot.style.left = left + "px";
|
|
2135
|
+
if (existingDot.style.top !== top + "px") existingDot.style.top = top + "px";
|
|
2136
|
+
placed++;
|
|
2137
|
+
continue;
|
|
2138
|
+
}
|
|
1635
2139
|
const dot = document.createElement("span");
|
|
1636
2140
|
dot.setAttribute(WS_DOT_ATTR, key);
|
|
1637
2141
|
dot.setAttribute("aria-hidden", "true");
|
|
1638
2142
|
dot.title = (typeof entry.name === "string" && entry.name !== "" ? entry.name : key) + " · " + entry.count;
|
|
1639
2143
|
dot.style.cssText = "position:absolute;pointer-events:none;width:8px;height:8px;border-radius:50%;"
|
|
1640
|
-
+ "background:#e5484d;box-shadow:0 0 0 1.5px var(--dsw-alias-bg-layer-1,#fff);"
|
|
1641
|
-
|
|
1642
|
-
|
|
2144
|
+
+ "background:#e5484d;box-shadow:0 0 0 1.5px var(--dsw-alias-bg-layer-1,#fff);";
|
|
2145
|
+
dot.style.left = left + "px";
|
|
2146
|
+
dot.style.top = top + "px";
|
|
1643
2147
|
row.appendChild(dot);
|
|
1644
2148
|
placed++;
|
|
1645
2149
|
created++;
|
|
1646
2150
|
}
|
|
1647
|
-
//
|
|
1648
|
-
//
|
|
2151
|
+
// Workspace rows: the running mark is a yellow DOT on the folder icon
|
|
2152
|
+
// (the count lives in the section header, where a number is readable).
|
|
2153
|
+
// "Left red, right yellow": the yellow takes the corner the red has
|
|
2154
|
+
// always used and the red steps aside by exactly one dot.
|
|
2155
|
+
for (const entry of wantedRunningMarks) {
|
|
2156
|
+
const key = workspaceDotKey(entry);
|
|
2157
|
+
if (key === undefined) { missed++; continue; }
|
|
2158
|
+
const keyedRow = workspaceRowElement(container, entry.id);
|
|
2159
|
+
const target = keyedRow === null ? null : folderTargetFor(keyedRow);
|
|
2160
|
+
if (target === null) { missed++; continue; }
|
|
2161
|
+
const row = target.row;
|
|
2162
|
+
const runDot = existingMarker(row, RUN_WS_ATTR, key);
|
|
2163
|
+
const icon = target.icon;
|
|
2164
|
+
const rowRect = row.getBoundingClientRect();
|
|
2165
|
+
const anchorRect = icon.getBoundingClientRect();
|
|
2166
|
+
if (window.getComputedStyle(row).position === "static") {
|
|
2167
|
+
workspaceDotRestores.push({ el: row, position: row.style.position });
|
|
2168
|
+
row.style.position = "relative";
|
|
2169
|
+
}
|
|
2170
|
+
const corner = Math.round(anchorRect.right - rowRect.left - 4);
|
|
2171
|
+
const top = Math.round(anchorRect.top - rowRect.top - 3);
|
|
2172
|
+
// The red keeps the pair's LEFT seat. It is nudged here as well as in
|
|
2173
|
+
// its own loop because on the first paint it cannot see a yellow that
|
|
2174
|
+
// does not exist yet — both places compute the same number, so the
|
|
2175
|
+
// pair never oscillates.
|
|
2176
|
+
const red = existingMarker(row, WS_DOT_ATTR, key);
|
|
2177
|
+
if (red !== null) {
|
|
2178
|
+
const redLeft = corner - WS_DOT_PAIR_SHIFT;
|
|
2179
|
+
if (red.style.left !== redLeft + "px") red.style.left = redLeft + "px";
|
|
2180
|
+
if (red.style.top !== top + "px") red.style.top = top + "px";
|
|
2181
|
+
}
|
|
2182
|
+
if (runDot !== null) {
|
|
2183
|
+
if (runDot.style.left !== corner + "px") runDot.style.left = corner + "px";
|
|
2184
|
+
if (runDot.style.top !== top + "px") runDot.style.top = top + "px";
|
|
2185
|
+
placed++;
|
|
2186
|
+
continue;
|
|
2187
|
+
}
|
|
2188
|
+
const mark = document.createElement("span");
|
|
2189
|
+
mark.setAttribute(RUN_WS_ATTR, key);
|
|
2190
|
+
mark.setAttribute("aria-hidden", "true");
|
|
2191
|
+
mark.title = (entry.name !== "" ? entry.name : key) + " · " + entry.count;
|
|
2192
|
+
mark.style.cssText = "position:absolute;pointer-events:none;width:8px;height:8px;border-radius:50%;"
|
|
2193
|
+
+ "background:" + RUNNING_YELLOW + ";box-shadow:0 0 0 1.5px var(--dsw-alias-bg-layer-1,#fff);";
|
|
2194
|
+
mark.style.left = corner + "px";
|
|
2195
|
+
mark.style.top = top + "px";
|
|
2196
|
+
row.appendChild(mark);
|
|
2197
|
+
placed++;
|
|
2198
|
+
created++;
|
|
2199
|
+
}
|
|
2200
|
+
// Session rows: RED first, then YELLOW — "left red, right yellow", and a
|
|
2201
|
+
// fixed order no matter which of the two features changed last.
|
|
1649
2202
|
for (const entry of wantedRows) {
|
|
1650
2203
|
const target = sessionRowTarget(container, entry.id, entry.title, legacyTitleMatch);
|
|
1651
2204
|
if (target === null) { missed++; continue; }
|
|
1652
|
-
|
|
2205
|
+
const existing = existingMarker(target.row, ROW_DOT_ATTR, entry.id);
|
|
2206
|
+
if (existing !== null) {
|
|
2207
|
+
// Both markers can survive a repaint; keep the red in front of the
|
|
2208
|
+
// yellow (a red painted while the yellow was gone would otherwise
|
|
2209
|
+
// stay glued to the title).
|
|
2210
|
+
const runDot = existingMarker(target.row, RUN_DOT_ATTR, entry.id);
|
|
2211
|
+
if (runDot !== null && existing.nextElementSibling !== runDot) existing.insertAdjacentElement("afterend", runDot);
|
|
2212
|
+
placed++;
|
|
2213
|
+
continue;
|
|
2214
|
+
}
|
|
1653
2215
|
const dot = document.createElement("span");
|
|
1654
2216
|
dot.setAttribute(ROW_DOT_ATTR, entry.id);
|
|
1655
2217
|
dot.setAttribute("aria-hidden", "true");
|
|
@@ -1657,11 +2219,27 @@ window.__ModuleLoader__.load({
|
|
|
1657
2219
|
// Tight to the title on the left, and kept off the timestamp on the
|
|
1658
2220
|
// right: with no right margin the dot reads as part of "24 分钟".
|
|
1659
2221
|
dot.style.cssText = "pointer-events:none;flex:none;width:8px;height:8px;border-radius:50%;"
|
|
1660
|
-
+ "background:#e5484d;margin:0
|
|
2222
|
+
+ "background:#e5484d;margin:0 5px 0 6px;";
|
|
1661
2223
|
target.title.insertAdjacentElement("afterend", dot);
|
|
1662
2224
|
placed++;
|
|
1663
2225
|
created++;
|
|
1664
2226
|
}
|
|
2227
|
+
for (const entry of wantedRunningRows) {
|
|
2228
|
+
const target = sessionRowTarget(container, entry.id, entry.title, legacyTitleMatch);
|
|
2229
|
+
if (target === null) { missed++; continue; }
|
|
2230
|
+
if (existingMarker(target.row, RUN_DOT_ATTR, entry.id) !== null) { placed++; continue; }
|
|
2231
|
+
const runDot = document.createElement("span");
|
|
2232
|
+
runDot.setAttribute(RUN_DOT_ATTR, entry.id);
|
|
2233
|
+
runDot.setAttribute("aria-hidden", "true");
|
|
2234
|
+
runDot.title = entry.title;
|
|
2235
|
+
runDot.style.cssText = "pointer-events:none;flex:none;width:8px;height:8px;border-radius:50%;"
|
|
2236
|
+
+ "background:" + RUNNING_YELLOW + ";margin:0 4px 0 0;";
|
|
2237
|
+
const red = existingMarker(target.row, ROW_DOT_ATTR, entry.id);
|
|
2238
|
+
if (red !== null) red.insertAdjacentElement("afterend", runDot);
|
|
2239
|
+
else target.title.insertAdjacentElement("afterend", runDot);
|
|
2240
|
+
placed++;
|
|
2241
|
+
created++;
|
|
2242
|
+
}
|
|
1665
2243
|
} catch (error) {
|
|
1666
2244
|
missed++;
|
|
1667
2245
|
noteWorkspaceDotFailure(String((error && error.message) || error));
|
|
@@ -1720,6 +2298,10 @@ window.__ModuleLoader__.load({
|
|
|
1720
2298
|
const workspaces = typeof props.useWorkspaces === "function" ? props.useWorkspaces((state) => state) : undefined;
|
|
1721
2299
|
const [config, setConfig] = React.useState(unreadConfig);
|
|
1722
2300
|
React.useEffect(() => subscribeUnreadConfig(setConfig), []);
|
|
2301
|
+
// The running mark's own switch. Same shape, separate feature: the two
|
|
2302
|
+
// never share state, so one switch can never move the other's marks.
|
|
2303
|
+
const [running, setRunning] = React.useState(runningConfig);
|
|
2304
|
+
React.useEffect(() => subscribeRunningConfig(setRunning), []);
|
|
1723
2305
|
// Baseline new sessions; persists only when it actually learns something.
|
|
1724
2306
|
React.useEffect(() => {
|
|
1725
2307
|
const ids = list !== null && typeof list === "object" && Array.isArray(list.ids) ? list.ids : [];
|
|
@@ -1732,6 +2314,9 @@ window.__ModuleLoader__.load({
|
|
|
1732
2314
|
// conversation (its own standard prop, the same one the shipped sidebar reads)
|
|
1733
2315
|
// and the tab going to the background. Both are read here, not inferred, so
|
|
1734
2316
|
// "you are looking at it" stops being a guess about stale retention.
|
|
2317
|
+
// Pausing signals, NOT leaving signals: they stop the stay clock without
|
|
2318
|
+
// touching which session this page is on (so the entered-at snapshot
|
|
2319
|
+
// survives). Only switching to another session counts as leaving.
|
|
1735
2320
|
const panelActive = typeof props.usePanelInfo === "function"
|
|
1736
2321
|
? props.usePanelInfo((info) => info !== null && typeof info === "object" && info.activePanelId !== null)
|
|
1737
2322
|
: false;
|
|
@@ -1741,7 +2326,9 @@ window.__ModuleLoader__.load({
|
|
|
1741
2326
|
window.addEventListener("visibilitychange", onVisibility);
|
|
1742
2327
|
return () => window.removeEventListener("visibilitychange", onVisibility);
|
|
1743
2328
|
}, []);
|
|
1744
|
-
|
|
2329
|
+
/** Looking at the conversation right now: on screen, and this tab in front. */
|
|
2330
|
+
const watching = visible && panelActive !== true;
|
|
2331
|
+
const currentIds = currentSessionIds(list, { displayed: displayedSessionId() });
|
|
1745
2332
|
const items = collectUnread(list, pending, config, seenState.seen);
|
|
1746
2333
|
const count = items.length;
|
|
1747
2334
|
// Being in a session is what marks it read — and the clock that does it is
|
|
@@ -1778,8 +2365,10 @@ window.__ModuleLoader__.load({
|
|
|
1778
2365
|
/**
|
|
1779
2366
|
* Entered-at snapshot: `id → endAt` as it stood when that session joined
|
|
1780
2367
|
* the view. Deliberately NOT refreshed while the session stays — that is
|
|
1781
|
-
* the rule ("an ending during your stay waits for the next entry").
|
|
1782
|
-
* session that
|
|
2368
|
+
* the rule ("an ending during your stay waits for the next entry"). Only a
|
|
2369
|
+
* session that LEAVES the view is forgotten, so coming back to it takes a
|
|
2370
|
+
* fresh snapshot; pausing (background tab, a global panel) keeps both the
|
|
2371
|
+
* session and its snapshot.
|
|
1783
2372
|
*/
|
|
1784
2373
|
const stayArmed = React.useRef(new Map());
|
|
1785
2374
|
React.useEffect(() => {
|
|
@@ -1791,7 +2380,10 @@ window.__ModuleLoader__.load({
|
|
|
1791
2380
|
for (const id of stayIds) {
|
|
1792
2381
|
if (!stayArmed.current.has(id)) stayArmed.current.set(id, lastTurnEndAt(snapshot[id]));
|
|
1793
2382
|
}
|
|
1794
|
-
|
|
2383
|
+
// Paused: nothing may be acknowledged while nobody is watching, but the
|
|
2384
|
+
// snapshots above stay put — that is what makes resuming continue the
|
|
2385
|
+
// same entry instead of silently re-entering it.
|
|
2386
|
+
if (staySignature === "" || watching !== true) return undefined;
|
|
1795
2387
|
const armed = stayIds.map((id) => ({ id, endAt: stayArmed.current.get(id) }));
|
|
1796
2388
|
let cancelled = false;
|
|
1797
2389
|
let timer = 0;
|
|
@@ -1812,26 +2404,51 @@ window.__ModuleLoader__.load({
|
|
|
1812
2404
|
cancelled = true;
|
|
1813
2405
|
if (timer !== 0) window.clearTimeout(timer);
|
|
1814
2406
|
};
|
|
1815
|
-
}, [staySignature, delayMs]);
|
|
2407
|
+
}, [staySignature, delayMs, watching]);
|
|
1816
2408
|
React.useEffect(() => { emitRealBadge(count, items); }, [count, items]);
|
|
2409
|
+
// The running fact, straight off the same two official props and with no
|
|
2410
|
+
// host round trip at all. `runningSignature` is what the marks effect below
|
|
2411
|
+
// depends on, so a list re-render with the same set of running sessions
|
|
2412
|
+
// does not repaint the sidebar.
|
|
2413
|
+
const runningIds = running.enabled === false ? [] : runningSessionIds(list, status);
|
|
2414
|
+
const runningSignature = runningIds.join("|");
|
|
2415
|
+
const runningCount = runningIds.length;
|
|
2416
|
+
React.useEffect(() => { emitRunningState(runningCount); }, [runningCount]);
|
|
2417
|
+
/**
|
|
2418
|
+
* One session's displayed title, the way the sidebar shows it.
|
|
2419
|
+
*
|
|
2420
|
+
* Kept in step with `collectUnread`'s own `titleOf`: the yellow dot is
|
|
2421
|
+
* matched against the row's rendered text, so it must use the same
|
|
2422
|
+
* `displayTitle` fallback the red dot uses, or a session whose generated
|
|
2423
|
+
* title has not landed yet would get no yellow dot at all.
|
|
2424
|
+
*/
|
|
2425
|
+
const titleOf = (id, entry) => (entry !== null && typeof entry === "object" && typeof entry.displayTitle === "string" && entry.displayTitle !== "" ? entry.displayTitle : id);
|
|
1817
2426
|
// Map session → its Workspace from the official snapshot: the ID anchors the
|
|
1818
2427
|
// folder dot to the keyed row, the title is what the row displays (not always
|
|
1819
2428
|
// the cwd basename, and not always equal to the stored title either).
|
|
1820
2429
|
React.useEffect(() => {
|
|
1821
2430
|
const owners = {};
|
|
1822
|
-
const
|
|
1823
|
-
for (const workspace of
|
|
2431
|
+
const workspaceItems = workspaces !== null && typeof workspaces === "object" && Array.isArray(workspaces.items) ? workspaces.items : [];
|
|
2432
|
+
for (const workspace of workspaceItems) {
|
|
1824
2433
|
if (workspace === null || typeof workspace !== "object") continue;
|
|
1825
2434
|
if (!Array.isArray(workspace.sessionIds)) continue;
|
|
1826
2435
|
const id = typeof workspace.workspaceId === "string" ? workspace.workspaceId : "";
|
|
1827
2436
|
const title = typeof workspace.title === "string" ? workspace.title : "";
|
|
1828
2437
|
for (const sessionId of workspace.sessionIds) owners[sessionId] = { id, title };
|
|
1829
2438
|
}
|
|
2439
|
+
// The session rows, for naming the running ones the way the sidebar shows
|
|
2440
|
+
// them (`displayTitle`, the same fallback the red dots use).
|
|
2441
|
+
const byId = list !== null && typeof list === "object" && list.byId !== null && typeof list.byId === "object" ? list.byId : {};
|
|
1830
2442
|
emitSidebarMarks({
|
|
1831
2443
|
workspaces: dotsFromItems(items, owners),
|
|
1832
|
-
sessions: items.map((item) => ({ id: item.id, title: item.title }))
|
|
2444
|
+
sessions: items.map((item) => ({ id: item.id, title: item.title })),
|
|
2445
|
+
// The same rows, the other fact. Both arrays travel together, so the
|
|
2446
|
+
// paint pass can put the yellow dot in front of the red one
|
|
2447
|
+
// deterministically instead of depending on which feature changed last.
|
|
2448
|
+
runningSessions: runningIds.map((id) => ({ id, title: titleOf(id, byId[id]) })),
|
|
2449
|
+
runningWorkspaces: runningWorkspaceDots(runningIds, owners)
|
|
1833
2450
|
});
|
|
1834
|
-
}, [items, workspaces]);
|
|
2451
|
+
}, [items, workspaces, runningSignature, running]);
|
|
1835
2452
|
return null;
|
|
1836
2453
|
}
|
|
1837
2454
|
|
|
@@ -1858,6 +2475,8 @@ window.__ModuleLoader__.load({
|
|
|
1858
2475
|
* the old sidebar-foot button is gone.
|
|
1859
2476
|
*/
|
|
1860
2477
|
const HEAD_BADGE_ATTR = "data-icon-custom-unreadbadge";
|
|
2478
|
+
/** The running count's own node in the same header row — bottom-right of the label. */
|
|
2479
|
+
const HEAD_RUN_ATTR = "data-icon-custom-runbadge";
|
|
1861
2480
|
const HEAD_BADGE_STYLE_ID = "dsh-icon-custom-unreadbadge-css";
|
|
1862
2481
|
const HEAD_BADGE_WIRED = "data-icon-custom-unreadbadge-wired";
|
|
1863
2482
|
const HEAD_BADGE_MAX_FAILURES = 40;
|
|
@@ -1865,6 +2484,8 @@ window.__ModuleLoader__.load({
|
|
|
1865
2484
|
const HEAD_BADGE_MAX_STEPS = 6;
|
|
1866
2485
|
/** Raise above the label's top edge; the header only leaves 8px of headroom. */
|
|
1867
2486
|
const HEAD_BADGE_RAISE = 7;
|
|
2487
|
+
/** Drop below the label's bottom edge; mirrors the raise, and still fits the 36px header. */
|
|
2488
|
+
const HEAD_RUN_DROP = 8;
|
|
1868
2489
|
/** Overhang past the label's right edge. */
|
|
1869
2490
|
const HEAD_BADGE_OVERHANG = 9;
|
|
1870
2491
|
let headerBadgeHost = null;
|
|
@@ -1892,7 +2513,10 @@ window.__ModuleLoader__.load({
|
|
|
1892
2513
|
`[${HEAD_BADGE_ATTR}]{position:absolute;display:none;align-items:center;justify-content:center;min-width:15px;height:15px;padding:0 3.5px;box-sizing:border-box;border:0;border-radius:999px;background:#e5484d;color:#fff;font:inherit;font-size:9.5px;font-weight:700;line-height:1;font-variant-numeric:tabular-nums;letter-spacing:-.02em;cursor:pointer;z-index:3;box-shadow:0 0 0 1.5px var(--dsw-specific-sidebar-fill,#fff)}`,
|
|
1893
2514
|
`[${HEAD_BADGE_ATTR}]::after{content:"";position:absolute;inset:0 -5px -5px -5px;border-radius:999px}`,
|
|
1894
2515
|
`[${HEAD_BADGE_ATTR}]:hover{filter:brightness(1.12)}`,
|
|
1895
|
-
`[${HEAD_BADGE_ATTR}][data-zero="1"]{background:var(--dsw-alias-state-idle-primary,#b6bcc4)}
|
|
2516
|
+
`[${HEAD_BADGE_ATTR}][data-zero="1"]{background:var(--dsw-alias-state-idle-primary,#b6bcc4)}`,
|
|
2517
|
+
// The running count: same pill, other corner, not interactive. It has no
|
|
2518
|
+
// zero state — a yellow 0 would be pure noise — so it is shown or hidden.
|
|
2519
|
+
`[${HEAD_RUN_ATTR}]{position:absolute;display:none;align-items:center;justify-content:center;min-width:15px;height:15px;padding:0 3.5px;box-sizing:border-box;border:0;border-radius:999px;background:${RUNNING_YELLOW};color:#1f1f1f;font:inherit;font-size:9.5px;font-weight:700;line-height:1;font-variant-numeric:tabular-nums;letter-spacing:-.02em;pointer-events:none;z-index:3;box-shadow:0 0 0 1.5px var(--dsw-specific-sidebar-fill,#fff)}`
|
|
1896
2520
|
].join("");
|
|
1897
2521
|
|
|
1898
2522
|
/** Install the badge rules once per page. */
|
|
@@ -1919,11 +2543,13 @@ window.__ModuleLoader__.load({
|
|
|
1919
2543
|
* @returns `{ row, label }`, or null when this build's DOM is not recognised.
|
|
1920
2544
|
*/
|
|
1921
2545
|
function sectionHeaderParts(container) {
|
|
1922
|
-
// Our own
|
|
1923
|
-
//
|
|
2546
|
+
// Our own nodes are never a stand-in for shipped controls or for the label:
|
|
2547
|
+
// the red badge is a button and the yellow one carries text, so either could
|
|
2548
|
+
// be mistaken for the search control or the label if left in the running.
|
|
1924
2549
|
let firstButton = null;
|
|
1925
2550
|
for (const candidate of container.querySelectorAll("button")) {
|
|
1926
2551
|
if (candidate.getAttribute(HEAD_BADGE_ATTR) !== null) continue;
|
|
2552
|
+
if (candidate.getAttribute(HEAD_RUN_ATTR) !== null) continue;
|
|
1927
2553
|
firstButton = candidate;
|
|
1928
2554
|
break;
|
|
1929
2555
|
}
|
|
@@ -1933,6 +2559,8 @@ window.__ModuleLoader__.load({
|
|
|
1933
2559
|
for (const child of row.children) {
|
|
1934
2560
|
if (child === firstButton || child.contains(firstButton)) continue;
|
|
1935
2561
|
if (child.tagName === "BUTTON") continue;
|
|
2562
|
+
if (child.getAttribute(HEAD_BADGE_ATTR) !== null) continue;
|
|
2563
|
+
if (child.getAttribute(HEAD_RUN_ATTR) !== null) continue;
|
|
1936
2564
|
if (child.querySelector("button") !== null) continue;
|
|
1937
2565
|
if ((child.textContent ?? "").trim() !== "") return { row, label: child };
|
|
1938
2566
|
}
|
|
@@ -2012,6 +2640,24 @@ window.__ModuleLoader__.load({
|
|
|
2012
2640
|
}
|
|
2013
2641
|
}
|
|
2014
2642
|
if (node.textContent !== text) node.textContent = text;
|
|
2643
|
+
// The running count, in the same row's other corner: top-right is the unread
|
|
2644
|
+
// number (where it has always been), bottom-right is this one.
|
|
2645
|
+
let runNode = row.querySelector(`[${HEAD_RUN_ATTR}]`);
|
|
2646
|
+
if (runNode === null) {
|
|
2647
|
+
runNode = document.createElement("span");
|
|
2648
|
+
runNode.setAttribute(HEAD_RUN_ATTR, "1");
|
|
2649
|
+
row.appendChild(runNode);
|
|
2650
|
+
}
|
|
2651
|
+
const running = normalizeCount(runningTotal);
|
|
2652
|
+
const runText = badgeLabel(running);
|
|
2653
|
+
if (typeof badgeT === "function") {
|
|
2654
|
+
const spoken = badgeT("runningMarkCount").replace("{n}", String(running));
|
|
2655
|
+
if (runNode.getAttribute("aria-label") !== spoken) {
|
|
2656
|
+
runNode.setAttribute("aria-label", spoken);
|
|
2657
|
+
runNode.setAttribute("title", spoken);
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2660
|
+
if (runNode.textContent !== runText) runNode.textContent = runText;
|
|
2015
2661
|
// The label collapses to nothing while the search box is open; the badge
|
|
2016
2662
|
// has no meaning then, and the label's own visibility cannot hide it
|
|
2017
2663
|
// because it is a sibling.
|
|
@@ -2019,6 +2665,7 @@ window.__ModuleLoader__.load({
|
|
|
2019
2665
|
const labelRect = label.getBoundingClientRect();
|
|
2020
2666
|
if (labelRect.width < 8) {
|
|
2021
2667
|
if (node.style.display !== "none") node.style.display = "none";
|
|
2668
|
+
if (runNode.style.display !== "none") runNode.style.display = "none";
|
|
2022
2669
|
} else {
|
|
2023
2670
|
const width = typeof node.offsetWidth === "number" && node.offsetWidth > 0 ? node.offsetWidth : 15;
|
|
2024
2671
|
const left = Math.round(labelRect.right - rowRect.left - width + HEAD_BADGE_OVERHANG);
|
|
@@ -2027,6 +2674,17 @@ window.__ModuleLoader__.load({
|
|
|
2027
2674
|
if (node.style.left !== `${left}px`) node.style.left = `${left}px`;
|
|
2028
2675
|
if (node.style.top !== `${top}px`) node.style.top = `${top}px`;
|
|
2029
2676
|
if (node.style.display !== "flex") node.style.display = "flex";
|
|
2677
|
+
// No zero state for the running count: it simply is not there.
|
|
2678
|
+
if (running <= 0) {
|
|
2679
|
+
if (runNode.style.display !== "none") runNode.style.display = "none";
|
|
2680
|
+
} else {
|
|
2681
|
+
const runWidth = typeof runNode.offsetWidth === "number" && runNode.offsetWidth > 0 ? runNode.offsetWidth : 15;
|
|
2682
|
+
const runLeft = Math.round(labelRect.right - rowRect.left - runWidth + HEAD_BADGE_OVERHANG);
|
|
2683
|
+
const runTop = Math.round(labelRect.bottom - rowRect.top - HEAD_RUN_DROP);
|
|
2684
|
+
if (runNode.style.left !== `${runLeft}px`) runNode.style.left = `${runLeft}px`;
|
|
2685
|
+
if (runNode.style.top !== `${runTop}px`) runNode.style.top = `${runTop}px`;
|
|
2686
|
+
if (runNode.style.display !== "flex") runNode.style.display = "flex";
|
|
2687
|
+
}
|
|
2030
2688
|
}
|
|
2031
2689
|
// The label animates its own width; follow it instead of waiting for the
|
|
2032
2690
|
// next mutation, which a CSS transition never produces.
|
|
@@ -2040,7 +2698,7 @@ window.__ModuleLoader__.load({
|
|
|
2040
2698
|
|
|
2041
2699
|
/** Take the badge back out and restore the row's own positioning. */
|
|
2042
2700
|
function clearHeaderBadge() {
|
|
2043
|
-
try { document.querySelectorAll(`[${HEAD_BADGE_ATTR}]`).forEach((node) => node.remove()); } catch {}
|
|
2701
|
+
try { document.querySelectorAll(`[${HEAD_BADGE_ATTR}],[${HEAD_RUN_ATTR}]`).forEach((node) => node.remove()); } catch {}
|
|
2044
2702
|
if (headerBadgeObserver !== null) {
|
|
2045
2703
|
try { headerBadgeObserver.disconnect(); } catch {}
|
|
2046
2704
|
headerBadgeObserver = null;
|
|
@@ -2194,6 +2852,28 @@ window.__ModuleLoader__.load({
|
|
|
2194
2852
|
);
|
|
2195
2853
|
}
|
|
2196
2854
|
|
|
2855
|
+
/**
|
|
2856
|
+
* The two brand-mark pill sizes.
|
|
2857
|
+
*
|
|
2858
|
+
* A 24px mark cannot hold two full-size numbers, and drawing one big and one
|
|
2859
|
+
* small reads as a rendering bug rather than a design. So when BOTH are shown
|
|
2860
|
+
* they are drawn at one common edge — the largest that still fits them stacked
|
|
2861
|
+
* — while a pill shown alone keeps the normal size. Pure, so the rule is
|
|
2862
|
+
* testable without React.
|
|
2863
|
+
* @param size - the mark's edge in px.
|
|
2864
|
+
* @param scale - the badge-size multiplier from the setting.
|
|
2865
|
+
* @param hasUnread - whether the unread pill is shown.
|
|
2866
|
+
* @param hasRunning - whether the running pill is shown.
|
|
2867
|
+
* @returns `{ unread, running }` pill edges in px.
|
|
2868
|
+
*/
|
|
2869
|
+
function brandPillEdges(size, scale, hasUnread, hasRunning) {
|
|
2870
|
+
const edge = Math.max(12, Math.round(size * 0.5 * scale));
|
|
2871
|
+
if (hasUnread !== true || hasRunning !== true) return { unread: edge, running: edge };
|
|
2872
|
+
// Half the mark each, minus the seam between them.
|
|
2873
|
+
const shared = Math.max(9, Math.floor((size - 2) / 2));
|
|
2874
|
+
return { unread: shared, running: shared };
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2197
2877
|
/**
|
|
2198
2878
|
* Sidebar brand-mark occupant (registered on the official
|
|
2199
2879
|
* `sidebar.brand.mark` single seat). Shows the custom icon when the
|
|
@@ -2225,15 +2905,18 @@ window.__ModuleLoader__.load({
|
|
|
2225
2905
|
alt: "",
|
|
2226
2906
|
style
|
|
2227
2907
|
});
|
|
2228
|
-
// No
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2908
|
+
// No mark at all: keep the exact original element (no wrapper, no layout risk).
|
|
2909
|
+
const runningCount = normalizeCount(badge.running);
|
|
2910
|
+
if (badge.count <= 0 && runningCount <= 0) return img;
|
|
2911
|
+
// Two independent facts, two corners: the unread count owns the top-right
|
|
2912
|
+
// (where it has always been), the running count takes the bottom-right.
|
|
2913
|
+
const edges = brandPillEdges(size, badgeScale(badge.size), badge.count > 0, runningCount > 0);
|
|
2914
|
+
const edge = edges.unread;
|
|
2915
|
+
const children = [img];
|
|
2916
|
+
if (badge.count > 0) {
|
|
2917
|
+
const label = `${badgeLabel(badge.count)} pending (test)`;
|
|
2918
|
+
children.push(React.createElement("span", {
|
|
2919
|
+
key: "unread",
|
|
2237
2920
|
title: label,
|
|
2238
2921
|
"aria-label": label,
|
|
2239
2922
|
style: {
|
|
@@ -2257,8 +2940,43 @@ window.__ModuleLoader__.load({
|
|
|
2257
2940
|
fontVariantNumeric: "tabular-nums",
|
|
2258
2941
|
pointerEvents: "none"
|
|
2259
2942
|
}
|
|
2260
|
-
}, badgeLabel(badge.count))
|
|
2261
|
-
|
|
2943
|
+
}, badgeLabel(badge.count)));
|
|
2944
|
+
}
|
|
2945
|
+
if (runningCount > 0) {
|
|
2946
|
+
const label = `${badgeLabel(runningCount)} running`;
|
|
2947
|
+
// Same edge as the unread pill whenever both are shown (see
|
|
2948
|
+
// brandPillEdges): a big red beside a small yellow reads as broken.
|
|
2949
|
+
const runEdge = edges.running;
|
|
2950
|
+
children.push(React.createElement("span", {
|
|
2951
|
+
key: "running",
|
|
2952
|
+
title: label,
|
|
2953
|
+
"aria-label": label,
|
|
2954
|
+
style: {
|
|
2955
|
+
position: "absolute",
|
|
2956
|
+
bottom: 0,
|
|
2957
|
+
right: 0,
|
|
2958
|
+
minWidth: runEdge,
|
|
2959
|
+
height: runEdge,
|
|
2960
|
+
padding: "0 2px",
|
|
2961
|
+
boxSizing: "border-box",
|
|
2962
|
+
borderRadius: 999,
|
|
2963
|
+
background: RUNNING_YELLOW,
|
|
2964
|
+
color: "#1f1f1f",
|
|
2965
|
+
border: "1.5px solid var(--dsw-alias-bg-layer-1, #fff)",
|
|
2966
|
+
fontSize: Math.max(7, Math.round(runEdge * 0.62)),
|
|
2967
|
+
fontWeight: 700,
|
|
2968
|
+
lineHeight: 1,
|
|
2969
|
+
display: "flex",
|
|
2970
|
+
alignItems: "center",
|
|
2971
|
+
justifyContent: "center",
|
|
2972
|
+
fontVariantNumeric: "tabular-nums",
|
|
2973
|
+
pointerEvents: "none"
|
|
2974
|
+
}
|
|
2975
|
+
}, badgeLabel(runningCount)));
|
|
2976
|
+
}
|
|
2977
|
+
return React.createElement("span", {
|
|
2978
|
+
style: { position: "relative", display: "inline-flex", width: size, height: size, flex: "none" }
|
|
2979
|
+
}, children);
|
|
2262
2980
|
}
|
|
2263
2981
|
|
|
2264
2982
|
function FaviconSection(props) {
|
|
@@ -2275,9 +2993,35 @@ window.__ModuleLoader__.load({
|
|
|
2275
2993
|
const [badgeSourceValue, setBadgeSourceValue] = React.useState(badgeSource);
|
|
2276
2994
|
const [unread, setUnread] = React.useState(unreadConfig);
|
|
2277
2995
|
React.useEffect(() => subscribeUnreadConfig(setUnread), []);
|
|
2996
|
+
// Its own switch and its own endpoint pair: the running marks must be
|
|
2997
|
+
// switchable without touching the unread rule (see lib/running.js).
|
|
2998
|
+
const [running, setRunning] = React.useState(runningConfig);
|
|
2999
|
+
React.useEffect(() => subscribeRunningConfig(setRunning), []);
|
|
2278
3000
|
// Sampled when the section mounts, which is the moment the user is
|
|
2279
3001
|
// looking at it. It reports local facts only (see badgeCapabilityProbe).
|
|
2280
3002
|
const [appBadgeProbe] = React.useState(() => appBadgeEnv());
|
|
3003
|
+
// Live, unlike the capability probe above: the notifications permission
|
|
3004
|
+
// can move outside this page (System Settings, another tab's prompt), and
|
|
3005
|
+
// on WebKit the badge follows it.
|
|
3006
|
+
const [notificationProbe, setNotificationProbe] = React.useState(() => notificationPermissionEnv());
|
|
3007
|
+
const [permissionBusy, setPermissionBusy] = React.useState(false);
|
|
3008
|
+
// A permission granted while the page is open is the WebKit precondition
|
|
3009
|
+
// the badge was missing: every earlier call was dropped while there was
|
|
3010
|
+
// no permission to draw it, so re-assert the number the page already shows.
|
|
3011
|
+
const onPermissionChanged = React.useCallback((next) => {
|
|
3012
|
+
setNotificationProbe(next);
|
|
3013
|
+
if (next.state === "granted") reconcileAppBadge(true);
|
|
3014
|
+
}, []);
|
|
3015
|
+
React.useEffect(() => subscribeNotificationPermission(onPermissionChanged), [onPermissionChanged]);
|
|
3016
|
+
// Called straight from the click: the prompt needs a user gesture, so it
|
|
3017
|
+
// must never be moved into an effect.
|
|
3018
|
+
const onRequestPermission = React.useCallback(() => {
|
|
3019
|
+
setPermissionBusy(true);
|
|
3020
|
+
requestNotificationPermission().then((next) => {
|
|
3021
|
+
setPermissionBusy(false);
|
|
3022
|
+
onPermissionChanged(next);
|
|
3023
|
+
});
|
|
3024
|
+
}, [onPermissionChanged]);
|
|
2281
3025
|
// Draft text for the delay box: typing must not save on every keystroke,
|
|
2282
3026
|
// and the box has to show the CLAMPED value once the save lands (type
|
|
2283
3027
|
// 99999 and it settles on the 600 cap instead of lying about it).
|
|
@@ -2368,6 +3112,19 @@ window.__ModuleLoader__.load({
|
|
|
2368
3112
|
const onAppBadgeToggle = React.useCallback((checked) => {
|
|
2369
3113
|
saveUnreadRule({ appBadge: checked });
|
|
2370
3114
|
}, [saveUnreadRule]);
|
|
3115
|
+
// Same "one save path, merged onto the current config" discipline as the
|
|
3116
|
+
// unread rule, on its own endpoint: enabling or disabling the yellow marks
|
|
3117
|
+
// applies locally at once and is persisted for the next page load.
|
|
3118
|
+
const saveRunningRule = React.useCallback((patch) => {
|
|
3119
|
+
const next = normalizeRunningConfig({ ...runningConfig, ...patch });
|
|
3120
|
+
emitRunningConfig(next);
|
|
3121
|
+
rpc.call("/api", "iconCustom/setRunningRule", {
|
|
3122
|
+
args: { request: { enabled: next.enabled } }
|
|
3123
|
+
}).catch(() => {});
|
|
3124
|
+
}, [rpc]);
|
|
3125
|
+
const onRunningMarkToggle = React.useCallback((checked) => {
|
|
3126
|
+
saveRunningRule({ enabled: checked });
|
|
3127
|
+
}, [saveRunningRule]);
|
|
2371
3128
|
// Seconds you must stay in a session before it counts as read; 0 = as soon
|
|
2372
3129
|
// as you enter. Clamped by the shared normalizer, so a stray keystroke can
|
|
2373
3130
|
// never persist an absurd delay.
|
|
@@ -2602,6 +3359,7 @@ window.__ModuleLoader__.load({
|
|
|
2602
3359
|
style: { ...(badgeSizeValue === key ? style.btnPrimary : style.btn), padding: "4px 10px" }
|
|
2603
3360
|
}, t(key === "sm" ? "badgeSizeSmall" : key === "md" ? "badgeSizeMedium" : "badgeSizeLarge")))
|
|
2604
3361
|
),
|
|
3362
|
+
React.createElement("div", { style: style.hint }, t("badgeSizeHint")),
|
|
2605
3363
|
React.createElement("div", { style: { marginTop: "12px" } },
|
|
2606
3364
|
React.createElement("div", { style: style.desc }, t("unreadReasonsLabel")),
|
|
2607
3365
|
React.createElement("div", { style: { display: "flex", flexWrap: "wrap", alignItems: "center" } },
|
|
@@ -2648,10 +3406,40 @@ window.__ModuleLoader__.load({
|
|
|
2648
3406
|
React.createElement("div", { style: style.hint }, t("appBadgeHint")),
|
|
2649
3407
|
// What THIS device is — never a claim about the platform.
|
|
2650
3408
|
React.createElement("div", { style: style.hint }, t(appBadgeCapabilityKey(appBadgeProbe))),
|
|
3409
|
+
// The WebKit precondition as a live local fact, plus the one
|
|
3410
|
+
// gesture that can still decide it (button only while undecided).
|
|
3411
|
+
React.createElement("div", { style: { display: "flex", alignItems: "center", flexWrap: "wrap", gap: "8px", marginTop: "8px" } },
|
|
3412
|
+
React.createElement("span", { style: style.desc }, `${t("notificationPermissionLabel")}${t(notificationPermissionKey(notificationProbe))}`),
|
|
3413
|
+
notificationPermissionRequestable(notificationProbe)
|
|
3414
|
+
? React.createElement("button", {
|
|
3415
|
+
type: "button",
|
|
3416
|
+
style: { ...style.btn, ...(permissionBusy ? style.btnDisabled : null) },
|
|
3417
|
+
disabled: permissionBusy,
|
|
3418
|
+
onClick: onRequestPermission
|
|
3419
|
+
}, t("notificationPermissionButton"))
|
|
3420
|
+
: null
|
|
3421
|
+
),
|
|
3422
|
+
React.createElement("div", { style: style.hint }, t("notificationPermissionHint")),
|
|
2651
3423
|
React.createElement("div", { style: style.hint }, t("appBadgeFootnote"))
|
|
2652
3424
|
),
|
|
2653
3425
|
React.createElement("div", { style: style.hint }, t("badgeTestHint"))
|
|
2654
3426
|
),
|
|
3427
|
+
// A second, independent feature in its own block: same settings page,
|
|
3428
|
+
// same save discipline, different fact.
|
|
3429
|
+
React.createElement("div", { style: { borderTop: "1px solid var(--dsw-alias-border-l2)", marginTop: "12px", paddingTop: "12px" } },
|
|
3430
|
+
React.createElement("div", { style: style.label }, t("runningMarkSection")),
|
|
3431
|
+
React.createElement("div", { style: { marginTop: "8px" } },
|
|
3432
|
+
React.createElement("label", { style: { display: "inline-flex", alignItems: "center", gap: "6px", fontSize: 12, color: "var(--dsw-alias-label-primary)", cursor: "pointer" } },
|
|
3433
|
+
React.createElement("input", {
|
|
3434
|
+
type: "checkbox",
|
|
3435
|
+
checked: running.enabled !== false,
|
|
3436
|
+
onChange: (event) => onRunningMarkToggle(event.target.checked === true)
|
|
3437
|
+
}),
|
|
3438
|
+
React.createElement("span", null, t("runningMarkLabel"))
|
|
3439
|
+
),
|
|
3440
|
+
React.createElement("div", { style: style.hint }, t("runningMarkHint"))
|
|
3441
|
+
)
|
|
3442
|
+
),
|
|
2655
3443
|
notice ? React.createElement("div", { style: style.notice }, notice) : null,
|
|
2656
3444
|
error ? React.createElement("div", { style: style.error }, error) : null
|
|
2657
3445
|
);
|
|
@@ -2693,6 +3481,14 @@ window.__ModuleLoader__.load({
|
|
|
2693
3481
|
scheduleHostSync();
|
|
2694
3482
|
})
|
|
2695
3483
|
.catch(() => {});
|
|
3484
|
+
// The running-mark switch rides its own endpoint, for the same reason it has
|
|
3485
|
+
// its own file: a problem in either feature must not touch the other.
|
|
3486
|
+
rpc.call("/api", "iconCustom/getRunningRule", { args: {} })
|
|
3487
|
+
.then((resp) => {
|
|
3488
|
+
if (resp === null || typeof resp !== "object" || resp.ok !== true) return;
|
|
3489
|
+
emitRunningConfig(resp.value);
|
|
3490
|
+
})
|
|
3491
|
+
.catch(() => {});
|
|
2696
3492
|
// Keep "the last moment this browser was here" fresh, so an ending that
|
|
2697
3493
|
// happens while the app is closed still counts as unread when you return.
|
|
2698
3494
|
ctx.effect(() => {
|
|
@@ -2794,15 +3590,15 @@ window.__ModuleLoader__.load({
|
|
|
2794
3590
|
// this effect keeps the tab icon in sync. Teardown restores the
|
|
2795
3591
|
// pristine platform/custom icon.
|
|
2796
3592
|
ctx.effect(() => {
|
|
2797
|
-
const unsubscribe = subscribeBadge((snapshot) => {
|
|
3593
|
+
const unsubscribe = subscribeBadge((snapshot) => { composeFavicon(snapshot.count, snapshot.size); });
|
|
2798
3594
|
// Level-triggered on purpose: (re)applying this half must adopt the
|
|
2799
3595
|
// value that is already on the bridge. The brand mark reads it at
|
|
2800
3596
|
// mount; the tab icon has to be told, or a plugin hot-reload would
|
|
2801
3597
|
// leave it restored (the old effect's cleanup) until the next change.
|
|
2802
|
-
|
|
3598
|
+
composeFavicon(effectiveCount(), badgeSize);
|
|
2803
3599
|
return () => {
|
|
2804
3600
|
unsubscribe();
|
|
2805
|
-
|
|
3601
|
+
composeFavicon(0);
|
|
2806
3602
|
};
|
|
2807
3603
|
}, "dsh-icon-custom: favicon badge");
|
|
2808
3604
|
// Watchdog. The tab icon is the one surface other parties can disturb
|
|
@@ -2867,19 +3663,24 @@ window.__ModuleLoader__.load({
|
|
|
2867
3663
|
if (!attach()) retry = window.setInterval(() => { if (attach()) window.clearInterval(retry); }, 1000);
|
|
2868
3664
|
const offDots = subscribeSidebarMarks(schedule);
|
|
2869
3665
|
const offConfig = subscribeUnreadConfig(schedule);
|
|
3666
|
+
// The running marks ride the same paint pass, so their switch has to wake
|
|
3667
|
+
// it too — otherwise turning it off would leave the marks until the next
|
|
3668
|
+
// unrelated repaint.
|
|
3669
|
+
const offRunning = subscribeRunningConfig(schedule);
|
|
2870
3670
|
return () => {
|
|
2871
3671
|
if (retry !== 0) window.clearInterval(retry);
|
|
2872
3672
|
if (workspaceDotVerify !== 0) { window.clearTimeout(workspaceDotVerify); workspaceDotVerify = 0; }
|
|
2873
3673
|
if (observer !== null) observer.disconnect();
|
|
2874
3674
|
offDots();
|
|
2875
3675
|
offConfig();
|
|
3676
|
+
offRunning();
|
|
2876
3677
|
clearWorkspaceDots();
|
|
2877
3678
|
clearHeaderBadge();
|
|
2878
3679
|
};
|
|
2879
3680
|
}, "dsh-icon-custom: workspace dots");
|
|
2880
3681
|
// The platform favicon.svg repaints itself for dark mode through
|
|
2881
3682
|
// prefers-color-scheme; a composited bitmap cannot, so rebuild it.
|
|
2882
|
-
ctx.on("theme/change", () => {
|
|
3683
|
+
ctx.on("theme/change", () => { composeFavicon(effectiveCount(), badgeSize); });
|
|
2883
3684
|
}
|
|
2884
3685
|
exports.apply = apply;
|
|
2885
3686
|
exports.inject = inject;
|