@cowwo/dsh-icon-custom 0.15.0 → 0.18.0

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