@cowwo/dsh-icon-custom 0.16.0 → 0.19.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 +24 -7
- package/README.zh.md +33 -8
- package/client/client.js +1189 -107
- package/lib/index.js +52 -0
- package/lib/running.js +32 -0
- package/lib/typert.host.js +78 -3
- package/lib/unread.js +18 -4
- 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: "大",
|
|
@@ -61,6 +62,11 @@ window.__ModuleLoader__.load({
|
|
|
61
62
|
unreadEnded: "刚结束,还没看",
|
|
62
63
|
unreadPanelOther: "其他",
|
|
63
64
|
unreadMarkAllRead: "全部标记已读",
|
|
65
|
+
/** 进行中清单(点黄字打开):和那个黄数字是同一个事实,不落盘、不推断。 */
|
|
66
|
+
runningPanelTitle: "进行中的会话",
|
|
67
|
+
runningPanelEmpty: "现在没有会话在运行",
|
|
68
|
+
runningPanelTrigger: "{n} 个进行中,点击查看",
|
|
69
|
+
runningPanelRow: "回合正在跑",
|
|
64
70
|
/** 与官方侧栏同一形状("13小时"),方便和列表里的时间戳逐字对上。 */
|
|
65
71
|
ageNow: "刚刚",
|
|
66
72
|
ageMinutes: "{n}分钟",
|
|
@@ -70,17 +76,31 @@ window.__ModuleLoader__.load({
|
|
|
70
76
|
ageYears: "{n}年",
|
|
71
77
|
workspaceDotLabel: "在工作区和会话行上显示红点(实验)",
|
|
72
78
|
workspaceDotHint: "在侧栏工作区那一行的文件夹图标上点一个小红点(收起的工作区里有多少未读,就看它),有未读的会话在其标题右边也跟一个小红点。会话行上的点只在那一行真的显示着时才画:折叠起来的会话由工作区那一行的点和「待处理」清单代表。这一项是贴着页面结构做的,DSH 升级后可能失效;失效时只会变成\"不显示\",不会影响红点数字、清单、跳转这些功能,随时可以关掉它。",
|
|
79
|
+
revealLabel: "点清单里的会话时,在侧栏定位到它(实验)",
|
|
80
|
+
revealHint: "点「待处理」或「进行中」清单里的一条时,除了打开那个会话,还会在侧栏把它所在的工作区展开、滚到那一行、并闪一下。这是贴着页面结构做的:官方只把\"展开 + 滚动\"留给了它自己的搜索框,没有给插件留接口,所以这里只能模拟点击官方的工作区行和「显示更多」。DSH 升级后可能失效,失效的表现只是\"侧栏不动\",打开会话完全不受影响。**展开状态是官方自己记住的**(存在浏览器里),所以它不会自己收回去——不想要就手动收起那个工作区,或者关掉这个开关。",
|
|
73
81
|
appBadgeLabel: "在系统应用图标上显示红点",
|
|
74
|
-
appBadgeHint: "安装为应用后,把同一个数字也放到系统图标上:Windows 是任务栏图标上的角标,macOS 是 Dock 角标,iOS 是主屏图标上的数字。样子由系统决定——Windows 上 Chrome 画深色圆+白字,Edge 走 Windows 系统徽章通道(是否显示数字由 Edge/Windows 决定);插件会周期重设数字,但只在应用窗口开着时更新。",
|
|
82
|
+
appBadgeHint: "安装为应用后,把同一个数字也放到系统图标上:Windows 是任务栏图标上的角标,macOS 是 Dock 角标,iOS 是主屏图标上的数字。样子由系统决定——Windows 上 Chrome 画深色圆+白字,Edge 走 Windows 系统徽章通道(是否显示数字由 Edge/Windows 决定);插件会周期重设数字,但只在应用窗口开着时更新。Safari 的程序坞网页应用 / 主屏应用还要先允许通知(见下面的「通知权限」),否则角标不会出现。",
|
|
75
83
|
appBadgeFootnote: "角标只在应用窗口开着时由本插件更新;数字超过 99 时由系统显示为 99+。",
|
|
76
84
|
appBadgeProbeInstalled: "当前环境:已安装为应用,本机可以设置角标。是否真正显示由系统决定。",
|
|
77
85
|
appBadgeProbeTab: "当前环境:浏览器标签页(不是安装的应用)——安装为应用后才会显示角标。",
|
|
78
86
|
appBadgeProbeUnsupported: "当前环境:本机没有这个能力(当前浏览器不支持应用角标)。",
|
|
79
87
|
appBadgeProbeInsecure: "当前环境:不是安全上下文(请用 localhost 或 https 访问),系统角标不可用。",
|
|
88
|
+
notificationPermissionLabel: "通知权限:",
|
|
89
|
+
notificationPermissionGranted: "已授予",
|
|
90
|
+
notificationPermissionDefault: "未授予",
|
|
91
|
+
notificationPermissionDenied: "已被拒绝(要去系统设置的「通知」里打开)",
|
|
92
|
+
notificationPermissionUnsupported: "本机浏览器没有通知能力",
|
|
93
|
+
notificationPermissionButton: "允许通知",
|
|
94
|
+
notificationPermissionHint: "Chrome / Edge 的角标不需要这一步;Safari 不一样——macOS 的「添加到程序坞」网页应用和 iOS 的主屏应用,只有在你允许通知之后才会画出角标(Apple 把角标权限绑在通知权限上)。这里只申请权限,本插件目前不会给你发通知。",
|
|
80
95
|
badgeTestHint: "真实未读 = 有会话发生了上面勾选的情况、而且你还没看过它(打开该会话即视为已读)。你正开着的会话也算——窗口可能被最小化、页面可能切到后台,这里无从判断你在不在看,所以不做这个区分。只有子代理不计。手动模式:自己填数字试看效果。刷新页面后回到真实未读。",
|
|
81
96
|
clearDelayLabel: "进入会话后多久算已读:",
|
|
82
97
|
clearDelayUnit: "秒",
|
|
83
|
-
clearDelayHint: "填 0 = 进去就算已读(红点立即消失)。大于 0 时,你要在那个会话里连续待满这么多秒它才算看过:中途切走会清零重数,刷新页面也重新数。**计时只认\"你进入会话时已经存在的结束\"**——你人已经在里面的时候又跑完一轮,那一轮不算数:它会一直亮着,等你切走再进来才会清。上限 600 秒。**切到「插件/设置」页、或把标签页切到后台,都只是暂停计时,不会因此清掉红点**(它们不算离开);真正算离开的只有一件事:**去别的会话**。已读记在宿主($DSH_HOME/custom-favicon/unread-seen.json):任何一台设备上看过,所有设备都不再提醒;浏览器那份只是缓存,清站点数据不会把已读丢回去。"
|
|
98
|
+
clearDelayHint: "填 0 = 进去就算已读(红点立即消失)。大于 0 时,你要在那个会话里连续待满这么多秒它才算看过:中途切走会清零重数,刷新页面也重新数。**计时只认\"你进入会话时已经存在的结束\"**——你人已经在里面的时候又跑完一轮,那一轮不算数:它会一直亮着,等你切走再进来才会清。上限 600 秒。**切到「插件/设置」页、或把标签页切到后台,都只是暂停计时,不会因此清掉红点**(它们不算离开);真正算离开的只有一件事:**去别的会话**。已读记在宿主($DSH_HOME/custom-favicon/unread-seen.json):任何一台设备上看过,所有设备都不再提醒;浏览器那份只是缓存,清站点数据不会把已读丢回去。",
|
|
99
|
+
/** 进行中黄点(0.18.0):官方 running 的第二次投影,与红点互不牵连。 */
|
|
100
|
+
runningMarkSection: "进行中黄点",
|
|
101
|
+
runningMarkLabel: "显示进行中标记(标题数字 / 文件夹与会话行 / 左上角 / 标签页)",
|
|
102
|
+
runningMarkCount: "{n} 个进行中",
|
|
103
|
+
runningMarkHint: "黄点就是官方的「进行中」:会话有回合正在跑、而且没有停下来等你(停下等你的时候,官方那一行显示琥珀色的「等待审批 / 等待回答」,黄点也就不亮)。它和未读红点完全独立、各画各的:侧栏「工作区」标题后面是黄色数字——此刻全局有几个会话在跑(和红数字上下排:右上红、右下黄);工作区文件夹图标上是黄点,有未读时红点在左、黄点在右;会话行上同样是红点在左、黄点在右;左上角 Logo 与标签页图标右下角也是同一个数字,红数字仍占右上角。事实全部来自官方(会话列表里每个会话的 `running`)——插件不落盘、不推断,所以刷新页面立刻是对的,进程重启也不会留下一个永远亮着的黄点。关掉这个开关,红点完全不受影响。"
|
|
84
104
|
};
|
|
85
105
|
const en = {
|
|
86
106
|
nav: "Favicon",
|
|
@@ -113,6 +133,7 @@ window.__ModuleLoader__.load({
|
|
|
113
133
|
badgeSourceManual: "Manual",
|
|
114
134
|
badgeTestUnit: "0 = hidden",
|
|
115
135
|
badgeSizeLabel: "Badge size:",
|
|
136
|
+
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
137
|
badgeSizeSmall: "Small",
|
|
117
138
|
badgeSizeMedium: "Medium",
|
|
118
139
|
badgeSizeLarge: "Large",
|
|
@@ -134,6 +155,11 @@ window.__ModuleLoader__.load({
|
|
|
134
155
|
unreadEnded: "ended, not seen yet",
|
|
135
156
|
unreadPanelOther: "Other",
|
|
136
157
|
unreadMarkAllRead: "Mark all read",
|
|
158
|
+
/** The running list (opened by the yellow number): the very fact that number counts. */
|
|
159
|
+
runningPanelTitle: "Sessions running",
|
|
160
|
+
runningPanelEmpty: "Nothing is running right now",
|
|
161
|
+
runningPanelTrigger: "{n} running — click for the list",
|
|
162
|
+
runningPanelRow: "turn in flight",
|
|
137
163
|
/** Same shape as the shipped sidebar ("13h"), so the two can be read side by side. */
|
|
138
164
|
ageNow: "now",
|
|
139
165
|
ageMinutes: "{n}min",
|
|
@@ -143,17 +169,31 @@ window.__ModuleLoader__.load({
|
|
|
143
169
|
ageYears: "{n}y",
|
|
144
170
|
workspaceDotLabel: "Dots on workspace and session rows (experimental)",
|
|
145
171
|
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.",
|
|
172
|
+
revealLabel: "Locate the session in the sidebar when a list row is clicked (experimental)",
|
|
173
|
+
revealHint: "Clicking a row in the \"needing you\" or \"running\" list opens that session AND, in the sidebar, expands its workspace, scrolls to the row and flashes it once. This reads the page structure: the official sidebar keeps expand-and-scroll for its own search box and exposes no interface for it, so this drives the shipped workspace row and \"show more\" button the way a click would. A DSH upgrade may break it, and the only symptom is a sidebar that does not move — opening the session is never affected. **The expanded state is remembered by the official sidebar itself** (in the browser), so it will not collapse again on its own: collapse that workspace by hand, or turn this off.",
|
|
146
174
|
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.",
|
|
175
|
+
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
176
|
appBadgeFootnote: "The badge is only updated by this plugin while the app window is open; above 99 the system shows 99+.",
|
|
149
177
|
appBadgeProbeInstalled: "This environment: installed as an app, so this device can set a badge. Whether it actually appears is up to the system.",
|
|
150
178
|
appBadgeProbeTab: "This environment: a browser tab (not an installed app) — install as an app to get a badge.",
|
|
151
179
|
appBadgeProbeUnsupported: "This environment: no app-badge capability in this browser.",
|
|
152
180
|
appBadgeProbeInsecure: "This environment: not a secure context (use localhost or https) — the system badge is unavailable.",
|
|
181
|
+
notificationPermissionLabel: "Notifications permission:",
|
|
182
|
+
notificationPermissionGranted: "granted",
|
|
183
|
+
notificationPermissionDefault: "not granted",
|
|
184
|
+
notificationPermissionDenied: "denied (enable it in the system notifications settings)",
|
|
185
|
+
notificationPermissionUnsupported: "this browser has no Notification API",
|
|
186
|
+
notificationPermissionButton: "Allow notifications",
|
|
187
|
+
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
188
|
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
189
|
clearDelayLabel: "Mark read after staying:",
|
|
155
190
|
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."
|
|
191
|
+
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.",
|
|
192
|
+
/** Running mark (0.18.0): a second projection of the official `running`. */
|
|
193
|
+
runningMarkSection: "Running marks",
|
|
194
|
+
runningMarkLabel: "Show running marks (header number / folder and session rows / top-left / tab)",
|
|
195
|
+
runningMarkCount: "{n} running",
|
|
196
|
+
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
197
|
};
|
|
158
198
|
//#endregion
|
|
159
199
|
|
|
@@ -271,7 +311,9 @@ window.__ModuleLoader__.load({
|
|
|
271
311
|
const UNREAD_CLEAR_DELAY_MAX_SEC = 600;
|
|
272
312
|
/** Mirror of the host's default (see lib/unread.js) — badging the app icon is on. */
|
|
273
313
|
const UNREAD_APP_BADGE_FALLBACK = true;
|
|
274
|
-
|
|
314
|
+
/** Mirror of the host's default (see lib/unread.js) — locating a clicked row is on. */
|
|
315
|
+
const UNREAD_REVEAL_FALLBACK = true;
|
|
316
|
+
let unreadConfig = { reasons: { ...UNREAD_FALLBACK_REASONS }, pending: true, workspaceDot: true, appBadge: UNREAD_APP_BADGE_FALLBACK, clearDelaySec: 0, revealOnOpen: UNREAD_REVEAL_FALLBACK };
|
|
275
317
|
const unreadConfigListeners = new Set();
|
|
276
318
|
/** Mirror of the host's normalizer: any input becomes an integer 0..cap seconds. */
|
|
277
319
|
function normalizeClearDelay(input) {
|
|
@@ -293,7 +335,8 @@ window.__ModuleLoader__.load({
|
|
|
293
335
|
pending: typeof source.pending === "boolean" ? source.pending : true,
|
|
294
336
|
workspaceDot: typeof source.workspaceDot === "boolean" ? source.workspaceDot : true,
|
|
295
337
|
appBadge: typeof source.appBadge === "boolean" ? source.appBadge : UNREAD_APP_BADGE_FALLBACK,
|
|
296
|
-
clearDelaySec: normalizeClearDelay(source.clearDelaySec)
|
|
338
|
+
clearDelaySec: normalizeClearDelay(source.clearDelaySec),
|
|
339
|
+
revealOnOpen: typeof source.revealOnOpen === "boolean" ? source.revealOnOpen : UNREAD_REVEAL_FALLBACK
|
|
297
340
|
};
|
|
298
341
|
}
|
|
299
342
|
function emitUnreadConfig(input) {
|
|
@@ -308,6 +351,35 @@ window.__ModuleLoader__.load({
|
|
|
308
351
|
return () => { unreadConfigListeners.delete(fn); };
|
|
309
352
|
}
|
|
310
353
|
|
|
354
|
+
/**
|
|
355
|
+
* The running mark's own switch, and the yellow it paints with.
|
|
356
|
+
*
|
|
357
|
+
* Deliberately NOT a field on the unread rule: the two features answer
|
|
358
|
+
* different questions ("something ended and you have not looked" vs
|
|
359
|
+
* "something is running right now"), so neither switch may drag the other's
|
|
360
|
+
* storage or response shape along. The yellow is a literal like the red
|
|
361
|
+
* `#e5484d` already is — the theme has no yellow token, and the official
|
|
362
|
+
* amber `--dsw-alias-state-warn-primary` (#f59e0b) is taken by the row's
|
|
363
|
+
* "waiting for you" dot, which must stay tellable apart from this one.
|
|
364
|
+
*/
|
|
365
|
+
const RUNNING_YELLOW = "#f5c518";
|
|
366
|
+
/** Mirror of the host default (lib/running.js). */
|
|
367
|
+
const RUNNING_MARK_FALLBACK = true;
|
|
368
|
+
let runningConfig = { enabled: RUNNING_MARK_FALLBACK };
|
|
369
|
+
const runningConfigListeners = new Set();
|
|
370
|
+
function normalizeRunningConfig(input) {
|
|
371
|
+
const source = input !== null && typeof input === "object" ? input : {};
|
|
372
|
+
return { enabled: typeof source.enabled === "boolean" ? source.enabled : RUNNING_MARK_FALLBACK };
|
|
373
|
+
}
|
|
374
|
+
function emitRunningConfig(input) {
|
|
375
|
+
runningConfig = normalizeRunningConfig(input);
|
|
376
|
+
runningConfigListeners.forEach((fn) => { try { fn(runningConfig); } catch {} });
|
|
377
|
+
}
|
|
378
|
+
function subscribeRunningConfig(fn) {
|
|
379
|
+
runningConfigListeners.add(fn);
|
|
380
|
+
return () => { runningConfigListeners.delete(fn); };
|
|
381
|
+
}
|
|
382
|
+
|
|
311
383
|
/**
|
|
312
384
|
* The seen watermark: the HOST owns it, this browser keeps the working copy.
|
|
313
385
|
*
|
|
@@ -658,6 +730,23 @@ window.__ModuleLoader__.load({
|
|
|
658
730
|
const value = values !== null && typeof values === "object" ? values[LAST_TURN_END_KEY] : undefined;
|
|
659
731
|
return value !== null && typeof value === "object" && typeof value.endAt === "number" ? value.endAt : 0;
|
|
660
732
|
}
|
|
733
|
+
/**
|
|
734
|
+
* Which workspace a session is shown under: the basename of its cwd.
|
|
735
|
+
*
|
|
736
|
+
* The sidebar's own workspace rows are labelled with this basename, so a list
|
|
737
|
+
* points at the same place the reader sees — the sidebar group cannot be
|
|
738
|
+
* expanded for us (that state is private to the shipped browser), so naming
|
|
739
|
+
* it is the honest substitute. Shared by the pending list and the running
|
|
740
|
+
* list: two lists of the same sessions must not disagree about where they are.
|
|
741
|
+
* @param entry - the session-list row (may be absent).
|
|
742
|
+
* @returns the basename, or "" when the row carries no cwd.
|
|
743
|
+
*/
|
|
744
|
+
function sessionWhereOf(entry) {
|
|
745
|
+
const cwd = entry !== null && typeof entry === "object" && typeof entry.cwd === "string" ? entry.cwd : "";
|
|
746
|
+
if (cwd === "") return "";
|
|
747
|
+
const parts = cwd.replace(/[\\/]+$/, "").split(/[\\/]/);
|
|
748
|
+
return parts[parts.length - 1] || cwd;
|
|
749
|
+
}
|
|
661
750
|
/**
|
|
662
751
|
* How many sessions deserve the badge right now: endings whose reason is
|
|
663
752
|
* enabled and that this browser has not seen since, plus sessions waiting
|
|
@@ -685,16 +774,6 @@ window.__ModuleLoader__.load({
|
|
|
685
774
|
const ids = new Set();
|
|
686
775
|
const byId = list !== null && typeof list === "object" && list.byId !== null && typeof list.byId === "object" ? list.byId : {};
|
|
687
776
|
const titleOf = (id, entry) => (entry !== null && typeof entry === "object" && typeof entry.displayTitle === "string" && entry.displayTitle !== "" ? entry.displayTitle : id);
|
|
688
|
-
// Which workspace the session lives in. The sidebar's own workspace rows are
|
|
689
|
-
// labelled with this cwd basename, so the list points at the same place the
|
|
690
|
-
// reader sees — the sidebar group cannot be expanded for us (that state is
|
|
691
|
-
// private to the shipped browser), so naming it is the honest substitute.
|
|
692
|
-
const whereOf = (entry) => {
|
|
693
|
-
const cwd = entry !== null && typeof entry === "object" && typeof entry.cwd === "string" ? entry.cwd : "";
|
|
694
|
-
if (cwd === "") return "";
|
|
695
|
-
const parts = cwd.replace(/[\\/]+$/, "").split(/[\\/]/);
|
|
696
|
-
return parts[parts.length - 1] || cwd;
|
|
697
|
-
};
|
|
698
777
|
if (list !== null && typeof list === "object") {
|
|
699
778
|
const order = Array.isArray(list.ids) ? list.ids : Object.keys(byId);
|
|
700
779
|
for (const id of order) {
|
|
@@ -711,7 +790,7 @@ window.__ModuleLoader__.load({
|
|
|
711
790
|
// turns them on, so a vocabulary growth can never surprise the badge.
|
|
712
791
|
if (reasons[value.reason] !== true) continue;
|
|
713
792
|
ids.add(id);
|
|
714
|
-
hits.push({ id, title: titleOf(id, entry), where:
|
|
793
|
+
hits.push({ id, title: titleOf(id, entry), where: sessionWhereOf(entry), waiting: false, kind: value.reason, at: endAt });
|
|
715
794
|
}
|
|
716
795
|
}
|
|
717
796
|
if (config === null || typeof config !== "object" || config.pending !== false) {
|
|
@@ -725,7 +804,7 @@ window.__ModuleLoader__.load({
|
|
|
725
804
|
return;
|
|
726
805
|
}
|
|
727
806
|
ids.add(id);
|
|
728
|
-
hits.push({ id, title: titleOf(id, byId[id]), where:
|
|
807
|
+
hits.push({ id, title: titleOf(id, byId[id]), where: sessionWhereOf(byId[id]), waiting: true, kind, at: Date.now() });
|
|
729
808
|
});
|
|
730
809
|
}
|
|
731
810
|
}
|
|
@@ -733,6 +812,84 @@ window.__ModuleLoader__.load({
|
|
|
733
812
|
return hits;
|
|
734
813
|
}
|
|
735
814
|
|
|
815
|
+
//#endregion
|
|
816
|
+
//#region running state
|
|
817
|
+
// The "who is running right now" fact is entirely OFFICIAL and entirely
|
|
818
|
+
// free: the browser's session list already carries each session's
|
|
819
|
+
// `running` (host `ctx.agents.get(id)?.status === 'running'`, pushed by
|
|
820
|
+
// `api-session/status`), and the official session row already draws its own
|
|
821
|
+
// grey spinner from it. This plugin adds no host plumbing, no projection
|
|
822
|
+
// and no storage — it only projects the same fact as a YELLOW mark in four
|
|
823
|
+
// places. See docs/adr/0010-running-mark-is-a-second-projection.md.
|
|
824
|
+
/**
|
|
825
|
+
* The sessions that are running right now.
|
|
826
|
+
*
|
|
827
|
+
* Two official facts are combined, in the shipped sidebar's own precedence:
|
|
828
|
+
* * running — `status.get(id)?.running`, falling back to the row's own
|
|
829
|
+
* `running` exactly like ui-workspace's `sessionNode` does;
|
|
830
|
+
* * waiting — a `pendingInteraction` makes the official row show the amber
|
|
831
|
+
* "waiting for approval / answer / plan review" instead of the spinner,
|
|
832
|
+
* so it is NOT running here either. That is why a session with the
|
|
833
|
+
* amber dot never also gets a yellow one.
|
|
834
|
+
*
|
|
835
|
+
* Sub-agent sessions are skipped for the same reason `collectUnread` skips
|
|
836
|
+
* them: they are not part of the browsing the user does (ui-workspace's
|
|
837
|
+
* `sessionVisible` hides them outright), so counting them would leave the
|
|
838
|
+
* mark lit by work nobody can open.
|
|
839
|
+
* @param list - the session list snapshot (may be absent).
|
|
840
|
+
* @param status - the official session-status map (may be absent).
|
|
841
|
+
* @returns the running session ids, in list order.
|
|
842
|
+
*/
|
|
843
|
+
function runningSessionIds(list, status) {
|
|
844
|
+
const out = [];
|
|
845
|
+
if (list === null || typeof list !== "object") return out;
|
|
846
|
+
const byId = list.byId !== null && typeof list.byId === "object" ? list.byId : {};
|
|
847
|
+
const order = Array.isArray(list.ids) ? list.ids : Object.keys(byId);
|
|
848
|
+
const lookup = status !== null && status !== undefined && typeof status.get === "function"
|
|
849
|
+
? (id) => status.get(id)
|
|
850
|
+
: () => undefined;
|
|
851
|
+
for (const id of order) {
|
|
852
|
+
if (typeof id !== "string" || id === "") continue;
|
|
853
|
+
const entry = byId[id];
|
|
854
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
855
|
+
if (entry.origin === "subagent") continue;
|
|
856
|
+
const row = lookup(id);
|
|
857
|
+
const known = row !== null && typeof row === "object";
|
|
858
|
+
if (known && row.pendingInteraction !== null && row.pendingInteraction !== undefined) continue;
|
|
859
|
+
const running = known && typeof row.running === "boolean" ? row.running : entry.running;
|
|
860
|
+
if (running !== true) continue;
|
|
861
|
+
out.push(id);
|
|
862
|
+
}
|
|
863
|
+
return out;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Roll the running sessions up to one count per workspace.
|
|
868
|
+
*
|
|
869
|
+
* Mirrors `dotsFromItems`: the ID anchors the number to the workspace row
|
|
870
|
+
* the browser itself keys (`data-row-key="workspace:<id>"`), the title is
|
|
871
|
+
* what the row displays, and a session whose workspace cannot be named is
|
|
872
|
+
* dropped rather than guessed at. Pure, so a test can drive it directly.
|
|
873
|
+
* @param ids - running session ids, from `runningSessionIds`.
|
|
874
|
+
* @param owners - sessionId → `{ id, title }` from the official workspace snapshot.
|
|
875
|
+
* @returns `[{ id, name, count }]`, busiest workspace first.
|
|
876
|
+
*/
|
|
877
|
+
function runningWorkspaceDots(ids, owners) {
|
|
878
|
+
const counts = new Map();
|
|
879
|
+
for (const id of Array.isArray(ids) ? ids : []) {
|
|
880
|
+
if (typeof id !== "string" || id === "") continue;
|
|
881
|
+
const owner = owners !== null && typeof owners === "object" ? owners[id] : undefined;
|
|
882
|
+
const workspaceId = owner !== null && typeof owner === "object" && typeof owner.id === "string" ? owner.id : "";
|
|
883
|
+
if (workspaceId === "") continue;
|
|
884
|
+
const title = typeof owner.title === "string" ? owner.title : "";
|
|
885
|
+
const existing = counts.get(workspaceId);
|
|
886
|
+
if (existing === undefined) counts.set(workspaceId, { id: workspaceId, name: title, count: 1 });
|
|
887
|
+
else existing.count++;
|
|
888
|
+
}
|
|
889
|
+
return [...counts.values()]
|
|
890
|
+
.sort((a, b) => b.count - a.count || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
891
|
+
}
|
|
892
|
+
|
|
736
893
|
//#endregion
|
|
737
894
|
//#region badge bridge
|
|
738
895
|
// In-package badge bridge. Two number sources:
|
|
@@ -746,6 +903,16 @@ window.__ModuleLoader__.load({
|
|
|
746
903
|
let realCount = 0;
|
|
747
904
|
/** The sessions behind `realCount`, for the sidebar list (newest first). */
|
|
748
905
|
let realItems = [];
|
|
906
|
+
/**
|
|
907
|
+
* How many sessions are running right now — the official `running` fact,
|
|
908
|
+
* already folded by `runningSessionIds`. It rides the SAME bridge as the
|
|
909
|
+
* unread number because it has the same two consumers (the brand mark and
|
|
910
|
+
* the tab icon) and the two must never disagree about which of them is
|
|
911
|
+
* being shown.
|
|
912
|
+
*/
|
|
913
|
+
let runningTotal = 0;
|
|
914
|
+
/** The sessions behind `runningTotal` (`{ id, title, where }`), for the running list. */
|
|
915
|
+
let runningItems = [];
|
|
749
916
|
let manualCount = 0;
|
|
750
917
|
let badgeSize = "md";
|
|
751
918
|
const badgeListeners = new Set();
|
|
@@ -753,11 +920,30 @@ window.__ModuleLoader__.load({
|
|
|
753
920
|
const n = Number(value);
|
|
754
921
|
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
|
|
755
922
|
}
|
|
923
|
+
/**
|
|
924
|
+
* A stable identity for one running list.
|
|
925
|
+
*
|
|
926
|
+
* The number alone is not enough: with one session running, handing over to a
|
|
927
|
+
* DIFFERENT session keeps the count at 1, so a guard that only compares counts
|
|
928
|
+
* leaves the open list naming the session that already stopped — the mark and
|
|
929
|
+
* the tab icon stay right while the list lies. Same reason the sidebar marks
|
|
930
|
+
* key off `runningSignature`: compare the list by value.
|
|
931
|
+
* @param items - `{ id, title, where }` rows (anything else is ignored).
|
|
932
|
+
* @returns a string that changes whenever the list does.
|
|
933
|
+
*/
|
|
934
|
+
function runningItemsSignature(items) {
|
|
935
|
+
const out = [];
|
|
936
|
+
for (const item of Array.isArray(items) ? items : []) {
|
|
937
|
+
if (item === null || typeof item !== "object") continue;
|
|
938
|
+
out.push(`${item.id}\u0000${item.title}\u0000${item.where}`);
|
|
939
|
+
}
|
|
940
|
+
return out.join("\u0001");
|
|
941
|
+
}
|
|
756
942
|
function effectiveCount() {
|
|
757
943
|
return badgeSource === "manual" ? manualCount : realCount;
|
|
758
944
|
}
|
|
759
945
|
function badgeSnapshot() {
|
|
760
|
-
return { count: effectiveCount(), size: badgeSize, source: badgeSource, items: badgeSource === "manual" ? [] : realItems };
|
|
946
|
+
return { count: effectiveCount(), size: badgeSize, source: badgeSource, items: badgeSource === "manual" ? [] : realItems, running: runningTotal, runningItems };
|
|
761
947
|
}
|
|
762
948
|
function notifyBadge() {
|
|
763
949
|
const snapshot = badgeSnapshot();
|
|
@@ -779,6 +965,26 @@ window.__ModuleLoader__.load({
|
|
|
779
965
|
realItems = Array.isArray(items) ? items : [];
|
|
780
966
|
notifyBadge();
|
|
781
967
|
}
|
|
968
|
+
/**
|
|
969
|
+
* The running sessions, as one number on the same bridge — plus the list
|
|
970
|
+
* behind it, so the yellow count can be opened like the red one.
|
|
971
|
+
*
|
|
972
|
+
* Emitted from every render of the headless badge source, so the "did it
|
|
973
|
+
* actually change" guard is what keeps a re-render from repainting the tab
|
|
974
|
+
* icon and the brand mark for nothing. It is NOT routed through
|
|
975
|
+
* `emitRealBadge`: the unread number and the running number are independent
|
|
976
|
+
* facts, and a manual test number must not disturb the other.
|
|
977
|
+
* @param value - how many sessions are running; anything non-positive is 0.
|
|
978
|
+
* @param items - `{ id, title, where }`, one per running session, in list order.
|
|
979
|
+
*/
|
|
980
|
+
function emitRunningState(value, items) {
|
|
981
|
+
const next = normalizeCount(value);
|
|
982
|
+
const nextItems = Array.isArray(items) ? items : [];
|
|
983
|
+
if (next === runningTotal && runningItemsSignature(nextItems) === runningItemsSignature(runningItems)) return;
|
|
984
|
+
runningTotal = next;
|
|
985
|
+
runningItems = nextItems;
|
|
986
|
+
notifyBadge();
|
|
987
|
+
}
|
|
782
988
|
function emitBadgeSize(value) {
|
|
783
989
|
badgeSize = Object.prototype.hasOwnProperty.call(BADGE_SCALES, value) ? value : "md";
|
|
784
990
|
notifyBadge();
|
|
@@ -972,6 +1178,202 @@ window.__ModuleLoader__.load({
|
|
|
972
1178
|
reconcileAppBadge(true);
|
|
973
1179
|
}
|
|
974
1180
|
|
|
1181
|
+
//#endregion
|
|
1182
|
+
//#region notifications permission (the WebKit half of app badging)
|
|
1183
|
+
// On WebKit the badge and the notifications permission are one feature:
|
|
1184
|
+
// Apple's own words are "when users allow a web app to send
|
|
1185
|
+
// notifications, that includes permissions for the web app to use
|
|
1186
|
+
// badging" — so a macOS web app added to the Dock (and an iOS
|
|
1187
|
+
// home-screen web app) draws NO badge until the user allows
|
|
1188
|
+
// notifications (sources: docs/adr/0009-notification-permission.md).
|
|
1189
|
+
//
|
|
1190
|
+
// This region does NOT gate `applyAppBadge`: Chromium needs none of it,
|
|
1191
|
+
// and the API only reports "not permitted" by drawing nothing, which is
|
|
1192
|
+
// undetectable. Keeping the call site unconditional is ADR 0005's rule;
|
|
1193
|
+
// what is added here is the PRECONDITION, made visible and requestable
|
|
1194
|
+
// from one user gesture.
|
|
1195
|
+
/** The three states the spec defines; anything else is "no API". */
|
|
1196
|
+
const NOTIFICATION_PERMISSION_STATES = ["granted", "denied", "default"];
|
|
1197
|
+
|
|
1198
|
+
/**
|
|
1199
|
+
* Normalize one reading of `Notification.permission` into a local fact.
|
|
1200
|
+
* @param value - the raw reading; only the spec strings count.
|
|
1201
|
+
* @returns `"granted" | "denied" | "default" | "unsupported"`.
|
|
1202
|
+
*/
|
|
1203
|
+
function normalizeNotificationPermission(value) {
|
|
1204
|
+
return typeof value === "string" && NOTIFICATION_PERMISSION_STATES.includes(value) ? value : "unsupported";
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/**
|
|
1208
|
+
* Normalize an already-shaped probe, so every reader of it takes dirty
|
|
1209
|
+
* input the same way: only the three spec states are real, and only a
|
|
1210
|
+
* strict `true` is a request entry point.
|
|
1211
|
+
* @param probe - a `{ state, canRequest }` probe, or anything else.
|
|
1212
|
+
* @returns the same two facts, normalized.
|
|
1213
|
+
*/
|
|
1214
|
+
function notificationPermissionFacts(probe) {
|
|
1215
|
+
const source = probe !== null && typeof probe === "object" ? probe : {};
|
|
1216
|
+
return {
|
|
1217
|
+
state: normalizeNotificationPermission(source.state),
|
|
1218
|
+
canRequest: source.canRequest === true
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/**
|
|
1223
|
+
* Shape a raw `{ permission, canRequest }` reading, mirroring
|
|
1224
|
+
* `badgeCapabilityProbe`: two independent local facts, no platform claim.
|
|
1225
|
+
* @param env - the raw reading; anything else reads false.
|
|
1226
|
+
* @returns the probe for this device.
|
|
1227
|
+
*/
|
|
1228
|
+
function notificationPermissionProbe(env) {
|
|
1229
|
+
const source = env !== null && typeof env === "object" ? env : {};
|
|
1230
|
+
return notificationPermissionFacts({
|
|
1231
|
+
state: normalizeNotificationPermission(source.permission),
|
|
1232
|
+
canRequest: source.canRequest === true
|
|
1233
|
+
});
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
/**
|
|
1237
|
+
* Read that fact off the window this code runs in.
|
|
1238
|
+
*
|
|
1239
|
+
* "unsupported" covers both a browser with no Notification API and a
|
|
1240
|
+
* permission string this code has never heard of — from the user's side
|
|
1241
|
+
* those are the same thing: there is nothing to grant here.
|
|
1242
|
+
* @returns the probe for this device.
|
|
1243
|
+
*/
|
|
1244
|
+
function notificationPermissionEnv() {
|
|
1245
|
+
const api = typeof Notification === "object" && Notification !== null ? Notification : null;
|
|
1246
|
+
return notificationPermissionProbe({
|
|
1247
|
+
permission: api === null ? undefined : api.permission,
|
|
1248
|
+
canRequest: api !== null && typeof api.requestPermission === "function"
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* Which settings-page sentence describes this reading.
|
|
1254
|
+
* @param probe - a `notificationPermissionProbe` result.
|
|
1255
|
+
* @returns a locale key.
|
|
1256
|
+
*/
|
|
1257
|
+
function notificationPermissionKey(probe) {
|
|
1258
|
+
const facts = notificationPermissionFacts(probe);
|
|
1259
|
+
if (facts.state === "granted") return "notificationPermissionGranted";
|
|
1260
|
+
if (facts.state === "denied") return "notificationPermissionDenied";
|
|
1261
|
+
if (facts.state === "default") return "notificationPermissionDefault";
|
|
1262
|
+
return "notificationPermissionUnsupported";
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/**
|
|
1266
|
+
* Whether the "allow notifications" button can do anything.
|
|
1267
|
+
*
|
|
1268
|
+
* Only an undecided permission is worth a prompt: once granted there is
|
|
1269
|
+
* nothing left to ask, and once denied the browser refuses to prompt
|
|
1270
|
+
* again, so the settings page says where to change it instead of showing
|
|
1271
|
+
* a button that would silently do nothing.
|
|
1272
|
+
* @param probe - a `notificationPermissionProbe` result.
|
|
1273
|
+
* @returns true only when a prompt could still decide the fact.
|
|
1274
|
+
*/
|
|
1275
|
+
function notificationPermissionRequestable(probe) {
|
|
1276
|
+
const facts = notificationPermissionFacts(probe);
|
|
1277
|
+
return facts.state === "default" && facts.canRequest === true;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
/**
|
|
1281
|
+
* Ask for the notifications permission — the ONE place that does.
|
|
1282
|
+
*
|
|
1283
|
+
* Must be called from a user gesture (the settings button's click):
|
|
1284
|
+
* WebKit and Chromium both drop a prompt that no activation asked for.
|
|
1285
|
+
* Resolves to a fresh probe and never rejects, so the caller can drop the
|
|
1286
|
+
* result straight into React state; the old callback form (Safari 15) is
|
|
1287
|
+
* why the returned promise is not the only path out.
|
|
1288
|
+
* @returns a Promise for the permission probe after the prompt.
|
|
1289
|
+
*/
|
|
1290
|
+
function requestNotificationPermission() {
|
|
1291
|
+
const api = typeof Notification === "object" && Notification !== null ? Notification : null;
|
|
1292
|
+
if (api === null || typeof api.requestPermission !== "function") return Promise.resolve(notificationPermissionEnv());
|
|
1293
|
+
return new Promise((resolve) => {
|
|
1294
|
+
// The prompt's own answer is authoritative when it comes back;
|
|
1295
|
+
// re-reading `.permission` covers the promise form and a rejection
|
|
1296
|
+
// (no user gesture, or not an installed web app on WebKit).
|
|
1297
|
+
const settle = (value) => {
|
|
1298
|
+
const next = notificationPermissionEnv();
|
|
1299
|
+
resolve({
|
|
1300
|
+
state: typeof value === "string" ? normalizeNotificationPermission(value) : next.state,
|
|
1301
|
+
canRequest: next.canRequest
|
|
1302
|
+
});
|
|
1303
|
+
};
|
|
1304
|
+
let returned;
|
|
1305
|
+
try {
|
|
1306
|
+
returned = api.requestPermission(settle);
|
|
1307
|
+
} catch {
|
|
1308
|
+
settle(undefined);
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
if (returned !== null && typeof returned === "object" && typeof returned.then === "function") {
|
|
1312
|
+
returned.then(settle, () => settle(undefined));
|
|
1313
|
+
}
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
/**
|
|
1318
|
+
* Follow the permission while this page is open.
|
|
1319
|
+
*
|
|
1320
|
+
* It can move outside the page (System Settings, the browser's own
|
|
1321
|
+
* settings, another tab's prompt), and a stale "not granted" beside a
|
|
1322
|
+
* granted permission is exactly the kind of lie this half avoids.
|
|
1323
|
+
* `permissions.query({ name: "notifications" })` is the precise channel;
|
|
1324
|
+
* a re-read on focus/visibilitychange is the portable one (WebKit does
|
|
1325
|
+
* not implement every permission descriptor), and both are cheap.
|
|
1326
|
+
* Publishes only when the fact actually changed.
|
|
1327
|
+
* @param fn - called with a fresh probe when the fact moved.
|
|
1328
|
+
* @returns an unsubscribe function.
|
|
1329
|
+
*/
|
|
1330
|
+
function subscribeNotificationPermission(fn) {
|
|
1331
|
+
if (typeof fn !== "function") return () => {};
|
|
1332
|
+
const win = typeof window !== "undefined" ? window : null;
|
|
1333
|
+
const navigatorObject = typeof navigator === "object" && navigator !== null ? navigator : null;
|
|
1334
|
+
const read = () => {
|
|
1335
|
+
const next = notificationPermissionEnv();
|
|
1336
|
+
return { next, id: `${next.state}:${next.canRequest === true}` };
|
|
1337
|
+
};
|
|
1338
|
+
// Seeded with the mount reading, so mounting never re-publishes the
|
|
1339
|
+
// value the settings page already rendered.
|
|
1340
|
+
let last = read().id;
|
|
1341
|
+
// Fire only on a real change: the settings page already rendered the
|
|
1342
|
+
// mount reading, and a focus event that changed nothing must not repaint.
|
|
1343
|
+
const publish = () => {
|
|
1344
|
+
const reading = read();
|
|
1345
|
+
if (reading.id === last) return;
|
|
1346
|
+
last = reading.id;
|
|
1347
|
+
try { fn(reading.next); } catch {}
|
|
1348
|
+
};
|
|
1349
|
+
/** @type {Array<() => void>} */
|
|
1350
|
+
const unsubscribes = [];
|
|
1351
|
+
if (win !== null && typeof win.addEventListener === "function") {
|
|
1352
|
+
for (const event of ["focus", "visibilitychange", "pageshow"]) {
|
|
1353
|
+
win.addEventListener(event, publish);
|
|
1354
|
+
unsubscribes.push(() => win.removeEventListener(event, publish));
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
try {
|
|
1358
|
+
if (navigatorObject !== null && navigatorObject.permissions && typeof navigatorObject.permissions.query === "function") {
|
|
1359
|
+
const pending = navigatorObject.permissions.query({ name: "notifications" });
|
|
1360
|
+
if (pending !== null && typeof pending === "object" && typeof pending.then === "function") {
|
|
1361
|
+
pending.then((status) => {
|
|
1362
|
+
if (status === null || typeof status !== "object" || typeof status.addEventListener !== "function") return;
|
|
1363
|
+
status.addEventListener("change", publish);
|
|
1364
|
+
unsubscribes.push(() => status.removeEventListener("change", publish));
|
|
1365
|
+
}).catch(() => {});
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
} catch {}
|
|
1369
|
+
return () => {
|
|
1370
|
+
for (const off of unsubscribes) {
|
|
1371
|
+
try { off(); } catch {}
|
|
1372
|
+
}
|
|
1373
|
+
unsubscribes.length = 0;
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
|
|
975
1377
|
//#endregion
|
|
976
1378
|
//#region favicon compositing
|
|
977
1379
|
// The browser accepts exactly ONE image per <link rel="icon">, so a tab
|
|
@@ -1091,6 +1493,58 @@ window.__ModuleLoader__.load({
|
|
|
1091
1493
|
c2d.fillText(text, cx, cy + size * 0.01);
|
|
1092
1494
|
c2d.restore();
|
|
1093
1495
|
}
|
|
1496
|
+
/**
|
|
1497
|
+
* The common badge scale when BOTH tab-icon numbers are shown.
|
|
1498
|
+
*
|
|
1499
|
+
* A big red "2" beside a small yellow "1" reads as a rendering bug, so the
|
|
1500
|
+
* two pills share one size: the largest that fits them stacked inside the
|
|
1501
|
+
* canvas (four half-heights + the edge pads + the seam between them). The
|
|
1502
|
+
* constraint is absolute — it is the canvas that is small, not the setting —
|
|
1503
|
+
* so this returns a SCALE multiplier the caller folds into the normal one,
|
|
1504
|
+
* and a pill drawn alone keeps whatever size the setting asks for.
|
|
1505
|
+
* @param scale - the badge-size multiplier from the setting.
|
|
1506
|
+
* @returns a multiplier ≤ 1 to apply to it.
|
|
1507
|
+
*/
|
|
1508
|
+
function faviconBadgeFitScale(scale) {
|
|
1509
|
+
const k = typeof scale === "number" && scale > 0 ? scale : 1;
|
|
1510
|
+
// 0.205: four half-heights plus both white rings plus a seam, inside the
|
|
1511
|
+
// canvas. Anything larger and the two rings merge into one blob.
|
|
1512
|
+
return Math.min(1, 0.205 / (0.28 * k));
|
|
1513
|
+
}
|
|
1514
|
+
/**
|
|
1515
|
+
* The yellow "N running" badge on the tab icon: bottom-right.
|
|
1516
|
+
*
|
|
1517
|
+
* Mirrors `drawFaviconBadge` exactly — same geometry, same white ring, same
|
|
1518
|
+
* digit size for the same `scale` — so the two numbers can sit one above the
|
|
1519
|
+
* other as equals. Dark digits, because yellow needs dark ink.
|
|
1520
|
+
* @param c2d - the tab-icon canvas context.
|
|
1521
|
+
* @param size - canvas edge in px.
|
|
1522
|
+
* @param count - how many sessions are running.
|
|
1523
|
+
* @param scale - the badge-size multiplier (already fitted when paired).
|
|
1524
|
+
*/
|
|
1525
|
+
function drawFaviconRunningBadge(c2d, size, count, scale) {
|
|
1526
|
+
const text = badgeLabel(count);
|
|
1527
|
+
const r = size * 0.28 * (scale || 1);
|
|
1528
|
+
const wide = text.length > 1;
|
|
1529
|
+
const w = wide ? r * 2 + size * 0.16 * (text.length - 1) : r * 2;
|
|
1530
|
+
const h = r * 2;
|
|
1531
|
+
const cx = size - w / 2 - size * 0.02;
|
|
1532
|
+
const cy = size - h / 2 - size * 0.02;
|
|
1533
|
+
c2d.save();
|
|
1534
|
+
// White ring so the badge stays readable on any base artwork.
|
|
1535
|
+
c2d.fillStyle = "#ffffff";
|
|
1536
|
+
roundRect(c2d, cx - w / 2 - size * 0.03, cy - h / 2 - size * 0.03, w + size * 0.06, h + size * 0.06);
|
|
1537
|
+
c2d.fill();
|
|
1538
|
+
c2d.fillStyle = RUNNING_YELLOW;
|
|
1539
|
+
roundRect(c2d, cx - w / 2, cy - h / 2, w, h);
|
|
1540
|
+
c2d.fill();
|
|
1541
|
+
c2d.fillStyle = "#1f1f1f";
|
|
1542
|
+
c2d.font = `700 ${Math.round(h * (wide ? 0.56 : 0.68))}px system-ui, -apple-system, sans-serif`;
|
|
1543
|
+
c2d.textAlign = "center";
|
|
1544
|
+
c2d.textBaseline = "middle";
|
|
1545
|
+
c2d.fillText(text, cx, cy + size * 0.01);
|
|
1546
|
+
c2d.restore();
|
|
1547
|
+
}
|
|
1094
1548
|
function loadIconImage(href) {
|
|
1095
1549
|
return new Promise((resolve, reject) => {
|
|
1096
1550
|
const img = new Image();
|
|
@@ -1101,7 +1555,10 @@ window.__ModuleLoader__.load({
|
|
|
1101
1555
|
}
|
|
1102
1556
|
async function applyBadgeToFavicon(count, size) {
|
|
1103
1557
|
const base = desiredIconHref();
|
|
1104
|
-
|
|
1558
|
+
// Two independent marks ride one composite, so "nothing to draw" means
|
|
1559
|
+
// neither of them is wanted — not merely "no unread".
|
|
1560
|
+
const running = runningTotal > 0;
|
|
1561
|
+
if (count <= 0 && !running) {
|
|
1105
1562
|
// No badge to draw. With a custom icon active the page must carry it on
|
|
1106
1563
|
// EVERY link (a stale scheme-specific sibling outranks the one we set);
|
|
1107
1564
|
// with none, the shipped dark/light pair goes back untouched — the
|
|
@@ -1129,8 +1586,13 @@ window.__ModuleLoader__.load({
|
|
|
1129
1586
|
const dw = iw * scale;
|
|
1130
1587
|
const dh = ih * scale;
|
|
1131
1588
|
c2d.drawImage(img, (FAVICON_SIZE - dw) / 2, (FAVICON_SIZE - dh) / 2, dw, dh);
|
|
1132
|
-
|
|
1133
|
-
|
|
1589
|
+
// One size for both numbers: when they share the tab icon they shrink
|
|
1590
|
+
// together to the largest pair that fits, so neither reads as a bug and
|
|
1591
|
+
// the two can never overlap.
|
|
1592
|
+
const badgeUnit = badgeScale(size);
|
|
1593
|
+
const badgeK = count > 0 && running ? badgeUnit * faviconBadgeFitScale(badgeUnit) : badgeUnit;
|
|
1594
|
+
if (count > 0) drawFaviconBadge(c2d, FAVICON_SIZE, count, badgeK);
|
|
1595
|
+
if (running) drawFaviconRunningBadge(c2d, FAVICON_SIZE, runningTotal, badgeK); setIconEverywhere(canvas.toDataURL("image/png"), "image/png");
|
|
1134
1596
|
} catch (error) {
|
|
1135
1597
|
// Never silent: a failure here is invisible in the UI (the tab simply
|
|
1136
1598
|
// keeps its previous icon), which once cost a whole debugging session.
|
|
@@ -1186,7 +1648,7 @@ window.__ModuleLoader__.load({
|
|
|
1186
1648
|
// stale sibling is exactly what used to outrank the icon we set.
|
|
1187
1649
|
const hrefs = iconLinks().map((candidate) => candidate.getAttribute("href"));
|
|
1188
1650
|
const ours = hrefs.length > 0 && hrefs.every((value) => typeof value === "string" && value.slice(0, 5) === "data:");
|
|
1189
|
-
if (effectiveCount() <= 0) {
|
|
1651
|
+
if (effectiveCount() <= 0 && runningTotal <= 0) {
|
|
1190
1652
|
// Carries the custom icon, or puts the shipped pair back. Both writers
|
|
1191
1653
|
// only touch a link that actually differs, so this stays a no-op once
|
|
1192
1654
|
// the page is correct and cannot re-arm the head observer.
|
|
@@ -1205,22 +1667,38 @@ window.__ModuleLoader__.load({
|
|
|
1205
1667
|
//#endregion
|
|
1206
1668
|
|
|
1207
1669
|
//#region unread panel
|
|
1670
|
+
//#region header panel
|
|
1208
1671
|
/**
|
|
1209
|
-
*
|
|
1210
|
-
*
|
|
1672
|
+
* Which list is open, and where the button that opened it sits.
|
|
1673
|
+
*
|
|
1674
|
+
* One store for both lists, and one open slot: the two numbers sit in the same
|
|
1675
|
+
* header row, so two panels open at once would stack on top of each other
|
|
1676
|
+
* under the same anchor. `mode` is therefore "unread" | "running" | null, and
|
|
1677
|
+
* opening either closes the other by construction.
|
|
1678
|
+
*
|
|
1679
|
+
* The buttons and the floating list live in two different slots, so they
|
|
1211
1680
|
* cannot share React state — this tiny store is what they agree through.
|
|
1212
1681
|
*/
|
|
1213
|
-
let
|
|
1214
|
-
let
|
|
1215
|
-
const
|
|
1216
|
-
function
|
|
1217
|
-
|
|
1218
|
-
if (anchor !== undefined)
|
|
1219
|
-
|
|
1682
|
+
let panelMode = null;
|
|
1683
|
+
let panelAnchor = null;
|
|
1684
|
+
const panelListeners = new Set();
|
|
1685
|
+
function emitPanel(mode, anchor) {
|
|
1686
|
+
panelMode = mode === "unread" || mode === "running" ? mode : null;
|
|
1687
|
+
if (anchor !== undefined) panelAnchor = anchor;
|
|
1688
|
+
panelListeners.forEach((fn) => { try { fn(); } catch {} });
|
|
1689
|
+
}
|
|
1690
|
+
function subscribePanel(fn) {
|
|
1691
|
+
panelListeners.add(fn);
|
|
1692
|
+
return () => { panelListeners.delete(fn); };
|
|
1220
1693
|
}
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1694
|
+
/**
|
|
1695
|
+
* One marker's button: open its list, or close it when it is already the open
|
|
1696
|
+
* one. Clicking the OTHER marker switches lists instead of stacking them.
|
|
1697
|
+
* @param mode - "unread" (the red count) or "running" (the yellow count).
|
|
1698
|
+
* @param anchor - the clicked node's viewport rect, or undefined to keep the last one.
|
|
1699
|
+
*/
|
|
1700
|
+
function togglePanel(mode, anchor) {
|
|
1701
|
+
emitPanel(panelMode === mode ? null : mode, anchor === undefined ? null : anchor);
|
|
1224
1702
|
}
|
|
1225
1703
|
/**
|
|
1226
1704
|
* Tells the badge source that the marks moved somewhere it did not touch —
|
|
@@ -1235,9 +1713,6 @@ window.__ModuleLoader__.load({
|
|
|
1235
1713
|
unreadPokeListeners.add(fn);
|
|
1236
1714
|
return () => { unreadPokeListeners.delete(fn); };
|
|
1237
1715
|
}
|
|
1238
|
-
function toggleUnreadPanel(anchor) {
|
|
1239
|
-
emitUnreadPanel(!unreadPanelOpen, anchor === undefined ? null : anchor);
|
|
1240
|
-
}
|
|
1241
1716
|
/**
|
|
1242
1717
|
* Roll the pending sessions up to one dot per workspace.
|
|
1243
1718
|
*
|
|
@@ -1365,6 +1840,22 @@ window.__ModuleLoader__.load({
|
|
|
1365
1840
|
if (short !== "") parts.push(short);
|
|
1366
1841
|
return parts.join(" · ");
|
|
1367
1842
|
}
|
|
1843
|
+
/**
|
|
1844
|
+
* The dim second line of one running row: that it is running, and which
|
|
1845
|
+
* session it is. Deliberately no age — the official session list carries no
|
|
1846
|
+
* "started at" fact (only `running: boolean`), and this plugin would rather
|
|
1847
|
+
* print less than invent a time. Shape matches `unreadRowSubtitle` so the two
|
|
1848
|
+
* lists read as one panel with two modes.
|
|
1849
|
+
* @param item - a row from the badge bridge's `runningItems`.
|
|
1850
|
+
* @param t - locale binder.
|
|
1851
|
+
* @returns the line, already joined.
|
|
1852
|
+
*/
|
|
1853
|
+
function runningRowSubtitle(item, t) {
|
|
1854
|
+
const parts = [t("runningPanelRow")];
|
|
1855
|
+
const short = shortSessionId(item.id);
|
|
1856
|
+
if (short !== "") parts.push(short);
|
|
1857
|
+
return parts.join(" · ");
|
|
1858
|
+
}
|
|
1368
1859
|
/**
|
|
1369
1860
|
* What "mark all read" may actually acknowledge: the turn-end rows.
|
|
1370
1861
|
*
|
|
@@ -1385,6 +1876,175 @@ window.__ModuleLoader__.load({
|
|
|
1385
1876
|
}
|
|
1386
1877
|
return targets;
|
|
1387
1878
|
}
|
|
1879
|
+
//#region reveal in sidebar
|
|
1880
|
+
/**
|
|
1881
|
+
* Locating a clicked session in the sidebar: expand its workspace, scroll to the
|
|
1882
|
+
* row, light it up for a moment.
|
|
1883
|
+
*
|
|
1884
|
+
* The official sidebar does exactly this by itself — but ONLY for its own search
|
|
1885
|
+
* results. The state behind that (`revealSessionId`) is private to that component
|
|
1886
|
+
* and no Service exposes it, while `uiWorkspace.openSession` switches the main
|
|
1887
|
+
* view and nothing else. So this drives the shipped rows the way a user's click
|
|
1888
|
+
* would: the workspace row toggles its group, the "show more" button raises the
|
|
1889
|
+
* collapsed-session limit.
|
|
1890
|
+
*
|
|
1891
|
+
* It therefore reads the page structure, and it is DECORATIVE: by the time it runs
|
|
1892
|
+
* the session is already open. Moved markup (a DSH upgrade), a rail sidebar or an
|
|
1893
|
+
* open search box all degrade to "the sidebar did not move" — never to an error,
|
|
1894
|
+
* and never to a blocked click.
|
|
1895
|
+
*/
|
|
1896
|
+
const REVEAL_ROW_KEY_ATTR = "data-row-key";
|
|
1897
|
+
const REVEAL_FLASH_ATTR = "data-icon-custom-flash";
|
|
1898
|
+
const REVEAL_FLASH_STYLE_ID = "dsh-icon-custom-flash-css";
|
|
1899
|
+
/** How long the located row stays lit. */
|
|
1900
|
+
const REVEAL_FLASH_MS = 1200;
|
|
1901
|
+
/**
|
|
1902
|
+
* How many DOM steps one reveal may take.
|
|
1903
|
+
*
|
|
1904
|
+
* The official flow does this in one shot by writing its own state; we only have
|
|
1905
|
+
* clicks, and every click needs a commit before the row can exist. The realistic
|
|
1906
|
+
* worst case is a collapsed workspace whose session sits past "show more", which
|
|
1907
|
+
* releases five rows per click — so this covers a workspace of roughly twenty
|
|
1908
|
+
* sessions and then stops, rather than driving a shipped control in a loop.
|
|
1909
|
+
*/
|
|
1910
|
+
const REVEAL_MAX_STEPS = 6;
|
|
1911
|
+
/**
|
|
1912
|
+
* The flash rules: a short outline pulse, not a lasting class. The point is to say
|
|
1913
|
+
* "here it is", not to leave a mark on a shipped row.
|
|
1914
|
+
*/
|
|
1915
|
+
const REVEAL_FLASH_CSS = [
|
|
1916
|
+
`[${REVEAL_FLASH_ATTR}]{animation:dsh-icon-custom-flash ${REVEAL_FLASH_MS}ms ease-out}`,
|
|
1917
|
+
`@keyframes dsh-icon-custom-flash{0%{outline:2px solid var(--dsw-alias-state-business-primary,#4d6bfe);outline-offset:-2px}100%{outline:2px solid transparent;outline-offset:-2px}}`,
|
|
1918
|
+
`@media (prefers-reduced-motion:reduce){[${REVEAL_FLASH_ATTR}]{animation:none;outline:2px solid var(--dsw-alias-state-business-primary,#4d6bfe);outline-offset:-2px}}`
|
|
1919
|
+
].join("");
|
|
1920
|
+
/** The row currently lit, and the timer that will un-light it. */
|
|
1921
|
+
let revealFlashRow = null;
|
|
1922
|
+
let revealFlashTimer = 0;
|
|
1923
|
+
|
|
1924
|
+
/**
|
|
1925
|
+
* What to do next to make one session's row visible.
|
|
1926
|
+
*
|
|
1927
|
+
* Pure, so the order — and the giving up — is testable without a browser: a row
|
|
1928
|
+
* that is already there gets scrolled to; otherwise its workspace is expanded;
|
|
1929
|
+
* otherwise "show more" is clicked; otherwise nothing is touched at all.
|
|
1930
|
+
* @param state - `{ hasRow, hasWorkspace, expanded, hasOverflow, overflowExpanded, steps }`.
|
|
1931
|
+
* @returns `"scroll" | "expand" | "overflow" | "give-up"`.
|
|
1932
|
+
*/
|
|
1933
|
+
function revealAction(state) {
|
|
1934
|
+
if (state.hasRow === true) return "scroll";
|
|
1935
|
+
if (state.steps >= REVEAL_MAX_STEPS) return "give-up";
|
|
1936
|
+
if (state.hasWorkspace === true && state.expanded !== true) return "expand";
|
|
1937
|
+
// `overflowExpanded` must be false: that button toggles, and this feature has
|
|
1938
|
+
// no business COLLAPSING a workspace the reader deliberately opened up.
|
|
1939
|
+
if (state.hasOverflow === true && state.overflowExpanded !== true) return "overflow";
|
|
1940
|
+
return "give-up";
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
/**
|
|
1944
|
+
* The shipped row carrying one `data-row-key`, matched by VALUE.
|
|
1945
|
+
*
|
|
1946
|
+
* Not by a selector built from the key: session ids go into the key verbatim, so a
|
|
1947
|
+
* selector would need escaping rules that a plain comparison simply does not have.
|
|
1948
|
+
* @param container - the browsing region root.
|
|
1949
|
+
* @param key - e.g. `session:<id>` / `workspace:<id>` / `overflow:<id>`.
|
|
1950
|
+
* @returns the element, or null.
|
|
1951
|
+
*/
|
|
1952
|
+
function findRowByKey(container, key) {
|
|
1953
|
+
try {
|
|
1954
|
+
for (const node of container.querySelectorAll(`[${REVEAL_ROW_KEY_ATTR}]`)) {
|
|
1955
|
+
if (node.getAttribute(REVEAL_ROW_KEY_ATTR) === key) return node;
|
|
1956
|
+
}
|
|
1957
|
+
} catch { /* a broken subtree must not become a thrown click */ }
|
|
1958
|
+
return null;
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
/** One paint's worth of waiting, so a click can commit before the row is looked for. */
|
|
1962
|
+
function nextPaint() {
|
|
1963
|
+
return new Promise((resolve) => {
|
|
1964
|
+
try {
|
|
1965
|
+
if (typeof window.requestAnimationFrame === "function") window.requestAnimationFrame(() => resolve());
|
|
1966
|
+
else window.setTimeout(resolve, 16);
|
|
1967
|
+
} catch { resolve(); }
|
|
1968
|
+
});
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
/** Install the flash rules once per page. */
|
|
1972
|
+
function ensureRevealFlashStyle() {
|
|
1973
|
+
try {
|
|
1974
|
+
if (document.getElementById(REVEAL_FLASH_STYLE_ID) !== null) return;
|
|
1975
|
+
const style = document.createElement("style");
|
|
1976
|
+
style.id = REVEAL_FLASH_STYLE_ID;
|
|
1977
|
+
style.textContent = REVEAL_FLASH_CSS;
|
|
1978
|
+
document.head.appendChild(style);
|
|
1979
|
+
} catch { /* styling is cosmetic; never let it reach a click */ }
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
/** Take the flash off, whoever put it there. Also the unload path. */
|
|
1983
|
+
function clearRevealFlash() {
|
|
1984
|
+
if (revealFlashTimer !== 0) {
|
|
1985
|
+
try { window.clearTimeout(revealFlashTimer); } catch { /* the page is going away */ }
|
|
1986
|
+
revealFlashTimer = 0;
|
|
1987
|
+
}
|
|
1988
|
+
if (revealFlashRow !== null) {
|
|
1989
|
+
try { revealFlashRow.removeAttribute(REVEAL_FLASH_ATTR); } catch { /* already detached */ }
|
|
1990
|
+
revealFlashRow = null;
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
/**
|
|
1995
|
+
* Scroll one shipped row into view and light it for a moment.
|
|
1996
|
+
*
|
|
1997
|
+
* `block:"nearest"` is the official call and the reason a reveal does not yank the
|
|
1998
|
+
* whole list: it moves the list only as far as the row needs.
|
|
1999
|
+
* @param row - the session row element.
|
|
2000
|
+
*/
|
|
2001
|
+
function flashRow(row) {
|
|
2002
|
+
try {
|
|
2003
|
+
clearRevealFlash();
|
|
2004
|
+
ensureRevealFlashStyle();
|
|
2005
|
+
row.setAttribute(REVEAL_FLASH_ATTR, "1");
|
|
2006
|
+
revealFlashRow = row;
|
|
2007
|
+
revealFlashTimer = window.setTimeout(() => { clearRevealFlash(); }, REVEAL_FLASH_MS);
|
|
2008
|
+
} catch { /* cosmetic */ }
|
|
2009
|
+
try { row.scrollIntoView({ block: "nearest" }); } catch { /* older engines take no options */ }
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
/**
|
|
2013
|
+
* Put one session's row in front of the reader.
|
|
2014
|
+
*
|
|
2015
|
+
* Best effort from the first line to the last: the session is ALREADY open when
|
|
2016
|
+
* this runs, so every exit here — no container, no workspace id, a sidebar that
|
|
2017
|
+
* never renders the row — costs the reader nothing but the scroll.
|
|
2018
|
+
* @param sessionId - the session to locate.
|
|
2019
|
+
* @param workspaceId - its workspace, when the official snapshot knows one.
|
|
2020
|
+
*/
|
|
2021
|
+
async function revealSessionRow(sessionId, workspaceId) {
|
|
2022
|
+
if (typeof sessionId !== "string" || sessionId === "") return;
|
|
2023
|
+
if (unreadConfig === null || typeof unreadConfig !== "object" || unreadConfig.revealOnOpen === false) return;
|
|
2024
|
+
const container = workspaceDotContainer();
|
|
2025
|
+
if (container === null) return;
|
|
2026
|
+
const wsKey = typeof workspaceId === "string" && workspaceId !== "" ? `workspace:${workspaceId}` : null;
|
|
2027
|
+
const moreKey = typeof workspaceId === "string" && workspaceId !== "" ? `overflow:${workspaceId}` : null;
|
|
2028
|
+
for (let steps = 0; steps < REVEAL_MAX_STEPS; steps++) {
|
|
2029
|
+
const row = findRowByKey(container, `session:${sessionId}`);
|
|
2030
|
+
const wsRow = wsKey === null ? null : findRowByKey(container, wsKey);
|
|
2031
|
+
const more = moreKey === null ? null : findRowByKey(container, moreKey);
|
|
2032
|
+
const action = revealAction({
|
|
2033
|
+
hasRow: row !== null,
|
|
2034
|
+
hasWorkspace: wsRow !== null,
|
|
2035
|
+
expanded: wsRow !== null && wsRow.getAttribute("aria-expanded") === "true",
|
|
2036
|
+
hasOverflow: more !== null,
|
|
2037
|
+
overflowExpanded: more !== null && more.getAttribute("aria-expanded") === "true",
|
|
2038
|
+
steps
|
|
2039
|
+
});
|
|
2040
|
+
if (action === "scroll") { flashRow(row); return; }
|
|
2041
|
+
if (action === "give-up") return;
|
|
2042
|
+
const target = action === "expand" ? wsRow : more;
|
|
2043
|
+
if (target === null) return;
|
|
2044
|
+
try { target.click(); } catch { return; }
|
|
2045
|
+
await nextPaint();
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
1388
2048
|
//#endregion
|
|
1389
2049
|
|
|
1390
2050
|
//#region workspace dots
|
|
@@ -1399,6 +2059,18 @@ window.__ModuleLoader__.load({
|
|
|
1399
2059
|
*/
|
|
1400
2060
|
const WS_DOT_ATTR = "data-icon-custom-wsdot";
|
|
1401
2061
|
const ROW_DOT_ATTR = "data-icon-custom-rowdot";
|
|
2062
|
+
/** The running mark's two shapes: a dot after a session title (and on a folder icon), plus the header count. */
|
|
2063
|
+
const RUN_DOT_ATTR = "data-icon-custom-rundot";
|
|
2064
|
+
const RUN_WS_ATTR = "data-icon-custom-runws";
|
|
2065
|
+
/**
|
|
2066
|
+
* How far the folder icon's red dot steps left when the yellow dot joins it.
|
|
2067
|
+
*
|
|
2068
|
+
* An 8px dot plus a 2px seam: the pair then still sits over the icon's right
|
|
2069
|
+
* half instead of one of them hanging off into the workspace name. The red
|
|
2070
|
+
* steps back the moment the yellow is gone, so the unread marker keeps its
|
|
2071
|
+
* old place whenever it is alone.
|
|
2072
|
+
*/
|
|
2073
|
+
const WS_DOT_PAIR_SHIFT = 10;
|
|
1402
2074
|
const WS_ROW_MIN_H = 18;
|
|
1403
2075
|
const WS_ROW_MAX_H = 56;
|
|
1404
2076
|
const WS_ROW_MIN_W = 80;
|
|
@@ -1406,6 +2078,10 @@ window.__ModuleLoader__.load({
|
|
|
1406
2078
|
let workspaceDots = [];
|
|
1407
2079
|
/** One entry per unread session: `{ id, title }`, matched against the row title. */
|
|
1408
2080
|
let sessionDots = [];
|
|
2081
|
+
/** One entry per RUNNING session: `{ id, title }` — same shape, same row handle. */
|
|
2082
|
+
let runningSessionDots = [];
|
|
2083
|
+
/** One entry per workspace with running sessions: `{ id, name, count }`. */
|
|
2084
|
+
let runningWorkspaceMarks = [];
|
|
1409
2085
|
let workspaceDotFailures = 0;
|
|
1410
2086
|
let workspaceDotGivenUp = false;
|
|
1411
2087
|
let workspaceDotPainting = false;
|
|
@@ -1420,6 +2096,11 @@ window.__ModuleLoader__.load({
|
|
|
1420
2096
|
const next = marks !== null && typeof marks === "object" ? marks : {};
|
|
1421
2097
|
workspaceDots = Array.isArray(next.workspaces) ? next.workspaces : [];
|
|
1422
2098
|
sessionDots = Array.isArray(next.sessions) ? next.sessions : [];
|
|
2099
|
+
// The running marks ride the same store and the same paint pass as the
|
|
2100
|
+
// unread ones, but they are a different feature: they carry their own
|
|
2101
|
+
// arrays, and whichever switch is off empties only its own.
|
|
2102
|
+
runningSessionDots = Array.isArray(next.runningSessions) ? next.runningSessions : [];
|
|
2103
|
+
runningWorkspaceMarks = Array.isArray(next.runningWorkspaces) ? next.runningWorkspaces : [];
|
|
1423
2104
|
workspaceDotListeners.forEach((fn) => { try { fn(); } catch {} });
|
|
1424
2105
|
}
|
|
1425
2106
|
function subscribeSidebarMarks(fn) {
|
|
@@ -1433,7 +2114,7 @@ window.__ModuleLoader__.load({
|
|
|
1433
2114
|
|| null;
|
|
1434
2115
|
}
|
|
1435
2116
|
function clearWorkspaceDots() {
|
|
1436
|
-
try { document.querySelectorAll("[" + WS_DOT_ATTR + "],[" + ROW_DOT_ATTR + "]").forEach((node) => node.remove()); } catch {}
|
|
2117
|
+
try { document.querySelectorAll("[" + WS_DOT_ATTR + "],[" + ROW_DOT_ATTR + "],[" + RUN_DOT_ATTR + "],[" + RUN_WS_ATTR + "]").forEach((node) => node.remove()); } catch {}
|
|
1437
2118
|
for (const entry of workspaceDotRestores) { try { entry.el.style.position = entry.position; } catch {} }
|
|
1438
2119
|
workspaceDotRestores = [];
|
|
1439
2120
|
}
|
|
@@ -1636,6 +2317,11 @@ window.__ModuleLoader__.load({
|
|
|
1636
2317
|
const enabled = unreadConfig.workspaceDot !== false;
|
|
1637
2318
|
const wanted = enabled ? workspaceDots : [];
|
|
1638
2319
|
const wantedRows = enabled ? sessionDots : [];
|
|
2320
|
+
// A separate switch for a separate feature: the running marks keep working
|
|
2321
|
+
// when the unread dots are turned off, and vice versa.
|
|
2322
|
+
const runningEnabled = runningConfig.enabled !== false;
|
|
2323
|
+
const wantedRunningRows = runningEnabled ? runningSessionDots : [];
|
|
2324
|
+
const wantedRunningMarks = runningEnabled ? runningWorkspaceMarks : [];
|
|
1639
2325
|
const container = workspaceDotContainer();
|
|
1640
2326
|
workspaceDotPainting = true;
|
|
1641
2327
|
workspaceDotPaintedAt = Date.now();
|
|
@@ -1648,12 +2334,20 @@ window.__ModuleLoader__.load({
|
|
|
1648
2334
|
// matched twice for a moment) can no longer blink an existing dot away.
|
|
1649
2335
|
const wantedNames = new Set(wanted.map((entry) => workspaceDotKey(entry)));
|
|
1650
2336
|
const wantedIds = new Set(wantedRows.map((entry) => entry.id));
|
|
2337
|
+
const wantedRunningIds = new Set(wantedRunningRows.map((entry) => entry.id));
|
|
2338
|
+
const wantedRunningNames = new Set(wantedRunningMarks.map((entry) => workspaceDotKey(entry)));
|
|
1651
2339
|
document.querySelectorAll("[" + WS_DOT_ATTR + "]").forEach((node) => {
|
|
1652
2340
|
if (!node.isConnected || !wantedNames.has(node.getAttribute(WS_DOT_ATTR))) node.remove();
|
|
1653
2341
|
});
|
|
1654
2342
|
document.querySelectorAll("[" + ROW_DOT_ATTR + "]").forEach((node) => {
|
|
1655
2343
|
if (!node.isConnected || !wantedIds.has(node.getAttribute(ROW_DOT_ATTR))) node.remove();
|
|
1656
2344
|
});
|
|
2345
|
+
document.querySelectorAll("[" + RUN_DOT_ATTR + "]").forEach((node) => {
|
|
2346
|
+
if (!node.isConnected || !wantedRunningIds.has(node.getAttribute(RUN_DOT_ATTR))) node.remove();
|
|
2347
|
+
});
|
|
2348
|
+
document.querySelectorAll("[" + RUN_WS_ATTR + "]").forEach((node) => {
|
|
2349
|
+
if (!node.isConnected || !wantedRunningNames.has(node.getAttribute(RUN_WS_ATTR))) node.remove();
|
|
2350
|
+
});
|
|
1657
2351
|
if (container === null) { workspaceDotPainting = false; return; } // start-up race
|
|
1658
2352
|
const legacyTitleMatch = !sessionRowKeysInUse(container);
|
|
1659
2353
|
for (const entry of wanted) {
|
|
@@ -1669,7 +2363,6 @@ window.__ModuleLoader__.load({
|
|
|
1669
2363
|
}
|
|
1670
2364
|
if (target === null) { missed++; continue; }
|
|
1671
2365
|
const row = target.row;
|
|
1672
|
-
if (existingMarker(row, WS_DOT_ATTR, key) !== null) { placed++; continue; }
|
|
1673
2366
|
const icon = target.icon;
|
|
1674
2367
|
const rowRect = row.getBoundingClientRect();
|
|
1675
2368
|
const anchorRect = icon.getBoundingClientRect();
|
|
@@ -1677,24 +2370,95 @@ window.__ModuleLoader__.load({
|
|
|
1677
2370
|
workspaceDotRestores.push({ el: row, position: row.style.position });
|
|
1678
2371
|
row.style.position = "relative";
|
|
1679
2372
|
}
|
|
2373
|
+
// "Left red, right yellow": the yellow takes the corner this dot has
|
|
2374
|
+
// always used, and the red steps one dot to the left while the pair is
|
|
2375
|
+
// together (and steps back the moment the yellow is gone).
|
|
2376
|
+
const paired = existingMarker(row, RUN_WS_ATTR, key) !== null;
|
|
2377
|
+
const left = Math.round(anchorRect.right - rowRect.left - 4 - (paired ? WS_DOT_PAIR_SHIFT : 0));
|
|
2378
|
+
const top = Math.round(anchorRect.top - rowRect.top - 3);
|
|
2379
|
+
const existingDot = existingMarker(row, WS_DOT_ATTR, key);
|
|
2380
|
+
if (existingDot !== null) {
|
|
2381
|
+
if (existingDot.style.left !== left + "px") existingDot.style.left = left + "px";
|
|
2382
|
+
if (existingDot.style.top !== top + "px") existingDot.style.top = top + "px";
|
|
2383
|
+
placed++;
|
|
2384
|
+
continue;
|
|
2385
|
+
}
|
|
1680
2386
|
const dot = document.createElement("span");
|
|
1681
2387
|
dot.setAttribute(WS_DOT_ATTR, key);
|
|
1682
2388
|
dot.setAttribute("aria-hidden", "true");
|
|
1683
2389
|
dot.title = (typeof entry.name === "string" && entry.name !== "" ? entry.name : key) + " · " + entry.count;
|
|
1684
2390
|
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
|
-
|
|
2391
|
+
+ "background:#e5484d;box-shadow:0 0 0 1.5px var(--dsw-alias-bg-layer-1,#fff);";
|
|
2392
|
+
dot.style.left = left + "px";
|
|
2393
|
+
dot.style.top = top + "px";
|
|
1688
2394
|
row.appendChild(dot);
|
|
1689
2395
|
placed++;
|
|
1690
2396
|
created++;
|
|
1691
2397
|
}
|
|
1692
|
-
//
|
|
1693
|
-
//
|
|
2398
|
+
// Workspace rows: the running mark is a yellow DOT on the folder icon
|
|
2399
|
+
// (the count lives in the section header, where a number is readable).
|
|
2400
|
+
// "Left red, right yellow": the yellow takes the corner the red has
|
|
2401
|
+
// always used and the red steps aside by exactly one dot.
|
|
2402
|
+
for (const entry of wantedRunningMarks) {
|
|
2403
|
+
const key = workspaceDotKey(entry);
|
|
2404
|
+
if (key === undefined) { missed++; continue; }
|
|
2405
|
+
const keyedRow = workspaceRowElement(container, entry.id);
|
|
2406
|
+
const target = keyedRow === null ? null : folderTargetFor(keyedRow);
|
|
2407
|
+
if (target === null) { missed++; continue; }
|
|
2408
|
+
const row = target.row;
|
|
2409
|
+
const runDot = existingMarker(row, RUN_WS_ATTR, key);
|
|
2410
|
+
const icon = target.icon;
|
|
2411
|
+
const rowRect = row.getBoundingClientRect();
|
|
2412
|
+
const anchorRect = icon.getBoundingClientRect();
|
|
2413
|
+
if (window.getComputedStyle(row).position === "static") {
|
|
2414
|
+
workspaceDotRestores.push({ el: row, position: row.style.position });
|
|
2415
|
+
row.style.position = "relative";
|
|
2416
|
+
}
|
|
2417
|
+
const corner = Math.round(anchorRect.right - rowRect.left - 4);
|
|
2418
|
+
const top = Math.round(anchorRect.top - rowRect.top - 3);
|
|
2419
|
+
// The red keeps the pair's LEFT seat. It is nudged here as well as in
|
|
2420
|
+
// its own loop because on the first paint it cannot see a yellow that
|
|
2421
|
+
// does not exist yet — both places compute the same number, so the
|
|
2422
|
+
// pair never oscillates.
|
|
2423
|
+
const red = existingMarker(row, WS_DOT_ATTR, key);
|
|
2424
|
+
if (red !== null) {
|
|
2425
|
+
const redLeft = corner - WS_DOT_PAIR_SHIFT;
|
|
2426
|
+
if (red.style.left !== redLeft + "px") red.style.left = redLeft + "px";
|
|
2427
|
+
if (red.style.top !== top + "px") red.style.top = top + "px";
|
|
2428
|
+
}
|
|
2429
|
+
if (runDot !== null) {
|
|
2430
|
+
if (runDot.style.left !== corner + "px") runDot.style.left = corner + "px";
|
|
2431
|
+
if (runDot.style.top !== top + "px") runDot.style.top = top + "px";
|
|
2432
|
+
placed++;
|
|
2433
|
+
continue;
|
|
2434
|
+
}
|
|
2435
|
+
const mark = document.createElement("span");
|
|
2436
|
+
mark.setAttribute(RUN_WS_ATTR, key);
|
|
2437
|
+
mark.setAttribute("aria-hidden", "true");
|
|
2438
|
+
mark.title = (entry.name !== "" ? entry.name : key) + " · " + entry.count;
|
|
2439
|
+
mark.style.cssText = "position:absolute;pointer-events:none;width:8px;height:8px;border-radius:50%;"
|
|
2440
|
+
+ "background:" + RUNNING_YELLOW + ";box-shadow:0 0 0 1.5px var(--dsw-alias-bg-layer-1,#fff);";
|
|
2441
|
+
mark.style.left = corner + "px";
|
|
2442
|
+
mark.style.top = top + "px";
|
|
2443
|
+
row.appendChild(mark);
|
|
2444
|
+
placed++;
|
|
2445
|
+
created++;
|
|
2446
|
+
}
|
|
2447
|
+
// Session rows: RED first, then YELLOW — "left red, right yellow", and a
|
|
2448
|
+
// fixed order no matter which of the two features changed last.
|
|
1694
2449
|
for (const entry of wantedRows) {
|
|
1695
2450
|
const target = sessionRowTarget(container, entry.id, entry.title, legacyTitleMatch);
|
|
1696
2451
|
if (target === null) { missed++; continue; }
|
|
1697
|
-
|
|
2452
|
+
const existing = existingMarker(target.row, ROW_DOT_ATTR, entry.id);
|
|
2453
|
+
if (existing !== null) {
|
|
2454
|
+
// Both markers can survive a repaint; keep the red in front of the
|
|
2455
|
+
// yellow (a red painted while the yellow was gone would otherwise
|
|
2456
|
+
// stay glued to the title).
|
|
2457
|
+
const runDot = existingMarker(target.row, RUN_DOT_ATTR, entry.id);
|
|
2458
|
+
if (runDot !== null && existing.nextElementSibling !== runDot) existing.insertAdjacentElement("afterend", runDot);
|
|
2459
|
+
placed++;
|
|
2460
|
+
continue;
|
|
2461
|
+
}
|
|
1698
2462
|
const dot = document.createElement("span");
|
|
1699
2463
|
dot.setAttribute(ROW_DOT_ATTR, entry.id);
|
|
1700
2464
|
dot.setAttribute("aria-hidden", "true");
|
|
@@ -1702,11 +2466,27 @@ window.__ModuleLoader__.load({
|
|
|
1702
2466
|
// Tight to the title on the left, and kept off the timestamp on the
|
|
1703
2467
|
// right: with no right margin the dot reads as part of "24 分钟".
|
|
1704
2468
|
dot.style.cssText = "pointer-events:none;flex:none;width:8px;height:8px;border-radius:50%;"
|
|
1705
|
-
+ "background:#e5484d;margin:0
|
|
2469
|
+
+ "background:#e5484d;margin:0 5px 0 6px;";
|
|
1706
2470
|
target.title.insertAdjacentElement("afterend", dot);
|
|
1707
2471
|
placed++;
|
|
1708
2472
|
created++;
|
|
1709
2473
|
}
|
|
2474
|
+
for (const entry of wantedRunningRows) {
|
|
2475
|
+
const target = sessionRowTarget(container, entry.id, entry.title, legacyTitleMatch);
|
|
2476
|
+
if (target === null) { missed++; continue; }
|
|
2477
|
+
if (existingMarker(target.row, RUN_DOT_ATTR, entry.id) !== null) { placed++; continue; }
|
|
2478
|
+
const runDot = document.createElement("span");
|
|
2479
|
+
runDot.setAttribute(RUN_DOT_ATTR, entry.id);
|
|
2480
|
+
runDot.setAttribute("aria-hidden", "true");
|
|
2481
|
+
runDot.title = entry.title;
|
|
2482
|
+
runDot.style.cssText = "pointer-events:none;flex:none;width:8px;height:8px;border-radius:50%;"
|
|
2483
|
+
+ "background:" + RUNNING_YELLOW + ";margin:0 4px 0 0;";
|
|
2484
|
+
const red = existingMarker(target.row, ROW_DOT_ATTR, entry.id);
|
|
2485
|
+
if (red !== null) red.insertAdjacentElement("afterend", runDot);
|
|
2486
|
+
else target.title.insertAdjacentElement("afterend", runDot);
|
|
2487
|
+
placed++;
|
|
2488
|
+
created++;
|
|
2489
|
+
}
|
|
1710
2490
|
} catch (error) {
|
|
1711
2491
|
missed++;
|
|
1712
2492
|
noteWorkspaceDotFailure(String((error && error.message) || error));
|
|
@@ -1765,6 +2545,10 @@ window.__ModuleLoader__.load({
|
|
|
1765
2545
|
const workspaces = typeof props.useWorkspaces === "function" ? props.useWorkspaces((state) => state) : undefined;
|
|
1766
2546
|
const [config, setConfig] = React.useState(unreadConfig);
|
|
1767
2547
|
React.useEffect(() => subscribeUnreadConfig(setConfig), []);
|
|
2548
|
+
// The running mark's own switch. Same shape, separate feature: the two
|
|
2549
|
+
// never share state, so one switch can never move the other's marks.
|
|
2550
|
+
const [running, setRunning] = React.useState(runningConfig);
|
|
2551
|
+
React.useEffect(() => subscribeRunningConfig(setRunning), []);
|
|
1768
2552
|
// Baseline new sessions; persists only when it actually learns something.
|
|
1769
2553
|
React.useEffect(() => {
|
|
1770
2554
|
const ids = list !== null && typeof list === "object" && Array.isArray(list.ids) ? list.ids : [];
|
|
@@ -1793,6 +2577,36 @@ window.__ModuleLoader__.load({
|
|
|
1793
2577
|
const watching = visible && panelActive !== true;
|
|
1794
2578
|
const currentIds = currentSessionIds(list, { displayed: displayedSessionId() });
|
|
1795
2579
|
const items = collectUnread(list, pending, config, seenState.seen);
|
|
2580
|
+
/**
|
|
2581
|
+
* Map session → its Workspace from the official snapshot: the ID anchors the
|
|
2582
|
+
* folder dot to the keyed row, the title is what the row displays (not always
|
|
2583
|
+
* the cwd basename, and not always equal to the stored title either).
|
|
2584
|
+
*
|
|
2585
|
+
* The same map answers a second question: which workspace row to expand when a
|
|
2586
|
+
* list row is clicked. The pending list only carries the cwd basename, which
|
|
2587
|
+
* cannot be turned back into a workspace id.
|
|
2588
|
+
*/
|
|
2589
|
+
const owners = React.useMemo(() => {
|
|
2590
|
+
const out = {};
|
|
2591
|
+
const workspaceItems = workspaces !== null && typeof workspaces === "object" && Array.isArray(workspaces.items) ? workspaces.items : [];
|
|
2592
|
+
for (const workspace of workspaceItems) {
|
|
2593
|
+
if (workspace === null || typeof workspace !== "object") continue;
|
|
2594
|
+
if (!Array.isArray(workspace.sessionIds)) continue;
|
|
2595
|
+
const id = typeof workspace.workspaceId === "string" ? workspace.workspaceId : "";
|
|
2596
|
+
const title = typeof workspace.title === "string" ? workspace.title : "";
|
|
2597
|
+
for (const sessionId of workspace.sessionIds) out[sessionId] = { id, title };
|
|
2598
|
+
}
|
|
2599
|
+
return out;
|
|
2600
|
+
}, [workspaces]);
|
|
2601
|
+
/**
|
|
2602
|
+
* The pending rows plus the workspace they belong to. A session the official
|
|
2603
|
+
* snapshot places nowhere keeps its row untouched: `revealSessionRow` then has
|
|
2604
|
+
* nothing to expand, which is the honest answer, not a guess.
|
|
2605
|
+
*/
|
|
2606
|
+
const itemsWithOwner = React.useMemo(() => items.map((item) => {
|
|
2607
|
+
const owner = owners[item.id];
|
|
2608
|
+
return owner === undefined || owner.id === "" ? item : { ...item, workspaceId: owner.id };
|
|
2609
|
+
}), [items, owners]);
|
|
1796
2610
|
const count = items.length;
|
|
1797
2611
|
// Being in a session is what marks it read — and the clock that does it is
|
|
1798
2612
|
// armed by ENTERING the session, never by a new ending.
|
|
@@ -1868,25 +2682,45 @@ window.__ModuleLoader__.load({
|
|
|
1868
2682
|
if (timer !== 0) window.clearTimeout(timer);
|
|
1869
2683
|
};
|
|
1870
2684
|
}, [staySignature, delayMs, watching]);
|
|
1871
|
-
React.useEffect(() => { emitRealBadge(count,
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
2685
|
+
React.useEffect(() => { emitRealBadge(count, itemsWithOwner); }, [count, itemsWithOwner]);
|
|
2686
|
+
/**
|
|
2687
|
+
* One session's displayed title, the way the sidebar shows it.
|
|
2688
|
+
*
|
|
2689
|
+
* Kept in step with `collectUnread`'s own `titleOf`: the yellow dot is
|
|
2690
|
+
* matched against the row's rendered text, so it must use the same
|
|
2691
|
+
* `displayTitle` fallback the red dot uses, or a session whose generated
|
|
2692
|
+
* title has not landed yet would get no yellow dot at all.
|
|
2693
|
+
*/
|
|
2694
|
+
const titleOf = (id, entry) => (entry !== null && typeof entry === "object" && typeof entry.displayTitle === "string" && entry.displayTitle !== "" ? entry.displayTitle : id);
|
|
2695
|
+
// The running fact, straight off the same two official props and with no
|
|
2696
|
+
// host round trip at all. `runningSignature` is what the marks effect below
|
|
2697
|
+
// depends on, so a list re-render with the same set of running sessions
|
|
2698
|
+
// does not repaint the sidebar.
|
|
2699
|
+
const runningIds = running.enabled === false ? [] : runningSessionIds(list, status);
|
|
2700
|
+
const byId = list !== null && typeof list === "object" && list.byId !== null && typeof list.byId === "object" ? list.byId : {};
|
|
2701
|
+
// The rows behind the yellow count, for the list it opens (`titleOf` +
|
|
2702
|
+
// the same workspace naming the pending list uses, so the two modes of the
|
|
2703
|
+
// panel cannot disagree about what a session is called or where it lives).
|
|
2704
|
+
const runningRows = runningIds.map((id) => ({ id, title: titleOf(id, byId[id]), where: sessionWhereOf(byId[id]), workspaceId: owners[id] === undefined ? "" : owners[id].id }));
|
|
2705
|
+
// Two keys for two consumers: the sidebar repaints on the SET OF IDS, while
|
|
2706
|
+
// the panel must also refresh when a title or a workspace changes.
|
|
2707
|
+
const runningSignature = runningIds.join("|");
|
|
2708
|
+
const runningRowSignature = runningItemsSignature(runningRows);
|
|
2709
|
+
const runningCount = runningIds.length;
|
|
2710
|
+
React.useEffect(() => { emitRunningState(runningCount, runningRows); }, [runningRowSignature]);
|
|
2711
|
+
// The session rows, for naming the running ones the way the sidebar shows
|
|
2712
|
+
// them (`displayTitle`, the same fallback the red dots use).
|
|
1875
2713
|
React.useEffect(() => {
|
|
1876
|
-
const owners = {};
|
|
1877
|
-
const list = workspaces !== null && typeof workspaces === "object" && Array.isArray(workspaces.items) ? workspaces.items : [];
|
|
1878
|
-
for (const workspace of list) {
|
|
1879
|
-
if (workspace === null || typeof workspace !== "object") continue;
|
|
1880
|
-
if (!Array.isArray(workspace.sessionIds)) continue;
|
|
1881
|
-
const id = typeof workspace.workspaceId === "string" ? workspace.workspaceId : "";
|
|
1882
|
-
const title = typeof workspace.title === "string" ? workspace.title : "";
|
|
1883
|
-
for (const sessionId of workspace.sessionIds) owners[sessionId] = { id, title };
|
|
1884
|
-
}
|
|
1885
2714
|
emitSidebarMarks({
|
|
1886
2715
|
workspaces: dotsFromItems(items, owners),
|
|
1887
|
-
sessions: items.map((item) => ({ id: item.id, title: item.title }))
|
|
2716
|
+
sessions: items.map((item) => ({ id: item.id, title: item.title })),
|
|
2717
|
+
// The same rows, the other fact. Both arrays travel together, so the
|
|
2718
|
+
// paint pass can put the yellow dot in front of the red one
|
|
2719
|
+
// deterministically instead of depending on which feature changed last.
|
|
2720
|
+
runningSessions: runningRows.map((row) => ({ id: row.id, title: row.title })),
|
|
2721
|
+
runningWorkspaces: runningWorkspaceDots(runningIds, owners)
|
|
1888
2722
|
});
|
|
1889
|
-
}, [items,
|
|
2723
|
+
}, [items, owners, runningSignature, running]);
|
|
1890
2724
|
return null;
|
|
1891
2725
|
}
|
|
1892
2726
|
|
|
@@ -1910,16 +2744,23 @@ window.__ModuleLoader__.load({
|
|
|
1910
2744
|
*
|
|
1911
2745
|
* The count is shown for EVERY value including zero: a grey 0 keeps the
|
|
1912
2746
|
* position from jumping, and doubles as the only way back to the panel once
|
|
1913
|
-
* the old sidebar-foot button is gone.
|
|
2747
|
+
* the old sidebar-foot button is gone. The YELLOW count, by contrast, has no
|
|
2748
|
+
* zero state and is a button: with nothing running there is no list to open.
|
|
1914
2749
|
*/
|
|
1915
2750
|
const HEAD_BADGE_ATTR = "data-icon-custom-unreadbadge";
|
|
2751
|
+
/** The running count's own node in the same header row — bottom-right of the label. */
|
|
2752
|
+
const HEAD_RUN_ATTR = "data-icon-custom-runbadge";
|
|
1916
2753
|
const HEAD_BADGE_STYLE_ID = "dsh-icon-custom-unreadbadge-css";
|
|
1917
2754
|
const HEAD_BADGE_WIRED = "data-icon-custom-unreadbadge-wired";
|
|
2755
|
+
/** The yellow pill's own wiring flag — it is a button now, exactly like the red one. */
|
|
2756
|
+
const HEAD_RUN_WIRED = "data-icon-custom-runbadge-wired";
|
|
1918
2757
|
const HEAD_BADGE_MAX_FAILURES = 40;
|
|
1919
2758
|
/** How many ancestors to walk up looking for the header row. */
|
|
1920
2759
|
const HEAD_BADGE_MAX_STEPS = 6;
|
|
1921
2760
|
/** Raise above the label's top edge; the header only leaves 8px of headroom. */
|
|
1922
2761
|
const HEAD_BADGE_RAISE = 7;
|
|
2762
|
+
/** Drop below the label's bottom edge; mirrors the raise, and still fits the 36px header. */
|
|
2763
|
+
const HEAD_RUN_DROP = 8;
|
|
1923
2764
|
/** Overhang past the label's right edge. */
|
|
1924
2765
|
const HEAD_BADGE_OVERHANG = 9;
|
|
1925
2766
|
let headerBadgeHost = null;
|
|
@@ -1947,7 +2788,14 @@ window.__ModuleLoader__.load({
|
|
|
1947
2788
|
`[${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
2789
|
`[${HEAD_BADGE_ATTR}]::after{content:"";position:absolute;inset:0 -5px -5px -5px;border-radius:999px}`,
|
|
1949
2790
|
`[${HEAD_BADGE_ATTR}]:hover{filter:brightness(1.12)}`,
|
|
1950
|
-
`[${HEAD_BADGE_ATTR}][data-zero="1"]{background:var(--dsw-alias-state-idle-primary,#b6bcc4)}
|
|
2791
|
+
`[${HEAD_BADGE_ATTR}][data-zero="1"]{background:var(--dsw-alias-state-idle-primary,#b6bcc4)}`,
|
|
2792
|
+
// The running count: same pill, other corner, and now a button like the red
|
|
2793
|
+
// one — it opens the list of the very sessions it counts. It has no zero
|
|
2794
|
+
// state (a yellow 0 would be pure noise), so with nothing running there is
|
|
2795
|
+
// nothing to open either: the pill is simply not there.
|
|
2796
|
+
`[${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;cursor:pointer;z-index:3;box-shadow:0 0 0 1.5px var(--dsw-specific-sidebar-fill,#fff)}`,
|
|
2797
|
+
`[${HEAD_RUN_ATTR}]::after{content:"";position:absolute;inset:0 -5px -5px -5px;border-radius:999px}`,
|
|
2798
|
+
`[${HEAD_RUN_ATTR}]:hover{filter:brightness(1.12)}`
|
|
1951
2799
|
].join("");
|
|
1952
2800
|
|
|
1953
2801
|
/** Install the badge rules once per page. */
|
|
@@ -1974,11 +2822,13 @@ window.__ModuleLoader__.load({
|
|
|
1974
2822
|
* @returns `{ row, label }`, or null when this build's DOM is not recognised.
|
|
1975
2823
|
*/
|
|
1976
2824
|
function sectionHeaderParts(container) {
|
|
1977
|
-
// Our own
|
|
1978
|
-
//
|
|
2825
|
+
// Our own nodes are never a stand-in for shipped controls or for the label:
|
|
2826
|
+
// the red badge is a button and the yellow one carries text, so either could
|
|
2827
|
+
// be mistaken for the search control or the label if left in the running.
|
|
1979
2828
|
let firstButton = null;
|
|
1980
2829
|
for (const candidate of container.querySelectorAll("button")) {
|
|
1981
2830
|
if (candidate.getAttribute(HEAD_BADGE_ATTR) !== null) continue;
|
|
2831
|
+
if (candidate.getAttribute(HEAD_RUN_ATTR) !== null) continue;
|
|
1982
2832
|
firstButton = candidate;
|
|
1983
2833
|
break;
|
|
1984
2834
|
}
|
|
@@ -1988,6 +2838,8 @@ window.__ModuleLoader__.load({
|
|
|
1988
2838
|
for (const child of row.children) {
|
|
1989
2839
|
if (child === firstButton || child.contains(firstButton)) continue;
|
|
1990
2840
|
if (child.tagName === "BUTTON") continue;
|
|
2841
|
+
if (child.getAttribute(HEAD_BADGE_ATTR) !== null) continue;
|
|
2842
|
+
if (child.getAttribute(HEAD_RUN_ATTR) !== null) continue;
|
|
1991
2843
|
if (child.querySelector("button") !== null) continue;
|
|
1992
2844
|
if ((child.textContent ?? "").trim() !== "") return { row, label: child };
|
|
1993
2845
|
}
|
|
@@ -2052,7 +2904,7 @@ window.__ModuleLoader__.load({
|
|
|
2052
2904
|
event.stopPropagation();
|
|
2053
2905
|
const rect = node.getBoundingClientRect();
|
|
2054
2906
|
// `bottom` is what the panel drops below; see unreadPanelPlacement.
|
|
2055
|
-
|
|
2907
|
+
togglePanel("unread", { left: rect.left, top: rect.top, bottom: rect.bottom });
|
|
2056
2908
|
});
|
|
2057
2909
|
}
|
|
2058
2910
|
const count = effectiveCount();
|
|
@@ -2067,6 +2919,42 @@ window.__ModuleLoader__.load({
|
|
|
2067
2919
|
}
|
|
2068
2920
|
}
|
|
2069
2921
|
if (node.textContent !== text) node.textContent = text;
|
|
2922
|
+
// The running count, in the same row's other corner: top-right is the unread
|
|
2923
|
+
// number (where it has always been), bottom-right is this one. A button, not
|
|
2924
|
+
// a label — it opens the list of the sessions it is counting.
|
|
2925
|
+
let runNode = row.querySelector(`[${HEAD_RUN_ATTR}]`);
|
|
2926
|
+
if (runNode === null) {
|
|
2927
|
+
runNode = document.createElement("button");
|
|
2928
|
+
runNode.type = "button";
|
|
2929
|
+
runNode.setAttribute(HEAD_RUN_ATTR, "1");
|
|
2930
|
+
// Same as the red one: appended AFTER the shipped controls, so it can
|
|
2931
|
+
// never be mistaken for the header's first button.
|
|
2932
|
+
row.appendChild(runNode);
|
|
2933
|
+
}
|
|
2934
|
+
if (runNode.getAttribute(HEAD_RUN_WIRED) !== "1") {
|
|
2935
|
+
runNode.setAttribute(HEAD_RUN_WIRED, "1");
|
|
2936
|
+
runNode.addEventListener("click", (event) => {
|
|
2937
|
+
// The row belongs to the sidebar; keep the click to ourselves.
|
|
2938
|
+
event.preventDefault();
|
|
2939
|
+
event.stopPropagation();
|
|
2940
|
+
const rect = runNode.getBoundingClientRect();
|
|
2941
|
+
// The same anchor shape the red badge hands over; see
|
|
2942
|
+
// `unreadPanelPlacement` for what `bottom` is used for.
|
|
2943
|
+
togglePanel("running", { left: rect.left, top: rect.top, bottom: rect.bottom });
|
|
2944
|
+
});
|
|
2945
|
+
}
|
|
2946
|
+
const running = normalizeCount(runningTotal);
|
|
2947
|
+
const runText = badgeLabel(running);
|
|
2948
|
+
if (typeof badgeT === "function") {
|
|
2949
|
+
// Says what the number is AND that it opens: it is a control now, and a
|
|
2950
|
+
// bare "2 running" would leave a screen reader with no idea it is one.
|
|
2951
|
+
const spoken = badgeT("runningPanelTrigger").replace("{n}", String(running));
|
|
2952
|
+
if (runNode.getAttribute("aria-label") !== spoken) {
|
|
2953
|
+
runNode.setAttribute("aria-label", spoken);
|
|
2954
|
+
runNode.setAttribute("title", spoken);
|
|
2955
|
+
}
|
|
2956
|
+
}
|
|
2957
|
+
if (runNode.textContent !== runText) runNode.textContent = runText;
|
|
2070
2958
|
// The label collapses to nothing while the search box is open; the badge
|
|
2071
2959
|
// has no meaning then, and the label's own visibility cannot hide it
|
|
2072
2960
|
// because it is a sibling.
|
|
@@ -2074,6 +2962,7 @@ window.__ModuleLoader__.load({
|
|
|
2074
2962
|
const labelRect = label.getBoundingClientRect();
|
|
2075
2963
|
if (labelRect.width < 8) {
|
|
2076
2964
|
if (node.style.display !== "none") node.style.display = "none";
|
|
2965
|
+
if (runNode.style.display !== "none") runNode.style.display = "none";
|
|
2077
2966
|
} else {
|
|
2078
2967
|
const width = typeof node.offsetWidth === "number" && node.offsetWidth > 0 ? node.offsetWidth : 15;
|
|
2079
2968
|
const left = Math.round(labelRect.right - rowRect.left - width + HEAD_BADGE_OVERHANG);
|
|
@@ -2082,6 +2971,17 @@ window.__ModuleLoader__.load({
|
|
|
2082
2971
|
if (node.style.left !== `${left}px`) node.style.left = `${left}px`;
|
|
2083
2972
|
if (node.style.top !== `${top}px`) node.style.top = `${top}px`;
|
|
2084
2973
|
if (node.style.display !== "flex") node.style.display = "flex";
|
|
2974
|
+
// No zero state for the running count: it simply is not there.
|
|
2975
|
+
if (running <= 0) {
|
|
2976
|
+
if (runNode.style.display !== "none") runNode.style.display = "none";
|
|
2977
|
+
} else {
|
|
2978
|
+
const runWidth = typeof runNode.offsetWidth === "number" && runNode.offsetWidth > 0 ? runNode.offsetWidth : 15;
|
|
2979
|
+
const runLeft = Math.round(labelRect.right - rowRect.left - runWidth + HEAD_BADGE_OVERHANG);
|
|
2980
|
+
const runTop = Math.round(labelRect.bottom - rowRect.top - HEAD_RUN_DROP);
|
|
2981
|
+
if (runNode.style.left !== `${runLeft}px`) runNode.style.left = `${runLeft}px`;
|
|
2982
|
+
if (runNode.style.top !== `${runTop}px`) runNode.style.top = `${runTop}px`;
|
|
2983
|
+
if (runNode.style.display !== "flex") runNode.style.display = "flex";
|
|
2984
|
+
}
|
|
2085
2985
|
}
|
|
2086
2986
|
// The label animates its own width; follow it instead of waiting for the
|
|
2087
2987
|
// next mutation, which a CSS transition never produces.
|
|
@@ -2095,7 +2995,7 @@ window.__ModuleLoader__.load({
|
|
|
2095
2995
|
|
|
2096
2996
|
/** Take the badge back out and restore the row's own positioning. */
|
|
2097
2997
|
function clearHeaderBadge() {
|
|
2098
|
-
try { document.querySelectorAll(`[${HEAD_BADGE_ATTR}]`).forEach((node) => node.remove()); } catch {}
|
|
2998
|
+
try { document.querySelectorAll(`[${HEAD_BADGE_ATTR}],[${HEAD_RUN_ATTR}]`).forEach((node) => node.remove()); } catch {}
|
|
2099
2999
|
if (headerBadgeObserver !== null) {
|
|
2100
3000
|
try { headerBadgeObserver.disconnect(); } catch {}
|
|
2101
3001
|
headerBadgeObserver = null;
|
|
@@ -2154,30 +3054,33 @@ window.__ModuleLoader__.load({
|
|
|
2154
3054
|
}
|
|
2155
3055
|
|
|
2156
3056
|
/**
|
|
2157
|
-
* The list behind
|
|
2158
|
-
* the sidebar's clipping and scroll container.
|
|
2159
|
-
*
|
|
3057
|
+
* The list behind those numbers, in the frame-wide overlay layer so it escapes
|
|
3058
|
+
* the sidebar's clipping and scroll container. ONE panel with two modes: the
|
|
3059
|
+
* red count opens "who needs you", the yellow count opens "who is running".
|
|
3060
|
+
* Clicking a row opens that session — which is also what marks an unread one
|
|
3061
|
+
* read. The running mode has no "mark all read": running is a live fact, not a
|
|
3062
|
+
* queue, so there is nothing there to acknowledge.
|
|
2160
3063
|
*/
|
|
2161
|
-
function
|
|
3064
|
+
function HeaderPopup(props) {
|
|
2162
3065
|
const [snapshot, setSnapshot] = React.useState(badgeSnapshot);
|
|
2163
|
-
const [
|
|
3066
|
+
const [mode, setMode] = React.useState(panelMode);
|
|
2164
3067
|
// Ages are computed against the HOST's clock (the endings are host
|
|
2165
3068
|
// timestamps); re-tick while open so "刚刚" does not stay stale.
|
|
2166
3069
|
const [, tick] = React.useState(0);
|
|
2167
3070
|
React.useEffect(() => subscribeBadge(setSnapshot), []);
|
|
2168
|
-
React.useEffect(() =>
|
|
3071
|
+
React.useEffect(() => subscribePanel(() => setMode(panelMode)), []);
|
|
2169
3072
|
React.useEffect(() => {
|
|
2170
|
-
if (
|
|
3073
|
+
if (mode === null) return undefined;
|
|
2171
3074
|
const interval = window.setInterval(() => tick((n) => n + 1), 30000);
|
|
2172
3075
|
return () => window.clearInterval(interval);
|
|
2173
|
-
}, [
|
|
3076
|
+
}, [mode]);
|
|
2174
3077
|
React.useEffect(() => {
|
|
2175
|
-
if (
|
|
2176
|
-
const onKey = (event) => { if (event.key === "Escape")
|
|
3078
|
+
if (mode === null) return undefined;
|
|
3079
|
+
const onKey = (event) => { if (event.key === "Escape") emitPanel(null); };
|
|
2177
3080
|
const onDown = (event) => {
|
|
2178
3081
|
const node = event.target;
|
|
2179
|
-
if (node !== null && typeof node.closest === "function" && node.closest('[data-icon-custom-
|
|
2180
|
-
|
|
3082
|
+
if (node !== null && typeof node.closest === "function" && node.closest('[data-icon-custom-panel="1"]') !== null) return;
|
|
3083
|
+
emitPanel(null);
|
|
2181
3084
|
};
|
|
2182
3085
|
window.addEventListener("keydown", onKey);
|
|
2183
3086
|
document.addEventListener("mousedown", onDown, true);
|
|
@@ -2185,10 +3088,12 @@ window.__ModuleLoader__.load({
|
|
|
2185
3088
|
window.removeEventListener("keydown", onKey);
|
|
2186
3089
|
document.removeEventListener("mousedown", onDown, true);
|
|
2187
3090
|
};
|
|
2188
|
-
}, [
|
|
2189
|
-
if (
|
|
3091
|
+
}, [mode]);
|
|
3092
|
+
if (mode === null) return null;
|
|
2190
3093
|
const t = props.t;
|
|
2191
|
-
|
|
3094
|
+
/** Which of the two lists this render is: same shell, different rows. */
|
|
3095
|
+
const running = mode === "running";
|
|
3096
|
+
const anchor = panelAnchor;
|
|
2192
3097
|
// Measured, not assumed: the panel has to clear whatever width the sidebar
|
|
2193
3098
|
// currently has (it is resizable and collapsible).
|
|
2194
3099
|
let sidebarRight = null;
|
|
@@ -2203,8 +3108,12 @@ window.__ModuleLoader__.load({
|
|
|
2203
3108
|
border: "1px solid var(--dsw-alias-border-l2)", background: "var(--dsw-alias-bg-layer-1)",
|
|
2204
3109
|
boxShadow: "0 10px 30px rgba(0,0,0,.18)", color: "var(--dsw-alias-label-primary)", fontSize: "12px"
|
|
2205
3110
|
};
|
|
2206
|
-
|
|
2207
|
-
|
|
3111
|
+
// The rows of whichever list is open. Both carry `{ id, title, where }`;
|
|
3112
|
+
// only the unread ones carry an ending to acknowledge.
|
|
3113
|
+
const items = running
|
|
3114
|
+
? (Array.isArray(snapshot.runningItems) ? snapshot.runningItems : [])
|
|
3115
|
+
: (Array.isArray(snapshot.items) ? snapshot.items : []);
|
|
3116
|
+
const ackTargets = running ? [] : unreadAckTargets(items);
|
|
2208
3117
|
/**
|
|
2209
3118
|
* Acknowledge every turn-end row at once.
|
|
2210
3119
|
*
|
|
@@ -2218,12 +3127,12 @@ window.__ModuleLoader__.load({
|
|
|
2218
3127
|
if (!changed) return;
|
|
2219
3128
|
saveSeenState();
|
|
2220
3129
|
emitUnreadPoke();
|
|
2221
|
-
if (ackTargets.length === items.length)
|
|
3130
|
+
if (ackTargets.length === items.length) emitPanel(null);
|
|
2222
3131
|
};
|
|
2223
|
-
return React.createElement("div", { "data-icon-custom-
|
|
2224
|
-
React.createElement("div", { style: { fontWeight: 600, padding: "2px 6px 8px" } }, t("unreadPanelTitle")),
|
|
3132
|
+
return React.createElement("div", { "data-icon-custom-panel": "1", style },
|
|
3133
|
+
React.createElement("div", { style: { fontWeight: 600, padding: "2px 6px 8px" } }, t(running ? "runningPanelTitle" : "unreadPanelTitle")),
|
|
2225
3134
|
items.length === 0
|
|
2226
|
-
? React.createElement("div", { style: { padding: "2px 6px 8px", opacity: 0.7 } }, t("unreadPanelEmpty"))
|
|
3135
|
+
? React.createElement("div", { style: { padding: "2px 6px 8px", opacity: 0.7 } }, t(running ? "runningPanelEmpty" : "unreadPanelEmpty"))
|
|
2227
3136
|
: React.createElement("div", { style: { display: "flex", flexDirection: "column", maxHeight: place.listMaxHeight + "px", overflowY: "auto" } },
|
|
2228
3137
|
groupUnreadByWorkspace(items).map((group) => React.createElement("div", { key: group.where === "" ? "\u0000none" : group.where },
|
|
2229
3138
|
React.createElement("div", { style: { padding: "7px 8px 3px", fontSize: "11px", fontWeight: 600, opacity: 0.55, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } },
|
|
@@ -2231,11 +3140,11 @@ window.__ModuleLoader__.load({
|
|
|
2231
3140
|
),
|
|
2232
3141
|
group.items.map((item) => React.createElement("button", {
|
|
2233
3142
|
key: item.id, type: "button",
|
|
2234
|
-
onClick: () => {
|
|
3143
|
+
onClick: () => { emitPanel(null); props.onOpen(item); },
|
|
2235
3144
|
style: { display: "block", width: "100%", textAlign: "left", padding: "6px 8px", border: "0", borderRadius: "8px", background: "transparent", color: "inherit", font: "inherit", cursor: "pointer" }
|
|
2236
3145
|
},
|
|
2237
3146
|
React.createElement("span", { style: { display: "block", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, item.title),
|
|
2238
|
-
React.createElement("span", { style: { display: "block", opacity: 0.6, fontSize: "11px" } }, unreadRowSubtitle(item, hostNow(), t))
|
|
3147
|
+
React.createElement("span", { style: { display: "block", opacity: 0.6, fontSize: "11px" } }, running ? runningRowSubtitle(item, t) : unreadRowSubtitle(item, hostNow(), t))
|
|
2239
3148
|
))
|
|
2240
3149
|
))
|
|
2241
3150
|
),
|
|
@@ -2249,6 +3158,28 @@ window.__ModuleLoader__.load({
|
|
|
2249
3158
|
);
|
|
2250
3159
|
}
|
|
2251
3160
|
|
|
3161
|
+
/**
|
|
3162
|
+
* The two brand-mark pill sizes.
|
|
3163
|
+
*
|
|
3164
|
+
* A 24px mark cannot hold two full-size numbers, and drawing one big and one
|
|
3165
|
+
* small reads as a rendering bug rather than a design. So when BOTH are shown
|
|
3166
|
+
* they are drawn at one common edge — the largest that still fits them stacked
|
|
3167
|
+
* — while a pill shown alone keeps the normal size. Pure, so the rule is
|
|
3168
|
+
* testable without React.
|
|
3169
|
+
* @param size - the mark's edge in px.
|
|
3170
|
+
* @param scale - the badge-size multiplier from the setting.
|
|
3171
|
+
* @param hasUnread - whether the unread pill is shown.
|
|
3172
|
+
* @param hasRunning - whether the running pill is shown.
|
|
3173
|
+
* @returns `{ unread, running }` pill edges in px.
|
|
3174
|
+
*/
|
|
3175
|
+
function brandPillEdges(size, scale, hasUnread, hasRunning) {
|
|
3176
|
+
const edge = Math.max(12, Math.round(size * 0.5 * scale));
|
|
3177
|
+
if (hasUnread !== true || hasRunning !== true) return { unread: edge, running: edge };
|
|
3178
|
+
// Half the mark each, minus the seam between them.
|
|
3179
|
+
const shared = Math.max(9, Math.floor((size - 2) / 2));
|
|
3180
|
+
return { unread: shared, running: shared };
|
|
3181
|
+
}
|
|
3182
|
+
|
|
2252
3183
|
/**
|
|
2253
3184
|
* Sidebar brand-mark occupant (registered on the official
|
|
2254
3185
|
* `sidebar.brand.mark` single seat). Shows the custom icon when the
|
|
@@ -2280,15 +3211,18 @@ window.__ModuleLoader__.load({
|
|
|
2280
3211
|
alt: "",
|
|
2281
3212
|
style
|
|
2282
3213
|
});
|
|
2283
|
-
// No
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
3214
|
+
// No mark at all: keep the exact original element (no wrapper, no layout risk).
|
|
3215
|
+
const runningCount = normalizeCount(badge.running);
|
|
3216
|
+
if (badge.count <= 0 && runningCount <= 0) return img;
|
|
3217
|
+
// Two independent facts, two corners: the unread count owns the top-right
|
|
3218
|
+
// (where it has always been), the running count takes the bottom-right.
|
|
3219
|
+
const edges = brandPillEdges(size, badgeScale(badge.size), badge.count > 0, runningCount > 0);
|
|
3220
|
+
const edge = edges.unread;
|
|
3221
|
+
const children = [img];
|
|
3222
|
+
if (badge.count > 0) {
|
|
3223
|
+
const label = `${badgeLabel(badge.count)} pending (test)`;
|
|
3224
|
+
children.push(React.createElement("span", {
|
|
3225
|
+
key: "unread",
|
|
2292
3226
|
title: label,
|
|
2293
3227
|
"aria-label": label,
|
|
2294
3228
|
style: {
|
|
@@ -2312,8 +3246,43 @@ window.__ModuleLoader__.load({
|
|
|
2312
3246
|
fontVariantNumeric: "tabular-nums",
|
|
2313
3247
|
pointerEvents: "none"
|
|
2314
3248
|
}
|
|
2315
|
-
}, badgeLabel(badge.count))
|
|
2316
|
-
|
|
3249
|
+
}, badgeLabel(badge.count)));
|
|
3250
|
+
}
|
|
3251
|
+
if (runningCount > 0) {
|
|
3252
|
+
const label = `${badgeLabel(runningCount)} running`;
|
|
3253
|
+
// Same edge as the unread pill whenever both are shown (see
|
|
3254
|
+
// brandPillEdges): a big red beside a small yellow reads as broken.
|
|
3255
|
+
const runEdge = edges.running;
|
|
3256
|
+
children.push(React.createElement("span", {
|
|
3257
|
+
key: "running",
|
|
3258
|
+
title: label,
|
|
3259
|
+
"aria-label": label,
|
|
3260
|
+
style: {
|
|
3261
|
+
position: "absolute",
|
|
3262
|
+
bottom: 0,
|
|
3263
|
+
right: 0,
|
|
3264
|
+
minWidth: runEdge,
|
|
3265
|
+
height: runEdge,
|
|
3266
|
+
padding: "0 2px",
|
|
3267
|
+
boxSizing: "border-box",
|
|
3268
|
+
borderRadius: 999,
|
|
3269
|
+
background: RUNNING_YELLOW,
|
|
3270
|
+
color: "#1f1f1f",
|
|
3271
|
+
border: "1.5px solid var(--dsw-alias-bg-layer-1, #fff)",
|
|
3272
|
+
fontSize: Math.max(7, Math.round(runEdge * 0.62)),
|
|
3273
|
+
fontWeight: 700,
|
|
3274
|
+
lineHeight: 1,
|
|
3275
|
+
display: "flex",
|
|
3276
|
+
alignItems: "center",
|
|
3277
|
+
justifyContent: "center",
|
|
3278
|
+
fontVariantNumeric: "tabular-nums",
|
|
3279
|
+
pointerEvents: "none"
|
|
3280
|
+
}
|
|
3281
|
+
}, badgeLabel(runningCount)));
|
|
3282
|
+
}
|
|
3283
|
+
return React.createElement("span", {
|
|
3284
|
+
style: { position: "relative", display: "inline-flex", width: size, height: size, flex: "none" }
|
|
3285
|
+
}, children);
|
|
2317
3286
|
}
|
|
2318
3287
|
|
|
2319
3288
|
function FaviconSection(props) {
|
|
@@ -2330,9 +3299,35 @@ window.__ModuleLoader__.load({
|
|
|
2330
3299
|
const [badgeSourceValue, setBadgeSourceValue] = React.useState(badgeSource);
|
|
2331
3300
|
const [unread, setUnread] = React.useState(unreadConfig);
|
|
2332
3301
|
React.useEffect(() => subscribeUnreadConfig(setUnread), []);
|
|
3302
|
+
// Its own switch and its own endpoint pair: the running marks must be
|
|
3303
|
+
// switchable without touching the unread rule (see lib/running.js).
|
|
3304
|
+
const [running, setRunning] = React.useState(runningConfig);
|
|
3305
|
+
React.useEffect(() => subscribeRunningConfig(setRunning), []);
|
|
2333
3306
|
// Sampled when the section mounts, which is the moment the user is
|
|
2334
3307
|
// looking at it. It reports local facts only (see badgeCapabilityProbe).
|
|
2335
3308
|
const [appBadgeProbe] = React.useState(() => appBadgeEnv());
|
|
3309
|
+
// Live, unlike the capability probe above: the notifications permission
|
|
3310
|
+
// can move outside this page (System Settings, another tab's prompt), and
|
|
3311
|
+
// on WebKit the badge follows it.
|
|
3312
|
+
const [notificationProbe, setNotificationProbe] = React.useState(() => notificationPermissionEnv());
|
|
3313
|
+
const [permissionBusy, setPermissionBusy] = React.useState(false);
|
|
3314
|
+
// A permission granted while the page is open is the WebKit precondition
|
|
3315
|
+
// the badge was missing: every earlier call was dropped while there was
|
|
3316
|
+
// no permission to draw it, so re-assert the number the page already shows.
|
|
3317
|
+
const onPermissionChanged = React.useCallback((next) => {
|
|
3318
|
+
setNotificationProbe(next);
|
|
3319
|
+
if (next.state === "granted") reconcileAppBadge(true);
|
|
3320
|
+
}, []);
|
|
3321
|
+
React.useEffect(() => subscribeNotificationPermission(onPermissionChanged), [onPermissionChanged]);
|
|
3322
|
+
// Called straight from the click: the prompt needs a user gesture, so it
|
|
3323
|
+
// must never be moved into an effect.
|
|
3324
|
+
const onRequestPermission = React.useCallback(() => {
|
|
3325
|
+
setPermissionBusy(true);
|
|
3326
|
+
requestNotificationPermission().then((next) => {
|
|
3327
|
+
setPermissionBusy(false);
|
|
3328
|
+
onPermissionChanged(next);
|
|
3329
|
+
});
|
|
3330
|
+
}, [onPermissionChanged]);
|
|
2336
3331
|
// Draft text for the delay box: typing must not save on every keystroke,
|
|
2337
3332
|
// and the box has to show the CLAMPED value once the save lands (type
|
|
2338
3333
|
// 99999 and it settles on the 600 cap instead of lying about it).
|
|
@@ -2402,8 +3397,11 @@ window.__ModuleLoader__.load({
|
|
|
2402
3397
|
const saveUnreadRule = React.useCallback((patch) => {
|
|
2403
3398
|
const next = normalizeUnreadConfig({ ...unreadConfig, ...patch });
|
|
2404
3399
|
emitUnreadConfig(next);
|
|
3400
|
+
// Every field is sent explicitly, and the host replaces its copy with what
|
|
3401
|
+
// arrives — so a field missing HERE is a field that silently reverts to its
|
|
3402
|
+
// default on the next page load, however well the local toggle worked.
|
|
2405
3403
|
rpc.call("/api", "iconCustom/setUnreadRule", {
|
|
2406
|
-
args: { request: { reasons: next.reasons, pending: next.pending, workspaceDot: next.workspaceDot, appBadge: next.appBadge, clearDelaySec: next.clearDelaySec } }
|
|
3404
|
+
args: { request: { reasons: next.reasons, pending: next.pending, workspaceDot: next.workspaceDot, appBadge: next.appBadge, clearDelaySec: next.clearDelaySec, revealOnOpen: next.revealOnOpen } }
|
|
2407
3405
|
}).catch(() => {});
|
|
2408
3406
|
}, [rpc]);
|
|
2409
3407
|
// Toggle one unread reason. Applies locally at once; the host keeps the
|
|
@@ -2418,11 +3416,29 @@ window.__ModuleLoader__.load({
|
|
|
2418
3416
|
const onWorkspaceDotToggle = React.useCallback((checked) => {
|
|
2419
3417
|
saveUnreadRule({ workspaceDot: checked });
|
|
2420
3418
|
}, [saveUnreadRule]);
|
|
3419
|
+
// The second fragile piece's own fuse (see the reveal region): locating a
|
|
3420
|
+
// clicked session drives the shipped sidebar rows.
|
|
3421
|
+
const onRevealToggle = React.useCallback((checked) => {
|
|
3422
|
+
saveUnreadRule({ revealOnOpen: checked });
|
|
3423
|
+
}, [saveUnreadRule]);
|
|
2421
3424
|
// The system-icon projection of the same number. Saved through the same
|
|
2422
3425
|
// one save path, so this field can never be dropped by a caller.
|
|
2423
3426
|
const onAppBadgeToggle = React.useCallback((checked) => {
|
|
2424
3427
|
saveUnreadRule({ appBadge: checked });
|
|
2425
3428
|
}, [saveUnreadRule]);
|
|
3429
|
+
// Same "one save path, merged onto the current config" discipline as the
|
|
3430
|
+
// unread rule, on its own endpoint: enabling or disabling the yellow marks
|
|
3431
|
+
// applies locally at once and is persisted for the next page load.
|
|
3432
|
+
const saveRunningRule = React.useCallback((patch) => {
|
|
3433
|
+
const next = normalizeRunningConfig({ ...runningConfig, ...patch });
|
|
3434
|
+
emitRunningConfig(next);
|
|
3435
|
+
rpc.call("/api", "iconCustom/setRunningRule", {
|
|
3436
|
+
args: { request: { enabled: next.enabled } }
|
|
3437
|
+
}).catch(() => {});
|
|
3438
|
+
}, [rpc]);
|
|
3439
|
+
const onRunningMarkToggle = React.useCallback((checked) => {
|
|
3440
|
+
saveRunningRule({ enabled: checked });
|
|
3441
|
+
}, [saveRunningRule]);
|
|
2426
3442
|
// Seconds you must stay in a session before it counts as read; 0 = as soon
|
|
2427
3443
|
// as you enter. Clamped by the shared normalizer, so a stray keystroke can
|
|
2428
3444
|
// never persist an absurd delay.
|
|
@@ -2657,6 +3673,7 @@ window.__ModuleLoader__.load({
|
|
|
2657
3673
|
style: { ...(badgeSizeValue === key ? style.btnPrimary : style.btn), padding: "4px 10px" }
|
|
2658
3674
|
}, t(key === "sm" ? "badgeSizeSmall" : key === "md" ? "badgeSizeMedium" : "badgeSizeLarge")))
|
|
2659
3675
|
),
|
|
3676
|
+
React.createElement("div", { style: style.hint }, t("badgeSizeHint")),
|
|
2660
3677
|
React.createElement("div", { style: { marginTop: "12px" } },
|
|
2661
3678
|
React.createElement("div", { style: style.desc }, t("unreadReasonsLabel")),
|
|
2662
3679
|
React.createElement("div", { style: { display: "flex", flexWrap: "wrap", alignItems: "center" } },
|
|
@@ -2691,6 +3708,17 @@ window.__ModuleLoader__.load({
|
|
|
2691
3708
|
),
|
|
2692
3709
|
React.createElement("div", { style: style.hint }, t("workspaceDotHint"))
|
|
2693
3710
|
),
|
|
3711
|
+
React.createElement("div", { style: { marginTop: "10px" } },
|
|
3712
|
+
React.createElement("label", { style: { display: "inline-flex", alignItems: "center", gap: "6px", fontSize: 12, color: "var(--dsw-alias-label-primary)", cursor: "pointer" } },
|
|
3713
|
+
React.createElement("input", {
|
|
3714
|
+
type: "checkbox",
|
|
3715
|
+
checked: unread.revealOnOpen !== false,
|
|
3716
|
+
onChange: (event) => onRevealToggle(event.target.checked === true)
|
|
3717
|
+
}),
|
|
3718
|
+
React.createElement("span", null, t("revealLabel"))
|
|
3719
|
+
),
|
|
3720
|
+
React.createElement("div", { style: style.hint }, t("revealHint"))
|
|
3721
|
+
),
|
|
2694
3722
|
React.createElement("div", { style: { marginTop: "10px" } },
|
|
2695
3723
|
React.createElement("label", { style: { display: "inline-flex", alignItems: "center", gap: "6px", fontSize: 12, color: "var(--dsw-alias-label-primary)", cursor: "pointer" } },
|
|
2696
3724
|
React.createElement("input", {
|
|
@@ -2703,10 +3731,40 @@ window.__ModuleLoader__.load({
|
|
|
2703
3731
|
React.createElement("div", { style: style.hint }, t("appBadgeHint")),
|
|
2704
3732
|
// What THIS device is — never a claim about the platform.
|
|
2705
3733
|
React.createElement("div", { style: style.hint }, t(appBadgeCapabilityKey(appBadgeProbe))),
|
|
3734
|
+
// The WebKit precondition as a live local fact, plus the one
|
|
3735
|
+
// gesture that can still decide it (button only while undecided).
|
|
3736
|
+
React.createElement("div", { style: { display: "flex", alignItems: "center", flexWrap: "wrap", gap: "8px", marginTop: "8px" } },
|
|
3737
|
+
React.createElement("span", { style: style.desc }, `${t("notificationPermissionLabel")}${t(notificationPermissionKey(notificationProbe))}`),
|
|
3738
|
+
notificationPermissionRequestable(notificationProbe)
|
|
3739
|
+
? React.createElement("button", {
|
|
3740
|
+
type: "button",
|
|
3741
|
+
style: { ...style.btn, ...(permissionBusy ? style.btnDisabled : null) },
|
|
3742
|
+
disabled: permissionBusy,
|
|
3743
|
+
onClick: onRequestPermission
|
|
3744
|
+
}, t("notificationPermissionButton"))
|
|
3745
|
+
: null
|
|
3746
|
+
),
|
|
3747
|
+
React.createElement("div", { style: style.hint }, t("notificationPermissionHint")),
|
|
2706
3748
|
React.createElement("div", { style: style.hint }, t("appBadgeFootnote"))
|
|
2707
3749
|
),
|
|
2708
3750
|
React.createElement("div", { style: style.hint }, t("badgeTestHint"))
|
|
2709
3751
|
),
|
|
3752
|
+
// A second, independent feature in its own block: same settings page,
|
|
3753
|
+
// same save discipline, different fact.
|
|
3754
|
+
React.createElement("div", { style: { borderTop: "1px solid var(--dsw-alias-border-l2)", marginTop: "12px", paddingTop: "12px" } },
|
|
3755
|
+
React.createElement("div", { style: style.label }, t("runningMarkSection")),
|
|
3756
|
+
React.createElement("div", { style: { marginTop: "8px" } },
|
|
3757
|
+
React.createElement("label", { style: { display: "inline-flex", alignItems: "center", gap: "6px", fontSize: 12, color: "var(--dsw-alias-label-primary)", cursor: "pointer" } },
|
|
3758
|
+
React.createElement("input", {
|
|
3759
|
+
type: "checkbox",
|
|
3760
|
+
checked: running.enabled !== false,
|
|
3761
|
+
onChange: (event) => onRunningMarkToggle(event.target.checked === true)
|
|
3762
|
+
}),
|
|
3763
|
+
React.createElement("span", null, t("runningMarkLabel"))
|
|
3764
|
+
),
|
|
3765
|
+
React.createElement("div", { style: style.hint }, t("runningMarkHint"))
|
|
3766
|
+
)
|
|
3767
|
+
),
|
|
2710
3768
|
notice ? React.createElement("div", { style: style.notice }, notice) : null,
|
|
2711
3769
|
error ? React.createElement("div", { style: style.error }, error) : null
|
|
2712
3770
|
);
|
|
@@ -2748,6 +3806,14 @@ window.__ModuleLoader__.load({
|
|
|
2748
3806
|
scheduleHostSync();
|
|
2749
3807
|
})
|
|
2750
3808
|
.catch(() => {});
|
|
3809
|
+
// The running-mark switch rides its own endpoint, for the same reason it has
|
|
3810
|
+
// its own file: a problem in either feature must not touch the other.
|
|
3811
|
+
rpc.call("/api", "iconCustom/getRunningRule", { args: {} })
|
|
3812
|
+
.then((resp) => {
|
|
3813
|
+
if (resp === null || typeof resp !== "object" || resp.ok !== true) return;
|
|
3814
|
+
emitRunningConfig(resp.value);
|
|
3815
|
+
})
|
|
3816
|
+
.catch(() => {});
|
|
2751
3817
|
// Keep "the last moment this browser was here" fresh, so an ending that
|
|
2752
3818
|
// happens while the app is closed still counts as unread when you return.
|
|
2753
3819
|
ctx.effect(() => {
|
|
@@ -2816,7 +3882,7 @@ window.__ModuleLoader__.load({
|
|
|
2816
3882
|
// The badge itself is NOT a slot occupant: it is injected into the
|
|
2817
3883
|
// sidebar's section header by `paintHeaderBadge()` (see above), because
|
|
2818
3884
|
// `sidebar.workspaces` is a single slot covering the whole region. Only
|
|
2819
|
-
// the
|
|
3885
|
+
// the lists they open still ride a slot — the frame-wide overlay, which
|
|
2820
3886
|
// escapes the sidebar's clipping.
|
|
2821
3887
|
//
|
|
2822
3888
|
// 0.2.x has no `ctx.sessions.open(id)` (the ClientSessions service exposes
|
|
@@ -2825,15 +3891,23 @@ window.__ModuleLoader__.load({
|
|
|
2825
3891
|
// `ctx.uiWorkspace.openSession(id)` (ui-chat does exactly this), so the entry
|
|
2826
3892
|
// is looked up optionally: navigation is best effort and must never make the
|
|
2827
3893
|
// favicon half depend on the workspace UI being mounted.
|
|
2828
|
-
const openSession = (
|
|
3894
|
+
const openSession = (row) => {
|
|
3895
|
+
// A list row (`{ id, workspaceId, … }`) since 0.19.0: the workspace is what
|
|
3896
|
+
// the sidebar reveal needs, and the pending list cannot supply it later. A
|
|
3897
|
+
// bare id still opens the session — it just cannot expand anything.
|
|
3898
|
+
const id = row !== null && typeof row === "object" ? row.id : row;
|
|
3899
|
+
const workspaceId = row !== null && typeof row === "object" && typeof row.workspaceId === "string" ? row.workspaceId : "";
|
|
2829
3900
|
try { ctx.get("uiWorkspace")?.openSession(id); } catch { /* navigation is best effort */ }
|
|
3901
|
+
// Deliberately not awaited, and never allowed to matter: the session is open
|
|
3902
|
+
// either way (see the reveal region for why this is all best effort).
|
|
3903
|
+
void revealSessionRow(id, workspaceId);
|
|
2830
3904
|
};
|
|
2831
3905
|
// The injected node labels itself for screen readers, so it needs the
|
|
2832
3906
|
// locale binder that only exists inside `apply()`.
|
|
2833
3907
|
badgeT = t;
|
|
2834
3908
|
ctx.slots.inject("shell.overlay", () => ctx.slots.register(
|
|
2835
|
-
{ name: "shell.overlay", id: "icon-custom-
|
|
2836
|
-
() => React.createElement(
|
|
3909
|
+
{ name: "shell.overlay", id: "icon-custom-header-popup", order: 30 },
|
|
3910
|
+
() => React.createElement(HeaderPopup, { t, onOpen: openSession })
|
|
2837
3911
|
));
|
|
2838
3912
|
|
|
2839
3913
|
// Real number source: a headless occupant of the frame-wide overlay
|
|
@@ -2922,14 +3996,22 @@ window.__ModuleLoader__.load({
|
|
|
2922
3996
|
if (!attach()) retry = window.setInterval(() => { if (attach()) window.clearInterval(retry); }, 1000);
|
|
2923
3997
|
const offDots = subscribeSidebarMarks(schedule);
|
|
2924
3998
|
const offConfig = subscribeUnreadConfig(schedule);
|
|
3999
|
+
// The running marks ride the same paint pass, so their switch has to wake
|
|
4000
|
+
// it too — otherwise turning it off would leave the marks until the next
|
|
4001
|
+
// unrelated repaint.
|
|
4002
|
+
const offRunning = subscribeRunningConfig(schedule);
|
|
2925
4003
|
return () => {
|
|
2926
4004
|
if (retry !== 0) window.clearInterval(retry);
|
|
2927
4005
|
if (workspaceDotVerify !== 0) { window.clearTimeout(workspaceDotVerify); workspaceDotVerify = 0; }
|
|
2928
4006
|
if (observer !== null) observer.disconnect();
|
|
2929
4007
|
offDots();
|
|
2930
4008
|
offConfig();
|
|
4009
|
+
offRunning();
|
|
2931
4010
|
clearWorkspaceDots();
|
|
2932
4011
|
clearHeaderBadge();
|
|
4012
|
+
// The one highlight this plugin ever puts on a SHIPPED row: it must not
|
|
4013
|
+
// outlive the plugin, even if the unload lands inside its flash window.
|
|
4014
|
+
clearRevealFlash();
|
|
2933
4015
|
};
|
|
2934
4016
|
}, "dsh-icon-custom: workspace dots");
|
|
2935
4017
|
// The platform favicon.svg repaints itself for dark mode through
|