@cowwo/dsh-icon-custom 0.16.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 +22 -7
- package/client/client.js +783 -37
- 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 秒。**切到「插件/设置」页、或把标签页切到后台,都只是暂停计时,不会因此清掉红点**(它们不算离开);真正算离开的只有一件事:**去别的会话**。已读记在宿主($DSH_HOME/custom-favicon/unread-seen.json):任何一台设备上看过,所有设备都不再提醒;浏览器那份只是缓存,清站点数据不会把已读丢回去。"
|
|
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. 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."
|
|
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
|
*
|
|
@@ -733,6 +788,84 @@ window.__ModuleLoader__.load({
|
|
|
733
788
|
return hits;
|
|
734
789
|
}
|
|
735
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
|
+
|
|
736
869
|
//#endregion
|
|
737
870
|
//#region badge bridge
|
|
738
871
|
// In-package badge bridge. Two number sources:
|
|
@@ -746,6 +879,14 @@ window.__ModuleLoader__.load({
|
|
|
746
879
|
let realCount = 0;
|
|
747
880
|
/** The sessions behind `realCount`, for the sidebar list (newest first). */
|
|
748
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;
|
|
749
890
|
let manualCount = 0;
|
|
750
891
|
let badgeSize = "md";
|
|
751
892
|
const badgeListeners = new Set();
|
|
@@ -757,7 +898,7 @@ window.__ModuleLoader__.load({
|
|
|
757
898
|
return badgeSource === "manual" ? manualCount : realCount;
|
|
758
899
|
}
|
|
759
900
|
function badgeSnapshot() {
|
|
760
|
-
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 };
|
|
761
902
|
}
|
|
762
903
|
function notifyBadge() {
|
|
763
904
|
const snapshot = badgeSnapshot();
|
|
@@ -779,6 +920,22 @@ window.__ModuleLoader__.load({
|
|
|
779
920
|
realItems = Array.isArray(items) ? items : [];
|
|
780
921
|
notifyBadge();
|
|
781
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
|
+
}
|
|
782
939
|
function emitBadgeSize(value) {
|
|
783
940
|
badgeSize = Object.prototype.hasOwnProperty.call(BADGE_SCALES, value) ? value : "md";
|
|
784
941
|
notifyBadge();
|
|
@@ -972,6 +1129,202 @@ window.__ModuleLoader__.load({
|
|
|
972
1129
|
reconcileAppBadge(true);
|
|
973
1130
|
}
|
|
974
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
|
+
|
|
975
1328
|
//#endregion
|
|
976
1329
|
//#region favicon compositing
|
|
977
1330
|
// The browser accepts exactly ONE image per <link rel="icon">, so a tab
|
|
@@ -1091,6 +1444,58 @@ window.__ModuleLoader__.load({
|
|
|
1091
1444
|
c2d.fillText(text, cx, cy + size * 0.01);
|
|
1092
1445
|
c2d.restore();
|
|
1093
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
|
+
}
|
|
1094
1499
|
function loadIconImage(href) {
|
|
1095
1500
|
return new Promise((resolve, reject) => {
|
|
1096
1501
|
const img = new Image();
|
|
@@ -1101,7 +1506,10 @@ window.__ModuleLoader__.load({
|
|
|
1101
1506
|
}
|
|
1102
1507
|
async function applyBadgeToFavicon(count, size) {
|
|
1103
1508
|
const base = desiredIconHref();
|
|
1104
|
-
|
|
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) {
|
|
1105
1513
|
// No badge to draw. With a custom icon active the page must carry it on
|
|
1106
1514
|
// EVERY link (a stale scheme-specific sibling outranks the one we set);
|
|
1107
1515
|
// with none, the shipped dark/light pair goes back untouched — the
|
|
@@ -1129,8 +1537,13 @@ window.__ModuleLoader__.load({
|
|
|
1129
1537
|
const dw = iw * scale;
|
|
1130
1538
|
const dh = ih * scale;
|
|
1131
1539
|
c2d.drawImage(img, (FAVICON_SIZE - dw) / 2, (FAVICON_SIZE - dh) / 2, dw, dh);
|
|
1132
|
-
|
|
1133
|
-
|
|
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");
|
|
1134
1547
|
} catch (error) {
|
|
1135
1548
|
// Never silent: a failure here is invisible in the UI (the tab simply
|
|
1136
1549
|
// keeps its previous icon), which once cost a whole debugging session.
|
|
@@ -1186,7 +1599,7 @@ window.__ModuleLoader__.load({
|
|
|
1186
1599
|
// stale sibling is exactly what used to outrank the icon we set.
|
|
1187
1600
|
const hrefs = iconLinks().map((candidate) => candidate.getAttribute("href"));
|
|
1188
1601
|
const ours = hrefs.length > 0 && hrefs.every((value) => typeof value === "string" && value.slice(0, 5) === "data:");
|
|
1189
|
-
if (effectiveCount() <= 0) {
|
|
1602
|
+
if (effectiveCount() <= 0 && runningTotal <= 0) {
|
|
1190
1603
|
// Carries the custom icon, or puts the shipped pair back. Both writers
|
|
1191
1604
|
// only touch a link that actually differs, so this stays a no-op once
|
|
1192
1605
|
// the page is correct and cannot re-arm the head observer.
|
|
@@ -1399,6 +1812,18 @@ window.__ModuleLoader__.load({
|
|
|
1399
1812
|
*/
|
|
1400
1813
|
const WS_DOT_ATTR = "data-icon-custom-wsdot";
|
|
1401
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;
|
|
1402
1827
|
const WS_ROW_MIN_H = 18;
|
|
1403
1828
|
const WS_ROW_MAX_H = 56;
|
|
1404
1829
|
const WS_ROW_MIN_W = 80;
|
|
@@ -1406,6 +1831,10 @@ window.__ModuleLoader__.load({
|
|
|
1406
1831
|
let workspaceDots = [];
|
|
1407
1832
|
/** One entry per unread session: `{ id, title }`, matched against the row title. */
|
|
1408
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 = [];
|
|
1409
1838
|
let workspaceDotFailures = 0;
|
|
1410
1839
|
let workspaceDotGivenUp = false;
|
|
1411
1840
|
let workspaceDotPainting = false;
|
|
@@ -1420,6 +1849,11 @@ window.__ModuleLoader__.load({
|
|
|
1420
1849
|
const next = marks !== null && typeof marks === "object" ? marks : {};
|
|
1421
1850
|
workspaceDots = Array.isArray(next.workspaces) ? next.workspaces : [];
|
|
1422
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 : [];
|
|
1423
1857
|
workspaceDotListeners.forEach((fn) => { try { fn(); } catch {} });
|
|
1424
1858
|
}
|
|
1425
1859
|
function subscribeSidebarMarks(fn) {
|
|
@@ -1433,7 +1867,7 @@ window.__ModuleLoader__.load({
|
|
|
1433
1867
|
|| null;
|
|
1434
1868
|
}
|
|
1435
1869
|
function clearWorkspaceDots() {
|
|
1436
|
-
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 {}
|
|
1437
1871
|
for (const entry of workspaceDotRestores) { try { entry.el.style.position = entry.position; } catch {} }
|
|
1438
1872
|
workspaceDotRestores = [];
|
|
1439
1873
|
}
|
|
@@ -1636,6 +2070,11 @@ window.__ModuleLoader__.load({
|
|
|
1636
2070
|
const enabled = unreadConfig.workspaceDot !== false;
|
|
1637
2071
|
const wanted = enabled ? workspaceDots : [];
|
|
1638
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 : [];
|
|
1639
2078
|
const container = workspaceDotContainer();
|
|
1640
2079
|
workspaceDotPainting = true;
|
|
1641
2080
|
workspaceDotPaintedAt = Date.now();
|
|
@@ -1648,12 +2087,20 @@ window.__ModuleLoader__.load({
|
|
|
1648
2087
|
// matched twice for a moment) can no longer blink an existing dot away.
|
|
1649
2088
|
const wantedNames = new Set(wanted.map((entry) => workspaceDotKey(entry)));
|
|
1650
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)));
|
|
1651
2092
|
document.querySelectorAll("[" + WS_DOT_ATTR + "]").forEach((node) => {
|
|
1652
2093
|
if (!node.isConnected || !wantedNames.has(node.getAttribute(WS_DOT_ATTR))) node.remove();
|
|
1653
2094
|
});
|
|
1654
2095
|
document.querySelectorAll("[" + ROW_DOT_ATTR + "]").forEach((node) => {
|
|
1655
2096
|
if (!node.isConnected || !wantedIds.has(node.getAttribute(ROW_DOT_ATTR))) node.remove();
|
|
1656
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
|
+
});
|
|
1657
2104
|
if (container === null) { workspaceDotPainting = false; return; } // start-up race
|
|
1658
2105
|
const legacyTitleMatch = !sessionRowKeysInUse(container);
|
|
1659
2106
|
for (const entry of wanted) {
|
|
@@ -1669,7 +2116,6 @@ window.__ModuleLoader__.load({
|
|
|
1669
2116
|
}
|
|
1670
2117
|
if (target === null) { missed++; continue; }
|
|
1671
2118
|
const row = target.row;
|
|
1672
|
-
if (existingMarker(row, WS_DOT_ATTR, key) !== null) { placed++; continue; }
|
|
1673
2119
|
const icon = target.icon;
|
|
1674
2120
|
const rowRect = row.getBoundingClientRect();
|
|
1675
2121
|
const anchorRect = icon.getBoundingClientRect();
|
|
@@ -1677,24 +2123,95 @@ window.__ModuleLoader__.load({
|
|
|
1677
2123
|
workspaceDotRestores.push({ el: row, position: row.style.position });
|
|
1678
2124
|
row.style.position = "relative";
|
|
1679
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
|
+
}
|
|
1680
2139
|
const dot = document.createElement("span");
|
|
1681
2140
|
dot.setAttribute(WS_DOT_ATTR, key);
|
|
1682
2141
|
dot.setAttribute("aria-hidden", "true");
|
|
1683
2142
|
dot.title = (typeof entry.name === "string" && entry.name !== "" ? entry.name : key) + " · " + entry.count;
|
|
1684
2143
|
dot.style.cssText = "position:absolute;pointer-events:none;width:8px;height:8px;border-radius:50%;"
|
|
1685
|
-
+ "background:#e5484d;box-shadow:0 0 0 1.5px var(--dsw-alias-bg-layer-1,#fff);"
|
|
1686
|
-
|
|
1687
|
-
|
|
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";
|
|
1688
2147
|
row.appendChild(dot);
|
|
1689
2148
|
placed++;
|
|
1690
2149
|
created++;
|
|
1691
2150
|
}
|
|
1692
|
-
//
|
|
1693
|
-
//
|
|
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.
|
|
1694
2202
|
for (const entry of wantedRows) {
|
|
1695
2203
|
const target = sessionRowTarget(container, entry.id, entry.title, legacyTitleMatch);
|
|
1696
2204
|
if (target === null) { missed++; continue; }
|
|
1697
|
-
|
|
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
|
+
}
|
|
1698
2215
|
const dot = document.createElement("span");
|
|
1699
2216
|
dot.setAttribute(ROW_DOT_ATTR, entry.id);
|
|
1700
2217
|
dot.setAttribute("aria-hidden", "true");
|
|
@@ -1702,11 +2219,27 @@ window.__ModuleLoader__.load({
|
|
|
1702
2219
|
// Tight to the title on the left, and kept off the timestamp on the
|
|
1703
2220
|
// right: with no right margin the dot reads as part of "24 分钟".
|
|
1704
2221
|
dot.style.cssText = "pointer-events:none;flex:none;width:8px;height:8px;border-radius:50%;"
|
|
1705
|
-
+ "background:#e5484d;margin:0
|
|
2222
|
+
+ "background:#e5484d;margin:0 5px 0 6px;";
|
|
1706
2223
|
target.title.insertAdjacentElement("afterend", dot);
|
|
1707
2224
|
placed++;
|
|
1708
2225
|
created++;
|
|
1709
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
|
+
}
|
|
1710
2243
|
} catch (error) {
|
|
1711
2244
|
missed++;
|
|
1712
2245
|
noteWorkspaceDotFailure(String((error && error.message) || error));
|
|
@@ -1765,6 +2298,10 @@ window.__ModuleLoader__.load({
|
|
|
1765
2298
|
const workspaces = typeof props.useWorkspaces === "function" ? props.useWorkspaces((state) => state) : undefined;
|
|
1766
2299
|
const [config, setConfig] = React.useState(unreadConfig);
|
|
1767
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), []);
|
|
1768
2305
|
// Baseline new sessions; persists only when it actually learns something.
|
|
1769
2306
|
React.useEffect(() => {
|
|
1770
2307
|
const ids = list !== null && typeof list === "object" && Array.isArray(list.ids) ? list.ids : [];
|
|
@@ -1869,24 +2406,49 @@ window.__ModuleLoader__.load({
|
|
|
1869
2406
|
};
|
|
1870
2407
|
}, [staySignature, delayMs, watching]);
|
|
1871
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);
|
|
1872
2426
|
// Map session → its Workspace from the official snapshot: the ID anchors the
|
|
1873
2427
|
// folder dot to the keyed row, the title is what the row displays (not always
|
|
1874
2428
|
// the cwd basename, and not always equal to the stored title either).
|
|
1875
2429
|
React.useEffect(() => {
|
|
1876
2430
|
const owners = {};
|
|
1877
|
-
const
|
|
1878
|
-
for (const workspace of
|
|
2431
|
+
const workspaceItems = workspaces !== null && typeof workspaces === "object" && Array.isArray(workspaces.items) ? workspaces.items : [];
|
|
2432
|
+
for (const workspace of workspaceItems) {
|
|
1879
2433
|
if (workspace === null || typeof workspace !== "object") continue;
|
|
1880
2434
|
if (!Array.isArray(workspace.sessionIds)) continue;
|
|
1881
2435
|
const id = typeof workspace.workspaceId === "string" ? workspace.workspaceId : "";
|
|
1882
2436
|
const title = typeof workspace.title === "string" ? workspace.title : "";
|
|
1883
2437
|
for (const sessionId of workspace.sessionIds) owners[sessionId] = { id, title };
|
|
1884
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 : {};
|
|
1885
2442
|
emitSidebarMarks({
|
|
1886
2443
|
workspaces: dotsFromItems(items, owners),
|
|
1887
|
-
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)
|
|
1888
2450
|
});
|
|
1889
|
-
}, [items, workspaces]);
|
|
2451
|
+
}, [items, workspaces, runningSignature, running]);
|
|
1890
2452
|
return null;
|
|
1891
2453
|
}
|
|
1892
2454
|
|
|
@@ -1913,6 +2475,8 @@ window.__ModuleLoader__.load({
|
|
|
1913
2475
|
* the old sidebar-foot button is gone.
|
|
1914
2476
|
*/
|
|
1915
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";
|
|
1916
2480
|
const HEAD_BADGE_STYLE_ID = "dsh-icon-custom-unreadbadge-css";
|
|
1917
2481
|
const HEAD_BADGE_WIRED = "data-icon-custom-unreadbadge-wired";
|
|
1918
2482
|
const HEAD_BADGE_MAX_FAILURES = 40;
|
|
@@ -1920,6 +2484,8 @@ window.__ModuleLoader__.load({
|
|
|
1920
2484
|
const HEAD_BADGE_MAX_STEPS = 6;
|
|
1921
2485
|
/** Raise above the label's top edge; the header only leaves 8px of headroom. */
|
|
1922
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;
|
|
1923
2489
|
/** Overhang past the label's right edge. */
|
|
1924
2490
|
const HEAD_BADGE_OVERHANG = 9;
|
|
1925
2491
|
let headerBadgeHost = null;
|
|
@@ -1947,7 +2513,10 @@ window.__ModuleLoader__.load({
|
|
|
1947
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)}`,
|
|
1948
2514
|
`[${HEAD_BADGE_ATTR}]::after{content:"";position:absolute;inset:0 -5px -5px -5px;border-radius:999px}`,
|
|
1949
2515
|
`[${HEAD_BADGE_ATTR}]:hover{filter:brightness(1.12)}`,
|
|
1950
|
-
`[${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)}`
|
|
1951
2520
|
].join("");
|
|
1952
2521
|
|
|
1953
2522
|
/** Install the badge rules once per page. */
|
|
@@ -1974,11 +2543,13 @@ window.__ModuleLoader__.load({
|
|
|
1974
2543
|
* @returns `{ row, label }`, or null when this build's DOM is not recognised.
|
|
1975
2544
|
*/
|
|
1976
2545
|
function sectionHeaderParts(container) {
|
|
1977
|
-
// Our own
|
|
1978
|
-
//
|
|
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.
|
|
1979
2549
|
let firstButton = null;
|
|
1980
2550
|
for (const candidate of container.querySelectorAll("button")) {
|
|
1981
2551
|
if (candidate.getAttribute(HEAD_BADGE_ATTR) !== null) continue;
|
|
2552
|
+
if (candidate.getAttribute(HEAD_RUN_ATTR) !== null) continue;
|
|
1982
2553
|
firstButton = candidate;
|
|
1983
2554
|
break;
|
|
1984
2555
|
}
|
|
@@ -1988,6 +2559,8 @@ window.__ModuleLoader__.load({
|
|
|
1988
2559
|
for (const child of row.children) {
|
|
1989
2560
|
if (child === firstButton || child.contains(firstButton)) continue;
|
|
1990
2561
|
if (child.tagName === "BUTTON") continue;
|
|
2562
|
+
if (child.getAttribute(HEAD_BADGE_ATTR) !== null) continue;
|
|
2563
|
+
if (child.getAttribute(HEAD_RUN_ATTR) !== null) continue;
|
|
1991
2564
|
if (child.querySelector("button") !== null) continue;
|
|
1992
2565
|
if ((child.textContent ?? "").trim() !== "") return { row, label: child };
|
|
1993
2566
|
}
|
|
@@ -2067,6 +2640,24 @@ window.__ModuleLoader__.load({
|
|
|
2067
2640
|
}
|
|
2068
2641
|
}
|
|
2069
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;
|
|
2070
2661
|
// The label collapses to nothing while the search box is open; the badge
|
|
2071
2662
|
// has no meaning then, and the label's own visibility cannot hide it
|
|
2072
2663
|
// because it is a sibling.
|
|
@@ -2074,6 +2665,7 @@ window.__ModuleLoader__.load({
|
|
|
2074
2665
|
const labelRect = label.getBoundingClientRect();
|
|
2075
2666
|
if (labelRect.width < 8) {
|
|
2076
2667
|
if (node.style.display !== "none") node.style.display = "none";
|
|
2668
|
+
if (runNode.style.display !== "none") runNode.style.display = "none";
|
|
2077
2669
|
} else {
|
|
2078
2670
|
const width = typeof node.offsetWidth === "number" && node.offsetWidth > 0 ? node.offsetWidth : 15;
|
|
2079
2671
|
const left = Math.round(labelRect.right - rowRect.left - width + HEAD_BADGE_OVERHANG);
|
|
@@ -2082,6 +2674,17 @@ window.__ModuleLoader__.load({
|
|
|
2082
2674
|
if (node.style.left !== `${left}px`) node.style.left = `${left}px`;
|
|
2083
2675
|
if (node.style.top !== `${top}px`) node.style.top = `${top}px`;
|
|
2084
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
|
+
}
|
|
2085
2688
|
}
|
|
2086
2689
|
// The label animates its own width; follow it instead of waiting for the
|
|
2087
2690
|
// next mutation, which a CSS transition never produces.
|
|
@@ -2095,7 +2698,7 @@ window.__ModuleLoader__.load({
|
|
|
2095
2698
|
|
|
2096
2699
|
/** Take the badge back out and restore the row's own positioning. */
|
|
2097
2700
|
function clearHeaderBadge() {
|
|
2098
|
-
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 {}
|
|
2099
2702
|
if (headerBadgeObserver !== null) {
|
|
2100
2703
|
try { headerBadgeObserver.disconnect(); } catch {}
|
|
2101
2704
|
headerBadgeObserver = null;
|
|
@@ -2249,6 +2852,28 @@ window.__ModuleLoader__.load({
|
|
|
2249
2852
|
);
|
|
2250
2853
|
}
|
|
2251
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
|
+
|
|
2252
2877
|
/**
|
|
2253
2878
|
* Sidebar brand-mark occupant (registered on the official
|
|
2254
2879
|
* `sidebar.brand.mark` single seat). Shows the custom icon when the
|
|
@@ -2280,15 +2905,18 @@ window.__ModuleLoader__.load({
|
|
|
2280
2905
|
alt: "",
|
|
2281
2906
|
style
|
|
2282
2907
|
});
|
|
2283
|
-
// No
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
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",
|
|
2292
2920
|
title: label,
|
|
2293
2921
|
"aria-label": label,
|
|
2294
2922
|
style: {
|
|
@@ -2312,8 +2940,43 @@ window.__ModuleLoader__.load({
|
|
|
2312
2940
|
fontVariantNumeric: "tabular-nums",
|
|
2313
2941
|
pointerEvents: "none"
|
|
2314
2942
|
}
|
|
2315
|
-
}, badgeLabel(badge.count))
|
|
2316
|
-
|
|
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);
|
|
2317
2980
|
}
|
|
2318
2981
|
|
|
2319
2982
|
function FaviconSection(props) {
|
|
@@ -2330,9 +2993,35 @@ window.__ModuleLoader__.load({
|
|
|
2330
2993
|
const [badgeSourceValue, setBadgeSourceValue] = React.useState(badgeSource);
|
|
2331
2994
|
const [unread, setUnread] = React.useState(unreadConfig);
|
|
2332
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), []);
|
|
2333
3000
|
// Sampled when the section mounts, which is the moment the user is
|
|
2334
3001
|
// looking at it. It reports local facts only (see badgeCapabilityProbe).
|
|
2335
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]);
|
|
2336
3025
|
// Draft text for the delay box: typing must not save on every keystroke,
|
|
2337
3026
|
// and the box has to show the CLAMPED value once the save lands (type
|
|
2338
3027
|
// 99999 and it settles on the 600 cap instead of lying about it).
|
|
@@ -2423,6 +3112,19 @@ window.__ModuleLoader__.load({
|
|
|
2423
3112
|
const onAppBadgeToggle = React.useCallback((checked) => {
|
|
2424
3113
|
saveUnreadRule({ appBadge: checked });
|
|
2425
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]);
|
|
2426
3128
|
// Seconds you must stay in a session before it counts as read; 0 = as soon
|
|
2427
3129
|
// as you enter. Clamped by the shared normalizer, so a stray keystroke can
|
|
2428
3130
|
// never persist an absurd delay.
|
|
@@ -2657,6 +3359,7 @@ window.__ModuleLoader__.load({
|
|
|
2657
3359
|
style: { ...(badgeSizeValue === key ? style.btnPrimary : style.btn), padding: "4px 10px" }
|
|
2658
3360
|
}, t(key === "sm" ? "badgeSizeSmall" : key === "md" ? "badgeSizeMedium" : "badgeSizeLarge")))
|
|
2659
3361
|
),
|
|
3362
|
+
React.createElement("div", { style: style.hint }, t("badgeSizeHint")),
|
|
2660
3363
|
React.createElement("div", { style: { marginTop: "12px" } },
|
|
2661
3364
|
React.createElement("div", { style: style.desc }, t("unreadReasonsLabel")),
|
|
2662
3365
|
React.createElement("div", { style: { display: "flex", flexWrap: "wrap", alignItems: "center" } },
|
|
@@ -2703,10 +3406,40 @@ window.__ModuleLoader__.load({
|
|
|
2703
3406
|
React.createElement("div", { style: style.hint }, t("appBadgeHint")),
|
|
2704
3407
|
// What THIS device is — never a claim about the platform.
|
|
2705
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")),
|
|
2706
3423
|
React.createElement("div", { style: style.hint }, t("appBadgeFootnote"))
|
|
2707
3424
|
),
|
|
2708
3425
|
React.createElement("div", { style: style.hint }, t("badgeTestHint"))
|
|
2709
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
|
+
),
|
|
2710
3443
|
notice ? React.createElement("div", { style: style.notice }, notice) : null,
|
|
2711
3444
|
error ? React.createElement("div", { style: style.error }, error) : null
|
|
2712
3445
|
);
|
|
@@ -2748,6 +3481,14 @@ window.__ModuleLoader__.load({
|
|
|
2748
3481
|
scheduleHostSync();
|
|
2749
3482
|
})
|
|
2750
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(() => {});
|
|
2751
3492
|
// Keep "the last moment this browser was here" fresh, so an ending that
|
|
2752
3493
|
// happens while the app is closed still counts as unread when you return.
|
|
2753
3494
|
ctx.effect(() => {
|
|
@@ -2922,12 +3663,17 @@ window.__ModuleLoader__.load({
|
|
|
2922
3663
|
if (!attach()) retry = window.setInterval(() => { if (attach()) window.clearInterval(retry); }, 1000);
|
|
2923
3664
|
const offDots = subscribeSidebarMarks(schedule);
|
|
2924
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);
|
|
2925
3670
|
return () => {
|
|
2926
3671
|
if (retry !== 0) window.clearInterval(retry);
|
|
2927
3672
|
if (workspaceDotVerify !== 0) { window.clearTimeout(workspaceDotVerify); workspaceDotVerify = 0; }
|
|
2928
3673
|
if (observer !== null) observer.disconnect();
|
|
2929
3674
|
offDots();
|
|
2930
3675
|
offConfig();
|
|
3676
|
+
offRunning();
|
|
2931
3677
|
clearWorkspaceDots();
|
|
2932
3678
|
clearHeaderBadge();
|
|
2933
3679
|
};
|