@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/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
- let unreadConfig = { reasons: { ...UNREAD_FALLBACK_REASONS }, pending: true, workspaceDot: true, appBadge: UNREAD_APP_BADGE_FALLBACK, clearDelaySec: 0 };
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: whereOf(entry), waiting: false, kind: value.reason, at: endAt });
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: whereOf(byId[id]), waiting: true, kind, at: Date.now() });
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
- if (count <= 0) {
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
- drawFaviconBadge(c2d, FAVICON_SIZE, count, badgeScale(size));
1133
- setIconEverywhere(canvas.toDataURL("image/png"), "image/png");
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
- * Whether the "which sessions?" list is open, and where its button sits.
1210
- * The foot entry and the floating list live in two different slots, so they
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 unreadPanelOpen = false;
1214
- let unreadPanelAnchor = null;
1215
- const unreadPanelListeners = new Set();
1216
- function emitUnreadPanel(open, anchor) {
1217
- unreadPanelOpen = open === true;
1218
- if (anchor !== undefined) unreadPanelAnchor = anchor;
1219
- unreadPanelListeners.forEach((fn) => { try { fn(); } catch {} });
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
- function subscribeUnreadPanel(fn) {
1222
- unreadPanelListeners.add(fn);
1223
- return () => { unreadPanelListeners.delete(fn); };
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
- + "left:" + Math.round(anchorRect.right - rowRect.left - 4) + "px;"
1687
- + "top:" + Math.round(anchorRect.top - rowRect.top - 3) + "px;";
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
- // Session rows: the marker rides inline right after the title, so it stays
1693
- // beside the name whatever the timestamp happens to say.
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
- if (existingMarker(target.row, ROW_DOT_ATTR, entry.id) !== null) { placed++; continue; }
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 4px 0 6px;";
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, items); }, [count, items]);
1872
- // Map session → its Workspace from the official snapshot: the ID anchors the
1873
- // folder dot to the keyed row, the title is what the row displays (not always
1874
- // the cwd basename, and not always equal to the stored title either).
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, workspaces]);
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 badge is a button too; never let it stand in for the search
1978
- // control, or the row anchor would resolve to the wrong element.
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
- toggleUnreadPanel({ left: rect.left, top: rect.top, bottom: rect.bottom });
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 that number, in the frame-wide overlay layer so it escapes
2158
- * the sidebar's clipping and scroll container. Clicking a row opens that
2159
- * session — which is also what marks it read.
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 UnreadPopup(props) {
3064
+ function HeaderPopup(props) {
2162
3065
  const [snapshot, setSnapshot] = React.useState(badgeSnapshot);
2163
- const [open, setOpen] = React.useState(unreadPanelOpen);
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(() => subscribeUnreadPanel(() => setOpen(unreadPanelOpen)), []);
3071
+ React.useEffect(() => subscribePanel(() => setMode(panelMode)), []);
2169
3072
  React.useEffect(() => {
2170
- if (!open) return undefined;
3073
+ if (mode === null) return undefined;
2171
3074
  const interval = window.setInterval(() => tick((n) => n + 1), 30000);
2172
3075
  return () => window.clearInterval(interval);
2173
- }, [open]);
3076
+ }, [mode]);
2174
3077
  React.useEffect(() => {
2175
- if (!open) return undefined;
2176
- const onKey = (event) => { if (event.key === "Escape") emitUnreadPanel(false); };
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-unread="1"]') !== null) return;
2180
- emitUnreadPanel(false);
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
- }, [open]);
2189
- if (!open) return null;
3091
+ }, [mode]);
3092
+ if (mode === null) return null;
2190
3093
  const t = props.t;
2191
- const anchor = unreadPanelAnchor;
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
- const items = Array.isArray(snapshot.items) ? snapshot.items : [];
2207
- const ackTargets = unreadAckTargets(items);
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) emitUnreadPanel(false);
3130
+ if (ackTargets.length === items.length) emitPanel(null);
2222
3131
  };
2223
- return React.createElement("div", { "data-icon-custom-unread": "1", style },
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: () => { emitUnreadPanel(false); props.onOpen(item.id); },
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 badge: keep the exact original element (no wrapper, no layout risk).
2284
- if (badge.count <= 0) return img;
2285
- const edge = Math.max(12, Math.round(size * 0.5 * badgeScale(badge.size)));
2286
- const label = `${badgeLabel(badge.count)} pending (test)`;
2287
- return React.createElement("span", {
2288
- style: { position: "relative", display: "inline-flex", width: size, height: size, flex: "none" }
2289
- },
2290
- img,
2291
- React.createElement("span", {
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 list it opens still rides a slot — the frame-wide overlay, which
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 = (id) => {
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-unread-popup", order: 30 },
2836
- () => React.createElement(UnreadPopup, { t, onOpen: openSession })
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