@cowwo/dsh-icon-custom 0.19.0 → 0.20.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
@@ -15,6 +15,7 @@ window.__ModuleLoader__.load({
15
15
  none: "使用平台默认图标",
16
16
  upload: "上传图标",
17
17
  uploadHint: "支持 SVG、PNG、ICO,最大 10 MB。SVG 会自动转成 PNG,自动适配安卓/iOS/平板的主屏图标。上传后立即生效,刷新页面也会保留。",
18
+ uploadHintSummary: "支持 SVG/PNG/ICO,≤10 MB,上传即生效。",
18
19
  svgFail: "该 SVG 无法转换,请改用 PNG。",
19
20
  reset: "恢复默认",
20
21
  resetHint: "清除自定义图标并回到平台默认图标。",
@@ -28,12 +29,15 @@ window.__ModuleLoader__.load({
28
29
  successReset: "已恢复默认图标",
29
30
  pwa: "同时替换 PWA 安装图标",
30
31
  pwaHint: "安装为应用(添加到主屏幕)后,桌面上的图标也换成这张;已安装的应用可能需要重新安装才会更新。",
32
+ pwaHintSummary: "装成应用后桌面图标也换成这张,可能要重装。",
31
33
  successPwa: "PWA 图标设置已更新",
32
34
  logo: "同时替换页面 Logo 图标",
33
35
  logoHint: "把网页左上角品牌 logo 里的鲸鱼图标换成这张(不影响 deepseek HARNESS 文字)。",
36
+ logoHintSummary: "把左上角品牌 logo 里的鲸鱼换成这张。",
34
37
  successLogo: "页面 Logo 已更新",
35
38
  fail: "操作失败",
36
39
  noFile: "请先选择图标文件",
40
+ hintMoreLabel: "查看完整说明",
37
41
  badgeTest: "未读红点",
38
42
  badgeSourceLabel: "数据来源:",
39
43
  badgeSourceReal: "真实未读",
@@ -41,6 +45,7 @@ window.__ModuleLoader__.load({
41
45
  badgeTestUnit: "0 = 不显示",
42
46
  badgeSizeLabel: "红点大小:",
43
47
  badgeSizeHint: "单独出现时按这三档决定大小;红黄两颗数字同时出现时(未读 + 进行中),它们会一起缩到能塞下的最大尺寸 —— 这时三档画出来是一样的,因为画布就这么大。",
48
+ badgeSizeHintSummary: "单独出现按三档;红黄同框时一起缩到能容纳。",
44
49
  badgeSizeSmall: "小",
45
50
  badgeSizeMedium: "中",
46
51
  badgeSizeLarge: "大",
@@ -53,6 +58,9 @@ window.__ModuleLoader__.load({
53
58
  reasonAbortedUser: "我自己停止",
54
59
  reasonAbortedOther: "其他中断",
55
60
  reasonPending: "有人在等我(审批/提问/计划)",
61
+ unreadIncludeSubagentsLabel: "子代理引起的结束也算未读",
62
+ unreadIncludeSubagentsHint: "后台子代理跑完时,运行时会把它所属的会话**再叫醒跑一轮**;这一项决定那一轮结束算不算未读。关掉它:由子代理唤醒的那一轮结束不再亮红点,你自己发起的一轮照旧提醒(子代理自己没有会话行,一直都不算)。打开(默认)= 和以前完全一样。判定依据是官方日志里的那条「子代理结算」消息(`subagent-settled` / `agent-message`),所以一个回合里第一条消息是谁发的就决定了这一轮的归属;**你中途在同一条会话里插话**不会把已经记下的归属改写。",
63
+ unreadIncludeSubagentsHintSummary: "子代理唤醒的那一轮结束算不算未读。默认算。",
56
64
  unreadPanelShort: "待处理",
57
65
  unreadPanelCount: "{n} 个待处理",
58
66
  unreadPanelNone: "暂无待处理",
@@ -76,10 +84,13 @@ window.__ModuleLoader__.load({
76
84
  ageYears: "{n}年",
77
85
  workspaceDotLabel: "在工作区和会话行上显示红点(实验)",
78
86
  workspaceDotHint: "在侧栏工作区那一行的文件夹图标上点一个小红点(收起的工作区里有多少未读,就看它),有未读的会话在其标题右边也跟一个小红点。会话行上的点只在那一行真的显示着时才画:折叠起来的会话由工作区那一行的点和「待处理」清单代表。这一项是贴着页面结构做的,DSH 升级后可能失效;失效时只会变成\"不显示\",不会影响红点数字、清单、跳转这些功能,随时可以关掉它。",
87
+ workspaceDotHintSummary: "工作区文件夹和会话行上加小红点(实验,可能失效)。",
79
88
  revealLabel: "点清单里的会话时,在侧栏定位到它(实验)",
80
89
  revealHint: "点「待处理」或「进行中」清单里的一条时,除了打开那个会话,还会在侧栏把它所在的工作区展开、滚到那一行、并闪一下。这是贴着页面结构做的:官方只把\"展开 + 滚动\"留给了它自己的搜索框,没有给插件留接口,所以这里只能模拟点击官方的工作区行和「显示更多」。DSH 升级后可能失效,失效的表现只是\"侧栏不动\",打开会话完全不受影响。**展开状态是官方自己记住的**(存在浏览器里),所以它不会自己收回去——不想要就手动收起那个工作区,或者关掉这个开关。",
90
+ revealHintSummary: "点清单时在侧栏展开并闪一下(实验,可能失效)。",
81
91
  appBadgeLabel: "在系统应用图标上显示红点",
82
92
  appBadgeHint: "安装为应用后,把同一个数字也放到系统图标上:Windows 是任务栏图标上的角标,macOS 是 Dock 角标,iOS 是主屏图标上的数字。样子由系统决定——Windows 上 Chrome 画深色圆+白字,Edge 走 Windows 系统徽章通道(是否显示数字由 Edge/Windows 决定);插件会周期重设数字,但只在应用窗口开着时更新。Safari 的程序坞网页应用 / 主屏应用还要先允许通知(见下面的「通知权限」),否则角标不会出现。",
93
+ appBadgeHintSummary: "把同一个数字也放到系统应用图标上。",
83
94
  appBadgeFootnote: "角标只在应用窗口开着时由本插件更新;数字超过 99 时由系统显示为 99+。",
84
95
  appBadgeProbeInstalled: "当前环境:已安装为应用,本机可以设置角标。是否真正显示由系统决定。",
85
96
  appBadgeProbeTab: "当前环境:浏览器标签页(不是安装的应用)——安装为应用后才会显示角标。",
@@ -92,15 +103,25 @@ window.__ModuleLoader__.load({
92
103
  notificationPermissionUnsupported: "本机浏览器没有通知能力",
93
104
  notificationPermissionButton: "允许通知",
94
105
  notificationPermissionHint: "Chrome / Edge 的角标不需要这一步;Safari 不一样——macOS 的「添加到程序坞」网页应用和 iOS 的主屏应用,只有在你允许通知之后才会画出角标(Apple 把角标权限绑在通知权限上)。这里只申请权限,本插件目前不会给你发通知。",
95
- badgeTestHint: "真实未读 = 有会话发生了上面勾选的情况、而且你还没看过它(打开该会话即视为已读)。你正开着的会话也算——窗口可能被最小化、页面可能切到后台,这里无从判断你在不在看,所以不做这个区分。只有子代理不计。手动模式:自己填数字试看效果。刷新页面后回到真实未读。",
106
+ notificationPermissionHintSummary: "只有 Safari 系(iOS/macOS)需要先允许通知。",
107
+ badgeTestHint: "真实未读 = 有会话发生了上面勾选的情况、而且你还没看过它(打开该会话即视为已读)。你正开着的会话也算——窗口可能被最小化、页面可能切到后台,这里无从判断你在不在看,所以不做这个区分。子代理是否计入,由上方「子代理引起的结束也算未读」开关决定。手动模式:自己填数字试看效果。刷新页面后回到真实未读。",
108
+ badgeTestHintSummary: "真实未读 = 勾选情况发生且你没看过;子代理看上方的开关。",
96
109
  clearDelayLabel: "进入会话后多久算已读:",
97
110
  clearDelayUnit: "秒",
98
111
  clearDelayHint: "填 0 = 进去就算已读(红点立即消失)。大于 0 时,你要在那个会话里连续待满这么多秒它才算看过:中途切走会清零重数,刷新页面也重新数。**计时只认\"你进入会话时已经存在的结束\"**——你人已经在里面的时候又跑完一轮,那一轮不算数:它会一直亮着,等你切走再进来才会清。上限 600 秒。**切到「插件/设置」页、或把标签页切到后台,都只是暂停计时,不会因此清掉红点**(它们不算离开);真正算离开的只有一件事:**去别的会话**。已读记在宿主($DSH_HOME/custom-favicon/unread-seen.json):任何一台设备上看过,所有设备都不再提醒;浏览器那份只是缓存,清站点数据不会把已读丢回去。",
112
+ clearDelayHintSummary: "0 = 进去即已读;>0 要连续待满,上限 600 秒。",
99
113
  /** 进行中黄点(0.18.0):官方 running 的第二次投影,与红点互不牵连。 */
100
114
  runningMarkSection: "进行中黄点",
101
115
  runningMarkLabel: "显示进行中标记(标题数字 / 文件夹与会话行 / 左上角 / 标签页)",
102
116
  runningMarkCount: "{n} 个进行中",
103
- runningMarkHint: "黄点就是官方的「进行中」:会话有回合正在跑、而且没有停下来等你(停下等你的时候,官方那一行显示琥珀色的「等待审批 / 等待回答」,黄点也就不亮)。它和未读红点完全独立、各画各的:侧栏「工作区」标题后面是黄色数字——此刻全局有几个会话在跑(和红数字上下排:右上红、右下黄);工作区文件夹图标上是黄点,有未读时红点在左、黄点在右;会话行上同样是红点在左、黄点在右;左上角 Logo 与标签页图标右下角也是同一个数字,红数字仍占右上角。事实全部来自官方(会话列表里每个会话的 `running`)——插件不落盘、不推断,所以刷新页面立刻是对的,进程重启也不会留下一个永远亮着的黄点。关掉这个开关,红点完全不受影响。"
117
+ runningMarkHint: "黄点就是官方的「进行中」:会话有回合正在跑、而且没有停下来等你(停下等你的时候,官方那一行显示琥珀色的「等待审批 / 等待回答」,黄点也就不亮)。它和未读红点完全独立、各画各的:侧栏「工作区」标题后面是黄色数字——此刻全局有几个会话在跑(和红数字上下排:右上红、右下黄);工作区文件夹图标上是黄点,有未读时红点在左、黄点在右;会话行上同样是红点在左、黄点在右;左上角 Logo 与标签页图标右下角也是同一个数字,红数字仍占右上角。事实全部来自官方(会话列表里每个会话的 `running`)——插件不落盘、不推断,所以刷新页面立刻是对的,进程重启也不会留下一个永远亮着的黄点。关掉这个开关,红点完全不受影响。",
118
+ runningMarkHintSummary: "黄点就是官方的进行中,和红点各自独立、互不影响。",
119
+ runningIncludeSubagentsLabel: "把子代理的「进行中」算到它所属的会话上",
120
+ runningIncludeSubagentsHint: "子代理(子会话)在侧栏里没有自己的那一行,所以它正在跑的时候,它上面那个真正属于你的会话看上去是空闲的。打开这个开关:有子代理在跑,就把最顶层那个不是子代理的祖先会话记为进行中——黄点/黄数字反映的是「有活正在干」,而不只是「你亲手开的会话在跑」。同一个祖先下好几个子代理同时跑,只算一次(数字始终是会话数,不是子代理数);子代理自己那一行照旧不画。祖先会话正在等你(官方那行是琥珀色)时不计。关掉就完全回到旧行为:所有子代理一律不计。",
121
+ runningIncludeSubagentsHintSummary: "子代理在跑就把最顶层祖先会话记为进行中。",
122
+ headerRingLabel: "在会话头部的子代理计数上叠一个黄圈",
123
+ headerRingHint: "聊天区顶部那颗「N 个子智能体」的计数,右上角会叠一个小黄圈:只在**有直接子代理正在运行**时出现,里面的数字就是它们此刻的个数——不含更深一层的孙代理,同一个直接子代理只算一次,超过 99 显示 `99+`。它只是个数字:**不能点**,也不拦截任何点击,更不会改动画面的排版;会话头部那颗计数本身的文字和点击照旧,官方按钮的朗读里已经含运行数,所以它对着屏幕阅读器是隐藏的。关掉这个开关,其他地方的红黄点、左上角数字、标签页图标与系统角标一个都不受影响。子代理自己的会话页不显示官方那行,这个黄圈自然也不会出现。",
124
+ headerRingHintSummary: "只在有直接子代理在跑时,会话头部计数右上角叠一个黄圈。"
104
125
  };
105
126
  const en = {
106
127
  nav: "Favicon",
@@ -108,6 +129,7 @@ window.__ModuleLoader__.load({
108
129
  none: "Using the platform default icon",
109
130
  upload: "Upload",
110
131
  uploadHint: "SVG, PNG or ICO, up to 10 MB. SVG is auto-rasterized to PNG, and sizes are derived to fit Android/iOS/tablet home-screen icons. Applies instantly and persists across reloads.",
132
+ uploadHintSummary: "SVG, PNG or ICO up to 10 MB; applies at once and persists.",
111
133
  svgFail: "This SVG could not be rasterized. Use a PNG instead.",
112
134
  reset: "Reset",
113
135
  resetHint: "Clear the custom icon and use the platform default.",
@@ -121,12 +143,15 @@ window.__ModuleLoader__.load({
121
143
  successReset: "Default icon restored",
122
144
  pwa: "Also replace the PWA app icon",
123
145
  pwaHint: "The icon used after installing the site as an app (add to home screen). Already-installed apps may need a reinstall to pick it up.",
146
+ pwaHintSummary: "The icon after installing as an app; may need a reinstall.",
124
147
  successPwa: "PWA icon setting updated",
125
148
  logo: "Also replace the page logo mark",
126
149
  logoHint: "Replaces the whale mark beside the brand name in the top-left (the deepseek HARNESS text stays).",
150
+ logoHintSummary: "Swaps the whale mark inside the top-left brand logo.",
127
151
  successLogo: "Page logo updated",
128
152
  fail: "Operation failed",
129
153
  noFile: "Choose a file first",
154
+ hintMoreLabel: "Show full description",
130
155
  badgeTest: "Unread badge",
131
156
  badgeSourceLabel: "Number source:",
132
157
  badgeSourceReal: "Real unread",
@@ -134,6 +159,7 @@ window.__ModuleLoader__.load({
134
159
  badgeTestUnit: "0 = hidden",
135
160
  badgeSizeLabel: "Badge size:",
136
161
  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.",
162
+ badgeSizeHintSummary: "Used when alone; both numbers shrink together to fit.",
137
163
  badgeSizeSmall: "Small",
138
164
  badgeSizeMedium: "Medium",
139
165
  badgeSizeLarge: "Large",
@@ -146,6 +172,9 @@ window.__ModuleLoader__.load({
146
172
  reasonAbortedUser: "I stopped it",
147
173
  reasonAbortedOther: "Other cancel",
148
174
  reasonPending: "Someone is waiting",
175
+ unreadIncludeSubagentsLabel: "Count sub-agent activity as unread",
176
+ unreadIncludeSubagentsHint: "When a background sub-agent finishes, the runtime **wakes its parent session for one more turn**; this decides whether that turn's ending lights the red dot. Off: endings caused by a sub-agent stop lighting it, while a turn you started still notifies you (a sub-agent has no session row of its own and never counted). On (default) = exactly the old behaviour. The judgement comes from the official settlement notice in the log (`subagent-settled` / `agent-message`), so whichever message opened a turn owns that turn; typing into the same session halfway through does not rewrite an attribution already recorded.",
177
+ unreadIncludeSubagentsHintSummary: "Whether a sub-agent-woken turn counts as unread; on by default.",
149
178
  unreadPanelShort: "Pending",
150
179
  unreadPanelCount: "{n} pending",
151
180
  unreadPanelNone: "Nothing pending",
@@ -169,10 +198,13 @@ window.__ModuleLoader__.load({
169
198
  ageYears: "{n}y",
170
199
  workspaceDotLabel: "Dots on workspace and session rows (experimental)",
171
200
  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.",
201
+ workspaceDotHintSummary: "Small dots on workspace folders and session rows (experimental).",
172
202
  revealLabel: "Locate the session in the sidebar when a list row is clicked (experimental)",
173
203
  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.",
204
+ revealHintSummary: "Expands and flashes the session's sidebar row (experimental).",
174
205
  appBadgeLabel: "Badge the system app icon",
175
206
  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.",
207
+ appBadgeHintSummary: "Puts the same number onto the system app icon.",
176
208
  appBadgeFootnote: "The badge is only updated by this plugin while the app window is open; above 99 the system shows 99+.",
177
209
  appBadgeProbeInstalled: "This environment: installed as an app, so this device can set a badge. Whether it actually appears is up to the system.",
178
210
  appBadgeProbeTab: "This environment: a browser tab (not an installed app) — install as an app to get a badge.",
@@ -185,15 +217,25 @@ window.__ModuleLoader__.load({
185
217
  notificationPermissionUnsupported: "this browser has no Notification API",
186
218
  notificationPermissionButton: "Allow notifications",
187
219
  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.",
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.",
220
+ notificationPermissionHintSummary: "Only Safari (iOS/macOS) needs notifications allowed first.",
221
+ 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. Whether sub-agents count is decided by the \"Count sub-agent activity as unread\" switch above. Manual = type a number to preview. Resets to Real on reload.",
222
+ badgeTestHintSummary: "Real unread = a checked ending you have not seen; sub-agents follow the switch above.",
189
223
  clearDelayLabel: "Mark read after staying:",
190
224
  clearDelayUnit: "seconds",
191
225
  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.",
226
+ clearDelayHintSummary: "0 = read at once; above 0, stay that long (max 600s).",
192
227
  /** Running mark (0.18.0): a second projection of the official `running`. */
193
228
  runningMarkSection: "Running marks",
194
229
  runningMarkLabel: "Show running marks (header number / folder and session rows / top-left / tab)",
195
230
  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."
231
+ 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.",
232
+ runningMarkHintSummary: "The yellow mark is the official running state, independent of red dots.",
233
+ runningIncludeSubagentsLabel: "Count sub-agent activity under the session that owns it",
234
+ runningIncludeSubagentsHint: "A sub-agent (child session) has no row of its own in the sidebar, so while it works the real session above it looks idle. Turn this on: when a sub-agent is running, the topmost non-sub-agent ancestor session is credited as running — the yellow mark and number then reflect \"work is in flight\", not merely \"a session you opened is running\". Several sub-agents under one ancestor count once (the number stays a session count, never a sub-agent count), and the sub-agent row itself is still never drawn. An ancestor that is itself parked waiting for you (the official row is amber) is not counted. Turn it off and the old behaviour returns exactly: every sub-agent is ignored.",
235
+ runningIncludeSubagentsHintSummary: "Credits the topmost ancestor session while a sub-agent runs.",
236
+ headerRingLabel: "Draw a yellow ring on the header's sub-agent count",
237
+ headerRingHint: "The \"N sub-agents\" count at the top of the chat area gets a small yellow ring on its top-right corner: it appears only while **direct sub-agents are running**, and the number inside is how many of them are active right now — it never counts deeper grandchildren, and one direct sub-agent counts once; above 99 it reads `99+`. It is a number only: **it is not clickable**, it swallows no pointer events, and it never changes the layout; the header count keeps its own text and its own click, and because the official button's label already announces the running count the ring is hidden from screen readers. Turning this switch off leaves every other red/yellow mark, the top-left number, the tab icon and the system badge untouched. A sub-agent's own conversation renders none of that header row, so no ring appears there either.",
238
+ headerRingHintSummary: "A yellow ring on the header count while direct sub-agents run."
197
239
  };
198
240
  //#endregion
199
241
 
@@ -313,7 +355,9 @@ window.__ModuleLoader__.load({
313
355
  const UNREAD_APP_BADGE_FALLBACK = true;
314
356
  /** Mirror of the host's default (see lib/unread.js) — locating a clicked row is on. */
315
357
  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 };
358
+ /** Mirror of the host's default (see lib/unread.js) — sub-agent-caused endings count as unread. */
359
+ const UNREAD_INCLUDE_SUBAGENTS_FALLBACK = true;
360
+ let unreadConfig = { reasons: { ...UNREAD_FALLBACK_REASONS }, pending: true, workspaceDot: true, appBadge: UNREAD_APP_BADGE_FALLBACK, clearDelaySec: 0, revealOnOpen: UNREAD_REVEAL_FALLBACK, unreadIncludeSubagents: UNREAD_INCLUDE_SUBAGENTS_FALLBACK };
317
361
  const unreadConfigListeners = new Set();
318
362
  /** Mirror of the host's normalizer: any input becomes an integer 0..cap seconds. */
319
363
  function normalizeClearDelay(input) {
@@ -336,7 +380,12 @@ window.__ModuleLoader__.load({
336
380
  workspaceDot: typeof source.workspaceDot === "boolean" ? source.workspaceDot : true,
337
381
  appBadge: typeof source.appBadge === "boolean" ? source.appBadge : UNREAD_APP_BADGE_FALLBACK,
338
382
  clearDelaySec: normalizeClearDelay(source.clearDelaySec),
339
- revealOnOpen: typeof source.revealOnOpen === "boolean" ? source.revealOnOpen : UNREAD_REVEAL_FALLBACK
383
+ revealOnOpen: typeof source.revealOnOpen === "boolean" ? source.revealOnOpen : UNREAD_REVEAL_FALLBACK,
384
+ // Missing or dirty falls back to ON (= today's behaviour): an old host that
385
+ // never heard of this field must not make the badge quieter. Spelled as a
386
+ // literal like `pending` / `workspaceDot` above, so a partial slice of this
387
+ // normalizer (the tests take exactly this function) needs no extra constant.
388
+ unreadIncludeSubagents: typeof source.unreadIncludeSubagents === "boolean" ? source.unreadIncludeSubagents : true
340
389
  };
341
390
  }
342
391
  function emitUnreadConfig(input) {
@@ -365,11 +414,35 @@ window.__ModuleLoader__.load({
365
414
  const RUNNING_YELLOW = "#f5c518";
366
415
  /** Mirror of the host default (lib/running.js). */
367
416
  const RUNNING_MARK_FALLBACK = true;
368
- let runningConfig = { enabled: RUNNING_MARK_FALLBACK };
417
+ /**
418
+ * Mirror of the host's second running default (lib/running.js): rolling a
419
+ * running sub-agent up to the ancestor session that owns it is ON. The two
420
+ * questions are independent — whether to draw the marks at all is not the same
421
+ * as whether work you cannot see should light the session it belongs to.
422
+ */
423
+ const RUNNING_INCLUDE_SUBAGENTS_FALLBACK = true;
424
+ /**
425
+ * Mirror of the host's third running default (lib/running.js): the yellow ring
426
+ * on the conversation header's sub-agent count is ON. A third, independent
427
+ * question — the two above decide whether marks are drawn at all and whether a
428
+ * sub-agent's work is credited to its ancestor; this one only decides whether
429
+ * that one header count also carries a running number. It feeds no other count.
430
+ */
431
+ const RUNNING_HEADER_RING_FALLBACK = true;
432
+ let runningConfig = { enabled: RUNNING_MARK_FALLBACK, includeSubagents: RUNNING_INCLUDE_SUBAGENTS_FALLBACK, headerRing: RUNNING_HEADER_RING_FALLBACK };
369
433
  const runningConfigListeners = new Set();
370
434
  function normalizeRunningConfig(input) {
371
435
  const source = input !== null && typeof input === "object" ? input : {};
372
- return { enabled: typeof source.enabled === "boolean" ? source.enabled : RUNNING_MARK_FALLBACK };
436
+ // `typeof` (not a bare read) keeps this normalizer usable when a regression
437
+ // test lifts it out of the module: an absent mirror is simply the default.
438
+ const enabledFallback = typeof RUNNING_MARK_FALLBACK === "boolean" ? RUNNING_MARK_FALLBACK : true;
439
+ const includeFallback = typeof RUNNING_INCLUDE_SUBAGENTS_FALLBACK === "boolean" ? RUNNING_INCLUDE_SUBAGENTS_FALLBACK : true;
440
+ const headerRingFallback = typeof RUNNING_HEADER_RING_FALLBACK === "boolean" ? RUNNING_HEADER_RING_FALLBACK : true;
441
+ return {
442
+ enabled: typeof source.enabled === "boolean" ? source.enabled : enabledFallback,
443
+ includeSubagents: typeof source.includeSubagents === "boolean" ? source.includeSubagents : includeFallback,
444
+ headerRing: typeof source.headerRing === "boolean" ? source.headerRing : headerRingFallback
445
+ };
373
446
  }
374
447
  function emitRunningConfig(input) {
375
448
  runningConfig = normalizeRunningConfig(input);
@@ -646,69 +719,102 @@ window.__ModuleLoader__.load({
646
719
  function documentVisible() {
647
720
  try { return document.visibilityState !== "hidden"; } catch { return true; }
648
721
  }
722
+ /**
723
+ * The official "Session on screen" answer, when this build exposes it.
724
+ *
725
+ * `ctx.uiSession.adapter.current` is the very binding the renderer derives
726
+ * `[data-conversation-session]` from, so it answers with the same value without
727
+ * the DOM's first-match ambiguity (see `conversationOnScreen`) — and it keeps
728
+ * answering while a global panel has unmounted the conversation, where the DOM
729
+ * has nothing to say. Filled in by `apply`, where a client context exists;
730
+ * until then it answers nothing, which is exactly what the other fallbacks are
731
+ * for.
732
+ * @returns the session id, or undefined when this build does not say.
733
+ */
734
+ let officialSessionId = () => undefined;
735
+ /**
736
+ * What the conversation on screen is rendering, in two parts.
737
+ *
738
+ * `rendered` says whether the main conversation is on screen at all, and `id` is
739
+ * the session it renders (`undefined` while none is selected — the landing view).
740
+ * The distinction matters: "the conversation is showing no session" is an answer
741
+ * ("you are not in a session"), while "there is no conversation element" is not
742
+ * an answer at all (a global panel replaced it, the page is still booting).
743
+ *
744
+ * TWO occurrences can carry this attribute: the main conversation, and the
745
+ * embedded one the right sidebar keeps MOUNTED while collapsed (hidden by CSS and
746
+ * `inert`). A plain `querySelector` returns the first in document order, so the
747
+ * moment the main occurrence carries no attribute — no session selected, the
748
+ * archive/close path — the hidden one becomes the answer: "you are looking at
749
+ * that subagent conversation". The stay clock reads that as "you left this
750
+ * session", drops the entered-at snapshot, and the ending that arrived meanwhile
751
+ * is acknowledged the moment you come back: a dot that goes out without you ever
752
+ * leaving. Only an occurrence the user can actually see is an answer.
753
+ * @returns `{ rendered, id }`.
754
+ */
755
+ function conversationOnScreen() {
756
+ try {
757
+ const nodes = document.querySelectorAll("[data-conversation-session]");
758
+ let rendered = false;
759
+ for (const node of nodes) {
760
+ if (typeof node.closest === "function" && (node.closest("[data-sidebar-right-panel]") !== null || node.closest("[inert]") !== null)) continue;
761
+ rendered = true;
762
+ const id = node.getAttribute("data-conversation-session");
763
+ if (typeof id === "string" && id !== "") return { rendered: true, id };
764
+ }
765
+ return { rendered, id: undefined };
766
+ } catch { return { rendered: false, id: undefined }; }
767
+ }
649
768
  /**
650
769
  * The session whose conversation the page is rendering right now, from the DOM.
651
770
  *
652
771
  * This is the only signal that names the session on screen, and it is the one
653
772
  * that keeps a stale retention from clearing a dot: `retainedBy.mainView` means
654
- * "recently opened, not yet released", not "displayed". `undefined` means this
655
- * build carries no such marker (or the probe failed) and callers must fall back
656
- * to the retention answer rather than treat it as "nothing is displayed".
773
+ * "recently opened, not yet released", not "displayed". `undefined` means the page
774
+ * does not name one (see `conversationOnScreen`) and callers must fall back to the
775
+ * other answers rather than treat it as "nothing is displayed".
657
776
  * @returns the session id, or undefined when the page cannot say.
658
777
  */
659
778
  function displayedSessionId() {
660
- try {
661
- const node = document.querySelector("[data-conversation-session]");
662
- if (node === null) return undefined;
663
- const id = node.getAttribute("data-conversation-session");
664
- return typeof id === "string" && id !== "" ? id : undefined;
665
- } catch { return undefined; }
666
- }
667
- /**
668
- * Every session the main view is showing.
669
- *
670
- * `view` carries what the page itself says about the screen:
671
- * * `panelActive` — a global panel (settings, plugins, …) replaced the
672
- * conversation. DSH's own sidebar treats that as "no current session"
673
- * (`usePanelInfo((info) => info.activePanelId !== null)`), and following it
674
- * is what fixes the "cleared a dot I never opened" report: the conversation
675
- * is simply not on screen.
676
- * * `visible` — whether this page is on screen at all.
677
- * * `displayed` — the session the conversation is rendering, when the DOM can
678
- * name it. That is the precise answer and it wins over retention.
679
- *
680
- * Retention (`retainedBy.mainView`) stays as the fallback: it is what shipped
681
- * before, it keeps working on a build without the newer signals, and its known
682
- * weakness (a release that lags behind the pane) only costs a false "read" when
683
- * nothing better is available.
684
- * @param list - the session list snapshot (may be absent).
685
- * @param view - the screen report described above (may be absent).
686
- * @returns the ids being viewed, in list order; `[]` when there are none.
687
- */
779
+ return conversationOnScreen().id;
780
+ }
688
781
  /**
689
- * Which session this page is ON — the set that defines "entered" and "left".
782
+ * The session this page is ON — the set that defines "entered" and "left" — plus
783
+ * whether the answer is actually known.
784
+ *
785
+ * Only a change here counts as leaving a session, so this must NOT be emptied by
786
+ * pausing: a background tab, or a global panel (settings, plugins, …) that covers
787
+ * the conversation, keeps you on the same session. Emptying it would drop the
788
+ * entered-at snapshot, and taking a fresh one on return is exactly what
789
+ * acknowledges an ending you never left the session for.
690
790
  *
691
- * Only a change here counts as leaving a session, so this must NOT be emptied
692
- * by pausing: a background tab, or a global panel (settings, plugins, …) that
693
- * covers the conversation, keeps you on the same session. Emptying it would
694
- * drop the entered-at snapshot, and taking a fresh one on return is exactly
695
- * what acknowledges an ending you never left the session for — the bug behind
696
- * "切一下标签页红点就没了".
791
+ * The answers, in order:
792
+ * * the visible conversation's own session (the precise one, and the only one
793
+ * that may overrule retention);
794
+ * * the official "Session on screen" binding (`view.official`, from
795
+ * `ctx.uiSession`) — the very value the attribute above is rendered from, so
796
+ * it still answers while a global panel has unmounted the conversation;
797
+ * * `list.current`, when a build carries it;
798
+ * * retention (`retainedBy.mainView > 0`), the pre-0.12 answer.
697
799
  *
698
- * `view.displayed` (read from the DOM) is the precise answer when available;
699
- * `list.current` and then retention are the fallbacks, which is also what keeps
700
- * the answer meaningful while a panel covers the conversation.
800
+ * `known: false` is the important one: it means the page could not name ANY
801
+ * session, which is NOT the same as "no session is being viewed". Callers must
802
+ * keep the snapshots they already have instead of reading it as a leave — a
803
+ * single such frame used to be enough to acknowledge an ending you were sitting
804
+ * in front of.
701
805
  * @param list - the session list snapshot (may be absent).
702
- * @param view - `{ displayed }`: the session the conversation is rendering.
703
- * @returns the ids being viewed, in list order; `[]` when there are none.
806
+ * @param view - `{ displayed, official }`: what the page says about the screen.
807
+ * @returns `{ ids, known }`; `ids` in list order, `[]` when there are none.
704
808
  */
705
- function currentSessionIds(list, view) {
809
+ function viewedSessions(list, view) {
706
810
  const screen = view !== null && typeof view === "object" ? view : {};
707
- if (typeof screen.displayed === "string" && screen.displayed !== "") return [screen.displayed];
708
- if (list === null || typeof list !== "object") return [];
709
- if (typeof list.current === "string" && list.current !== "") return [list.current];
811
+ if (typeof screen.displayed === "string" && screen.displayed !== "") return { ids: [screen.displayed], known: true };
812
+ if (typeof screen.official === "string" && screen.official !== "") return { ids: [screen.official], known: true };
813
+ if (screen.idle === true) return { ids: [], known: true }; // the conversation is up, showing no session
814
+ if (list === null || typeof list !== "object") return { ids: [], known: false };
815
+ if (typeof list.current === "string" && list.current !== "") return { ids: [list.current], known: true };
710
816
  const byId = list.byId;
711
- if (byId === null || typeof byId !== "object") return [];
817
+ if (byId === null || typeof byId !== "object") return { ids: [], known: false };
712
818
  const ids = [];
713
819
  for (const session of Object.values(byId)) {
714
820
  if (session === null || typeof session !== "object") continue;
@@ -716,7 +822,11 @@ window.__ModuleLoader__.load({
716
822
  const count = retainedBy !== null && typeof retainedBy === "object" ? retainedBy.mainView : undefined;
717
823
  if (typeof count === "number" && count > 0 && typeof session.id === "string" && session.id !== "") ids.push(session.id);
718
824
  }
719
- return ids;
825
+ // Retention with nothing in it, and the conversation not on screen either, is
826
+ // exactly the frame this must not read as a leave: it is what a global panel
827
+ // over a released session, a boot, or a DOM gap looks like. The landing view
828
+ // (`idle`) is the positive "you are on no session" answer, not this.
829
+ return { ids, known: ids.length > 0 };
720
830
  }
721
831
  /**
722
832
  * One session's last `turn/end` timestamp (host domain), or 0 when it has
@@ -750,7 +860,17 @@ window.__ModuleLoader__.load({
750
860
  /**
751
861
  * How many sessions deserve the badge right now: endings whose reason is
752
862
  * enabled and that this browser has not seen since, plus sessions waiting
753
- * for you (when that source is enabled). Only sub-agent sessions never count.
863
+ * for you (when that source is enabled). Sub-agent sessions never count on
864
+ * their own; whether an ending caused by a sub-agent's settlement counts is
865
+ * decided by the `unreadIncludeSubagents` rule, not by this function.
866
+ *
867
+ * A PARENT session woken by a sub-agent settling is a second kind of
868
+ * sub-agent-caused unread: the child has no row of its own, but its
869
+ * settlement notice makes the parent run a turn, and that turn's ending used
870
+ * to light the parent's dot. The fold marks those endings
871
+ * (`subagentCaused`, see lib/unread.js) instead of hiding them, and this
872
+ * function drops them only while the rule says so — the fact stays in the
873
+ * projection either way, so flipping the switch never re-folds a log.
754
874
  *
755
875
  * The session you are LOOKING AT counts too. It used to be excluded, on the
756
876
  * theory that "you have it open, so you have seen it" — but that premise is
@@ -762,13 +882,19 @@ window.__ModuleLoader__.load({
762
882
  * @param list - the session list snapshot (may be absent).
763
883
  * @param pending - the pending-interaction map (may be absent).
764
884
  * @param config - the unread rule to apply.
885
+ * @param lit - the ids currently showing a dot, so a change of ENDING KIND cannot
886
+ * put an already lit dot out (see below). Optional.
765
887
  * @returns the badge number.
766
888
  */
767
- function collectUnread(list, pending, config, seen) {
889
+ function collectUnread(list, pending, config, seen, lit) {
768
890
  const reasons = config !== null && typeof config === "object" && config.reasons !== null && typeof config.reasons === "object" ? config.reasons : {};
891
+ // Absent, dirty or an old host's answer → ON (= everything this function
892
+ // did before the switch existed).
893
+ const includeSubagents = !(config !== null && typeof config === "object" && config.unreadIncludeSubagents === false);
769
894
  // The watermark is a parameter, not module state: this stays a pure
770
895
  // decision function that a test can drive with any marks it likes.
771
896
  const marks = seen !== null && typeof seen === "object" ? seen : {};
897
+ const litIds = lit !== null && lit !== undefined && typeof lit.has === "function" ? lit : null;
772
898
  /** { id, title, waiting, kind, at } — newest first, "in wait" ahead of "ended". */
773
899
  const hits = [];
774
900
  const ids = new Set();
@@ -783,12 +909,23 @@ window.__ModuleLoader__.load({
783
909
  const values = entry.projectionValues;
784
910
  const value = values !== null && typeof values === "object" ? values[LAST_TURN_END_KEY] : undefined;
785
911
  if (value === null || typeof value !== "object") continue;
912
+ // The switch is OFF: an ending the fold attributed to a sub-agent
913
+ // settling is not news for the person sitting here. This is NOT the
914
+ // reason-toggle exception below — that one protects an already lit dot
915
+ // from a vocabulary change, while this one is the user explicitly
916
+ // saying they do not want this class of news at all.
917
+ if (!includeSubagents && value.subagentCaused === true) continue;
786
918
  const endAt = typeof value.endAt === "number" ? value.endAt : 0;
787
919
  const seenAt = marks[id];
788
920
  if (endAt <= 0 || typeof seenAt !== "number" || endAt <= seenAt) continue;
789
921
  // Unknown reasons (a newer DSH adds one) stay hidden until the user
790
922
  // turns them on, so a vocabulary growth can never surprise the badge.
791
- if (reasons[value.reason] !== true) continue;
923
+ // A reason the user turned OFF behaves the same way — with ONE
924
+ // exception: it must never put out a dot that is already lit. That dot
925
+ // stands for an ending nobody has read yet, and "the newest turn ended
926
+ // in a way I do not want to be told about" is not "I read it". Only the
927
+ // watermark clears a lit dot.
928
+ if (reasons[value.reason] !== true && !(litIds !== null && litIds.has(id))) continue;
792
929
  ids.add(id);
793
930
  hits.push({ id, title: titleOf(id, entry), where: sessionWhereOf(entry), waiting: false, kind: value.reason, at: endAt });
794
931
  }
@@ -832,12 +969,20 @@ window.__ModuleLoader__.load({
832
969
  * so it is NOT running here either. That is why a session with the
833
970
  * amber dot never also gets a yellow one.
834
971
  *
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.
972
+ * Sub-agent sessions get no ids of their own for the same reason
973
+ * `collectUnread` skips them: they are not part of the browsing the user does
974
+ * (ui-workspace's `sessionVisible` hides them outright), so a mark on their row
975
+ * would point at work nobody can open. With `includeSubagents` on (the
976
+ * default), a running sub-agent still credits the topmost non-sub-agent
977
+ * ancestor session that owns it, walking `parentSessionId` upwards: that is the
978
+ * session the user actually opened, and without the roll-up it looks idle while
979
+ * its sub-agents work. Several sub-agents under one ancestor collapse into that
980
+ * single id, so the number stays a count of sessions. The ancestor's own
981
+ * `pendingInteraction` still wins, switch or no switch: a session parked
982
+ * waiting for you is never also "running".
839
983
  * @param list - the session list snapshot (may be absent).
840
984
  * @param status - the official session-status map (may be absent).
985
+ * @param includeSubagents - optional override for the switch; absent means "ask the live config", whose documented default is ON.
841
986
  * @returns the running session ids, in list order.
842
987
  */
843
988
  function runningSessionIds(list, status) {
@@ -848,17 +993,68 @@ window.__ModuleLoader__.load({
848
993
  const lookup = status !== null && status !== undefined && typeof status.get === "function"
849
994
  ? (id) => status.get(id)
850
995
  : () => undefined;
996
+ // The roll-up switch. A third argument wins (the regression tests drive it
997
+ // that way); otherwise the live config answers, and a copy lifted out of the
998
+ // module with no config in scope is the documented default: ON.
999
+ const includeSubagents = arguments.length > 2
1000
+ ? arguments[2] !== false
1001
+ : !(typeof runningConfig === "object" && runningConfig !== null && runningConfig.includeSubagents === false);
1002
+ /** Is this row parked waiting for the user (the amber state)? */
1003
+ const waitingForYou = (id) => {
1004
+ const row = lookup(id);
1005
+ return row !== null && typeof row === "object" && row.pendingInteraction !== null && row.pendingInteraction !== undefined;
1006
+ };
1007
+ /** The official running fact for a row, sidebar precedence, minus the wait state. */
1008
+ const isRunning = (id) => {
1009
+ if (waitingForYou(id)) return false;
1010
+ const row = lookup(id);
1011
+ const known = row !== null && typeof row === "object";
1012
+ const entry = byId[id];
1013
+ const running = known && typeof row.running === "boolean"
1014
+ ? row.running
1015
+ : entry !== null && typeof entry === "object" ? entry.running : undefined;
1016
+ return running === true;
1017
+ };
1018
+ /**
1019
+ * Up the `parentSessionId` chain to the topmost ancestor that is not itself a
1020
+ * sub-agent. A broken, cyclic or unknown chain yields "" (unattributable):
1021
+ * guessing an owner would light a session that has nothing to do with the
1022
+ * sub-agent.
1023
+ */
1024
+ const ownerOf = (id) => {
1025
+ let current = id;
1026
+ const walked = new Set([id]);
1027
+ for (let hops = 0; hops < 64; hops++) {
1028
+ const entry = byId[current];
1029
+ const parent = entry !== null && typeof entry === "object" && typeof entry.parentSessionId === "string" ? entry.parentSessionId : "";
1030
+ if (parent === "" || walked.has(parent)) return "";
1031
+ walked.add(parent);
1032
+ const next = byId[parent];
1033
+ if (next !== null && typeof next === "object" && next.origin === "subagent") {
1034
+ current = parent;
1035
+ continue;
1036
+ }
1037
+ if (next === null || typeof next !== "object") return "";
1038
+ return parent;
1039
+ }
1040
+ return "";
1041
+ };
1042
+ /** Ancestors are deduplicated: two sub-agents under one parent are one session. */
1043
+ const push = (id) => { if (!out.includes(id)) out.push(id); };
851
1044
  for (const id of order) {
852
1045
  if (typeof id !== "string" || id === "") continue;
853
1046
  const entry = byId[id];
854
1047
  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);
1048
+ if (entry.origin === "subagent") {
1049
+ if (!includeSubagents || !isRunning(id)) continue;
1050
+ const owner = ownerOf(id);
1051
+ // Only the ancestor's own wait state counts: the sub-agent has no row, so
1052
+ // it can never be the session the user is being asked about.
1053
+ if (owner !== "" && !waitingForYou(owner)) push(owner);
1054
+ continue;
1055
+ }
1056
+ if (!isRunning(id)) continue;
1057
+ push(id);
862
1058
  }
863
1059
  return out;
864
1060
  }
@@ -903,6 +1099,16 @@ window.__ModuleLoader__.load({
903
1099
  let realCount = 0;
904
1100
  /** The sessions behind `realCount`, for the sidebar list (newest first). */
905
1101
  let realItems = [];
1102
+ /**
1103
+ * Which sessions are showing an unread dot right now.
1104
+ *
1105
+ * Read back by `collectUnread` so a change of ending KIND cannot put a lit dot
1106
+ * out (see the reason filter there). Kept here rather than derived on the fly
1107
+ * because the projection only carries the LATEST ending: once a newer one lands
1108
+ * with a reason the user turned off, the one that lit the dot is no longer
1109
+ * visible anywhere.
1110
+ */
1111
+ let litUnreadIds = new Set();
906
1112
  /**
907
1113
  * How many sessions are running right now — the official `running` fact,
908
1114
  * already folded by `runningSessionIds`. It rides the SAME bridge as the
@@ -963,6 +1169,14 @@ window.__ModuleLoader__.load({
963
1169
  function emitRealBadge(value, items) {
964
1170
  realCount = normalizeCount(value);
965
1171
  realItems = Array.isArray(items) ? items : [];
1172
+ // Remember what is lit BEFORE the next round of folding can hide it: the
1173
+ // projection only carries the latest ending, so an ending whose reason is
1174
+ // turned off would otherwise erase the memory of the one that lit the dot.
1175
+ const lit = new Set();
1176
+ for (const item of realItems) {
1177
+ if (item !== null && typeof item === "object" && typeof item.id === "string" && item.id !== "") lit.add(item.id);
1178
+ }
1179
+ litUnreadIds = lit;
966
1180
  notifyBadge();
967
1181
  }
968
1182
  /**
@@ -1381,11 +1595,34 @@ window.__ModuleLoader__.load({
1381
1595
  // canvas and the merged bitmap is handed back to the browser. Count 0 (and
1382
1596
  // teardown) restores the pristine platform/custom href.
1383
1597
  const FAVICON_SIZE = 64;
1598
+ /**
1599
+ * How long one base-image fetch may take before the compose gives up on it.
1600
+ *
1601
+ * A fetch that neither loads nor errors (a stalled request, a proxy that never
1602
+ * answers) used to leave the compose pending forever — and silently, because no
1603
+ * error is ever raised — so the tab icon simply stopped following the numbers
1604
+ * with nothing in the console. This timeout turns that into a reported failure
1605
+ * the watchdog can retry.
1606
+ */
1607
+ const FAVICON_LOAD_TIMEOUT_MS = 8000;
1384
1608
  let faviconBase = null;
1385
1609
  let composeSeq = 0;
1386
1610
  let faviconComposing = false;
1387
1611
  /** Last compose failure, so a repeated failure warns once instead of spamming. */
1388
1612
  let lastComposeError = null;
1613
+ /**
1614
+ * The picture the tab is carrying right now: `未读|进行中|基图`.
1615
+ *
1616
+ * A `data:` href alone says nothing — an OLD bitmap and a bitmap whose compose
1617
+ * FAILED look exactly the same from the DOM. Remembering which picture was
1618
+ * actually written is what lets the watchdog tell "already right" from "stale
1619
+ * but still ours", which is the hole that used to freeze the tab icon.
1620
+ */
1621
+ let faviconDrawnSignature = "";
1622
+ /** The picture a compose is fetching right now, so a repeat cannot start a second fetch. */
1623
+ let faviconPendingSignature = "";
1624
+ /** Whether the last compose failed: the watchdog must retry instead of trusting the DOM. */
1625
+ let faviconFailed = false;
1389
1626
  /** Which icon the plugin considers current (filled from the status RPC). */
1390
1627
  let iconState = { active: false, rev: null, png: false, mime: null };
1391
1628
  /** The icon URL this page SHOULD be showing, per the plugin's own status. */
@@ -1399,6 +1636,24 @@ window.__ModuleLoader__.load({
1399
1636
  if (iconState.active !== true) return "image/svg+xml";
1400
1637
  return iconState.png === true ? "image/png" : (iconState.mime || "image/svg+xml");
1401
1638
  }
1639
+ /**
1640
+ * One string that names one picture: which numbers, on which base.
1641
+ *
1642
+ * It is the identity used both to remember what was drawn and to decide whether
1643
+ * a compose still has anything to do. The numbers are part of it on purpose —
1644
+ * "1 unread" and "2 unread" are different pictures even though both are `data:`.
1645
+ * @param count - the unread number this picture carries.
1646
+ * @param runningCount - the running number this picture carries.
1647
+ * @param base - the base icon href the numbers ride on.
1648
+ * @returns the signature string.
1649
+ */
1650
+ function faviconSignature(count, runningCount, base) {
1651
+ return normalizeCount(count) + "|" + normalizeCount(runningCount) + "|" + base;
1652
+ }
1653
+ /** The picture this page SHOULD be carrying right now, from live state. */
1654
+ function wantedFaviconSignature() {
1655
+ return faviconSignature(effectiveCount(), runningTotal, desiredIconHref());
1656
+ }
1402
1657
  /**
1403
1658
  * Every `<link rel="icon">` the document carries, creating one if the page
1404
1659
  * has none.
@@ -1545,36 +1800,64 @@ window.__ModuleLoader__.load({
1545
1800
  c2d.fillText(text, cx, cy + size * 0.01);
1546
1801
  c2d.restore();
1547
1802
  }
1803
+ /**
1804
+ * Load one image for the composite, with a deadline.
1805
+ *
1806
+ * Settles exactly once, whatever happens: a late `onload` after the timeout must
1807
+ * not draw on top of the picture that replaced it.
1808
+ * @param href - absolute URL of the base icon.
1809
+ * @returns the loaded image, or a rejection whose message says why.
1810
+ */
1548
1811
  function loadIconImage(href) {
1549
1812
  return new Promise((resolve, reject) => {
1550
1813
  const img = new Image();
1551
- img.onload = () => resolve(img);
1552
- img.onerror = () => reject(new Error("icon-load"));
1814
+ let done = false;
1815
+ const settle = (fn, value) => {
1816
+ if (done) return;
1817
+ done = true;
1818
+ window.clearTimeout(timer);
1819
+ fn(value);
1820
+ };
1821
+ const timer = window.setTimeout(() => { settle(reject, new Error("icon-load-timeout")); }, FAVICON_LOAD_TIMEOUT_MS);
1822
+ img.onload = () => { settle(resolve, img); };
1823
+ img.onerror = () => { settle(reject, new Error("icon-load")); };
1553
1824
  img.src = href;
1554
1825
  });
1555
1826
  }
1556
1827
  async function applyBadgeToFavicon(count, size) {
1557
- const base = desiredIconHref();
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) {
1562
- // No badge to draw. With a custom icon active the page must carry it on
1563
- // EVERY link (a stale scheme-specific sibling outranks the one we set);
1564
- // with none, the shipped dark/light pair goes back untouched — the
1565
- // plugin promises zero side effects while it has no icon of its own.
1566
- if (iconState.active === true) setIconEverywhere(base, desiredIconType());
1567
- else restoreShippedIcons();
1568
- faviconBase = null;
1569
- return;
1570
- }
1571
- // The base is resolved from the plugin's status, never from whatever the
1572
- // page happens to carry, and never from our own previous bitmap.
1573
- faviconBase = { href: base, type: desiredIconType() };
1828
+ // ONE snapshot decides this whole compose: which marks to draw AND with which
1829
+ // numbers. `runningTotal` used to be read again after the await, so a run that
1830
+ // stopped mid-fetch was painted as a yellow "0", and the two numbers could
1831
+ // come from two different moments.
1832
+ const wantedCount = normalizeCount(count);
1833
+ const runningCount = normalizeCount(runningTotal);
1834
+ const signature = faviconSignature(wantedCount, runningCount, desiredIconHref());
1835
+ // The sequence is taken BEFORE the "nothing to draw" branch: restoring the
1836
+ // plain icon has to invalidate any compose still in flight, or that older
1837
+ // bitmap lands afterwards and paints the number we just cleared back onto
1838
+ // the tab. (That is exactly the "stale dot that will not go away" report.)
1574
1839
  const seq = ++composeSeq;
1575
1840
  faviconComposing = true;
1841
+ faviconPendingSignature = signature;
1842
+ faviconFailed = false;
1576
1843
  try {
1577
- const img = await loadIconImage(new URL(faviconBase.href, document.baseURI).href);
1844
+ if (wantedCount <= 0 && runningCount <= 0) {
1845
+ // No badge to draw. With a custom icon active the page must carry it on
1846
+ // EVERY link (a stale scheme-specific sibling outranks the one we set);
1847
+ // with none, the shipped dark/light pair goes back untouched — the
1848
+ // plugin promises zero side effects while it has no icon of its own.
1849
+ if (seq !== composeSeq) return;
1850
+ if (iconState.active === true) setIconEverywhere(desiredIconHref(), desiredIconType());
1851
+ else restoreShippedIcons();
1852
+ faviconBase = null;
1853
+ faviconDrawnSignature = signature;
1854
+ return;
1855
+ }
1856
+ // The base is resolved from the plugin's status, never from whatever the
1857
+ // page happens to carry, and never from our own previous bitmap.
1858
+ const base = desiredIconHref();
1859
+ faviconBase = { href: base, type: desiredIconType() };
1860
+ const img = await loadIconImage(new URL(base, document.baseURI).href);
1578
1861
  if (seq !== composeSeq) return;
1579
1862
  const canvas = document.createElement("canvas");
1580
1863
  canvas.width = FAVICON_SIZE;
@@ -1590,16 +1873,22 @@ window.__ModuleLoader__.load({
1590
1873
  // together to the largest pair that fits, so neither reads as a bug and
1591
1874
  // the two can never overlap.
1592
1875
  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");
1876
+ const badgeK = wantedCount > 0 && runningCount > 0 ? badgeUnit * faviconBadgeFitScale(badgeUnit) : badgeUnit;
1877
+ if (wantedCount > 0) drawFaviconBadge(c2d, FAVICON_SIZE, wantedCount, badgeK);
1878
+ if (runningCount > 0) drawFaviconRunningBadge(c2d, FAVICON_SIZE, runningCount, badgeK);
1879
+ setIconEverywhere(canvas.toDataURL("image/png"), "image/png");
1880
+ faviconDrawnSignature = signature;
1596
1881
  } catch (error) {
1597
1882
  // Never silent: a failure here is invisible in the UI (the tab simply
1598
1883
  // keeps its previous icon), which once cost a whole debugging session.
1599
1884
  // Warn once per distinct reason so the 5s watchdog cannot spam.
1885
+ faviconFailed = true;
1600
1886
  warnFaviconCompose(error);
1601
1887
  } finally {
1602
- if (seq === composeSeq) faviconComposing = false;
1888
+ if (seq === composeSeq) {
1889
+ faviconComposing = false;
1890
+ faviconPendingSignature = "";
1891
+ }
1603
1892
  }
1604
1893
  }
1605
1894
  /**
@@ -1623,10 +1912,22 @@ window.__ModuleLoader__.load({
1623
1912
  * rejection, and the tab icon would simply never change with nothing in the
1624
1913
  * console — the exact "no warning, no badge" state. Every call site goes through
1625
1914
  * here instead.
1915
+ *
1916
+ * It is also the ONE place that decides whether there is anything to do: this is
1917
+ * called on every badge notification, and a badge notification happens on every
1918
+ * render. Without the signature guard each of those started its own base-image
1919
+ * fetch, and on a page whose base image is slow (a reverse proxy, a cold cache,
1920
+ * a laptop waking up) every fetch was superseded before it landed — the tab icon
1921
+ * fell further behind the numbers with every repaint instead of catching up.
1626
1922
  * @param count - sessions needing you; 0 restores the plain icon.
1627
1923
  * @param size - badge size key.
1924
+ * @param force - recompose even when this exact picture was already drawn (the
1925
+ * page was disturbed, the base icon changed, the last compose failed).
1628
1926
  */
1629
- function composeFavicon(count, size) {
1927
+ function composeFavicon(count, size, force) {
1928
+ const signature = faviconSignature(count, runningTotal, desiredIconHref());
1929
+ if (signature === faviconPendingSignature) return; // this very picture is being fetched
1930
+ if (force !== true && signature === faviconDrawnSignature && faviconFailed !== true) return; // already on the tab
1630
1931
  try {
1631
1932
  Promise.resolve(applyBadgeToFavicon(count, size)).catch(warnFaviconCompose);
1632
1933
  } catch (error) {
@@ -1641,6 +1942,12 @@ window.__ModuleLoader__.load({
1641
1942
  * icon link — a cached page, a browser extension, a tab move, or a plugin
1642
1943
  * reload. Our own output is a `data:` URL, so any other href means the
1643
1944
  * composite was replaced and has to be rebuilt from the new base.
1945
+ *
1946
+ * "Still ours" is not the same as "still right": a `data:` href can carry an
1947
+ * older number, or one whose compose died halfway. Comparing the picture that
1948
+ * was actually written against the one that is wanted now is what lets this
1949
+ * watchdog repair those — and what makes a failed compose a retry instead of a
1950
+ * tab icon that is frozen for the rest of the page's life.
1644
1951
  */
1645
1952
  function reconcileFavicon() {
1646
1953
  // Inspect EVERY icon link: 0.2.x ships two (dark/light), and a sibling
@@ -1648,21 +1955,24 @@ window.__ModuleLoader__.load({
1648
1955
  // stale sibling is exactly what used to outrank the icon we set.
1649
1956
  const hrefs = iconLinks().map((candidate) => candidate.getAttribute("href"));
1650
1957
  const ours = hrefs.length > 0 && hrefs.every((value) => typeof value === "string" && value.slice(0, 5) === "data:");
1651
- if (effectiveCount() <= 0 && runningTotal <= 0) {
1958
+ const wanted = wantedFaviconSignature();
1959
+ if (ours && faviconDrawnSignature === wanted && faviconFailed !== true) return; // exactly right, right now
1960
+ const count = effectiveCount();
1961
+ if (count <= 0 && normalizeCount(runningTotal) <= 0) {
1652
1962
  // Carries the custom icon, or puts the shipped pair back. Both writers
1653
1963
  // only touch a link that actually differs, so this stays a no-op once
1654
1964
  // the page is correct and cannot re-arm the head observer.
1655
- composeFavicon(0);
1965
+ composeFavicon(0, badgeSize, ours !== true);
1656
1966
  return;
1657
1967
  }
1658
- if (ours || faviconComposing) return; // already showing, or a build is running
1659
- composeFavicon(effectiveCount(), badgeSize);
1968
+ if (faviconComposing) return; // a build is already running
1969
+ composeFavicon(count, badgeSize, ours !== true);
1660
1970
  }
1661
1971
  // A freshly applied base icon replaces whatever the browser holds, so the
1662
1972
  // composite must be rebuilt from the NEW base instead of the stale one.
1663
1973
  function refreshFaviconBase() {
1664
1974
  faviconBase = null;
1665
- composeFavicon(effectiveCount(), badgeSize);
1975
+ composeFavicon(effectiveCount(), badgeSize, true);
1666
1976
  }
1667
1977
  //#endregion
1668
1978
 
@@ -2507,6 +2817,274 @@ window.__ModuleLoader__.load({
2507
2817
  }
2508
2818
  //#endregion
2509
2819
 
2820
+ //#region header ring
2821
+ /**
2822
+ * One yellow ring on the conversation header's sub-agent count.
2823
+ *
2824
+ * The official "N sub-agents" row is a `button[aria-haspopup="tree"]` inside
2825
+ * the `conversation.session.header.actions` slot, and its count is the LAST
2826
+ * `<span>` child of that button. The ring is stacked on that span's top-right
2827
+ * corner — the same corner logic as the workspace folder's yellow dot — and it
2828
+ * is display only: not clickable, `pointer-events:none`, `aria-hidden` because
2829
+ * the official button's own label already announces the running count.
2830
+ *
2831
+ * The number is the official `subagentCatalog` projection of the session on
2832
+ * screen, counting only DIRECT children that are running. It never joins any
2833
+ * other count this plugin draws (sidebar dots, the top-left number, the tab
2834
+ * icon, the OS badge), so this switch can never move one of them.
2835
+ *
2836
+ * Class names are build hashes and are never used; the anchor is found by the
2837
+ * official `data-slot` plus structure/role only. Like the sidebar marks this
2838
+ * half is injected into shipped DOM, reconciled instead of redrawn, quiet when
2839
+ * it cannot find the anchor, and self-disabling after repeated failures.
2840
+ */
2841
+ const HEAD_RING_ATTR = "data-icon-custom-headring";
2842
+ /** Marks the count element whose inline `position` we changed, for restore. */
2843
+ const HEAD_RING_HOST_ATTR = "data-icon-custom-headring-host";
2844
+ /** Breaker ceiling: a changed header structure must not warn forever. */
2845
+ const HEAD_RING_MAX_FAILURES = 20;
2846
+ /** The number for the session on screen; -1 means "no answer, draw nothing". */
2847
+ let headerRingValue = -1;
2848
+ let headerRingFailures = 0;
2849
+ let headerRingGivenUp = false;
2850
+ let headerRingHost = null;
2851
+ let headerRingHostPosition = "";
2852
+ let headerRingObserved = null;
2853
+ let headerRingObserver = null;
2854
+ /** The session service lookup, filled in by `apply` (may stay null). */
2855
+ let headerRingSessions = null;
2856
+ /** Throttle for the "catalog projection not loaded yet" refresh ask. */
2857
+ let headerRingRefreshSession = "";
2858
+ let headerRingRefreshAt = 0;
2859
+
2860
+ /**
2861
+ * How many DIRECT children of `sessionId` are running, from the official
2862
+ * `subagentCatalog` projection.
2863
+ *
2864
+ * Grandchildren are not part of a parent's catalog, so they are excluded by
2865
+ * construction. `status` (the official session-status map) wins over the list
2866
+ * row exactly the way the shipped sidebar reads it. An unloaded projection is
2867
+ * 0, never a guess.
2868
+ * @param list - the session list snapshot (may be absent).
2869
+ * @param status - the official session-status map (may be absent).
2870
+ * @param sessionId - the session whose header is on screen.
2871
+ * @returns the count of running direct children.
2872
+ */
2873
+ function headerRingCount(list, status, sessionId) {
2874
+ if (typeof sessionId !== "string" || sessionId === "") return 0;
2875
+ if (list === null || typeof list !== "object") return 0;
2876
+ const projections = list.projectionsBySession;
2877
+ if (projections === null || typeof projections !== "object") return 0;
2878
+ const snapshot = projections[sessionId];
2879
+ const values = snapshot !== null && typeof snapshot === "object" ? snapshot.values : undefined;
2880
+ const catalog = values !== null && typeof values === "object" ? values.subagentCatalog : undefined;
2881
+ if (!Array.isArray(catalog)) return 0;
2882
+ const byId = list.byId !== null && typeof list.byId === "object" ? list.byId : {};
2883
+ const lookup = status !== null && status !== undefined && typeof status.get === "function" ? (id) => status.get(id) : () => undefined;
2884
+ let count = 0;
2885
+ for (const entry of catalog) {
2886
+ if (entry === null || typeof entry !== "object" || typeof entry.id !== "string" || entry.id === "") continue;
2887
+ const row = lookup(entry.id);
2888
+ const known = row !== null && typeof row === "object" && typeof row.running === "boolean";
2889
+ const fallback = byId[entry.id];
2890
+ const running = known ? row.running : fallback !== null && typeof fallback === "object" ? fallback.running : undefined;
2891
+ if (running === true) count++;
2892
+ }
2893
+ return count;
2894
+ }
2895
+
2896
+ /**
2897
+ * Whether that session's catalog projection is loaded at all. `undefined`
2898
+ * means "not asked yet" — the one case worth nudging the host about (see
2899
+ * `requestHeaderRingRefresh`); an empty array is a real answer.
2900
+ */
2901
+ function headerRingCatalogLoaded(list, sessionId) {
2902
+ if (list === null || typeof list !== "object" || typeof sessionId !== "string" || sessionId === "") return false;
2903
+ const projections = list.projectionsBySession;
2904
+ const snapshot = projections !== null && typeof projections === "object" ? projections[sessionId] : undefined;
2905
+ const values = snapshot !== null && typeof snapshot === "object" ? snapshot.values : undefined;
2906
+ return values !== null && typeof values === "object" && Array.isArray(values.subagentCatalog);
2907
+ }
2908
+
2909
+ /**
2910
+ * Ask the host to fold that session's projections, at most once per 10 s. The
2911
+ * service lookup is optional (`apply` fills it in): without it the next render
2912
+ * simply tries again, which is the documented fallback.
2913
+ */
2914
+ function requestHeaderRingRefresh(sessionId) {
2915
+ const now = Date.now();
2916
+ if (sessionId === headerRingRefreshSession && now - headerRingRefreshAt < 10000) return;
2917
+ headerRingRefreshSession = sessionId;
2918
+ headerRingRefreshAt = now;
2919
+ try {
2920
+ const service = typeof headerRingSessions === "function" ? headerRingSessions() : null;
2921
+ const refresh = service !== null && service !== undefined ? service.refreshProjections : undefined;
2922
+ if (typeof refresh !== "function") return;
2923
+ const result = refresh.call(service, sessionId);
2924
+ if (result !== null && typeof result === "object" && typeof result.catch === "function") result.catch(() => {});
2925
+ } catch { /* best effort: the next render is the fallback */ }
2926
+ }
2927
+
2928
+ /** The rendered action button for the session on screen, or null. */
2929
+ function headerRingButton() {
2930
+ try {
2931
+ const slots = document.querySelectorAll('[data-slot="conversation.session.header.actions"]');
2932
+ if (slots === null || slots === undefined) return null;
2933
+ for (const slot of slots) {
2934
+ // The right sidebar keeps an embedded conversation MOUNTED while
2935
+ // collapsed (hidden by CSS and `inert`), so its header actions must
2936
+ // never be the anchor for the visible conversation's ring. Same guard
2937
+ // as `conversationOnScreen` uses for the same duplicate.
2938
+ if (typeof slot.closest === "function" && (slot.closest("[data-sidebar-right-panel]") !== null || slot.closest("[inert]") !== null)) continue;
2939
+ if (typeof slot.querySelectorAll !== "function") continue;
2940
+ for (const button of slot.querySelectorAll("button")) {
2941
+ if (button.getAttribute("aria-haspopup") === "tree") return button;
2942
+ }
2943
+ }
2944
+ return null;
2945
+ } catch { return null; }
2946
+ }
2947
+
2948
+ /** The count element: the LAST `<span>` direct child of that button. */
2949
+ function headerRingCountElement(button) {
2950
+ let found = null;
2951
+ if (button === null || button === undefined || button.children === undefined) return null;
2952
+ for (const child of button.children) {
2953
+ if (child === null || child === undefined || child.tagName !== "SPAN") continue;
2954
+ if (child.getAttribute(HEAD_RING_ATTR) !== null) continue;
2955
+ found = child;
2956
+ }
2957
+ return found;
2958
+ }
2959
+
2960
+ /**
2961
+ * Whether the decoration must stay off this conversation. A sub-agent's own
2962
+ * session renders no header row at all, so it must render no ring either.
2963
+ */
2964
+ function headerRingSuppressed(list, sessionId) {
2965
+ if (typeof sessionId !== "string" || sessionId === "") return true;
2966
+ if (list === null || typeof list !== "object") return true;
2967
+ const byId = list.byId !== null && typeof list.byId === "object" ? list.byId : {};
2968
+ const entry = byId[sessionId];
2969
+ // No row means the page cannot prove this is not a sub-agent's own
2970
+ // conversation: suppress rather than draw on a page we cannot read.
2971
+ if (entry === null || typeof entry !== "object") return true;
2972
+ return entry.origin === "subagent";
2973
+ }
2974
+
2975
+ /**
2976
+ * The effective number to draw. `-1` (no answer) and `0` both mean "draw
2977
+ * nothing"; the switch is the only other input. Pure, so the switch and the
2978
+ * zero case are testable without a DOM.
2979
+ */
2980
+ function headerRingVisibleCount(config, count) {
2981
+ const enabled = !(config !== null && typeof config === "object" && config.headerRing === false);
2982
+ if (!enabled) return -1;
2983
+ const value = Number(count);
2984
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
2985
+ }
2986
+
2987
+ /** Remove the ring and put the count element's own `position` back. */
2988
+ function clearHeaderRing() {
2989
+ try { document.querySelectorAll("[" + HEAD_RING_ATTR + "]").forEach((node) => node.remove()); } catch {}
2990
+ if (headerRingHost !== null) {
2991
+ try { headerRingHost.style.position = headerRingHostPosition; } catch {}
2992
+ try { headerRingHost.removeAttribute(HEAD_RING_HOST_ATTR); } catch {}
2993
+ }
2994
+ headerRingHost = null;
2995
+ headerRingHostPosition = "";
2996
+ if (headerRingObserver !== null) {
2997
+ try { headerRingObserver.disconnect(); } catch {}
2998
+ headerRingObserver = null;
2999
+ }
3000
+ headerRingObserved = null;
3001
+ }
3002
+
3003
+ /** Watch the header slot, so a re-render that dropped the ring gets it back. */
3004
+ function observeHeaderRingAnchor(button) {
3005
+ if (typeof window === "undefined" || window === null || typeof window.MutationObserver !== "function") return;
3006
+ const slot = button !== null && typeof button.closest === "function" ? button.closest('[data-slot="conversation.session.header.actions"]') : null;
3007
+ if (slot === null || slot === undefined || slot === headerRingObserved) return;
3008
+ try {
3009
+ if (headerRingObserver !== null) headerRingObserver.disconnect();
3010
+ headerRingObserver = new window.MutationObserver(() => { repaintHeaderRing(); });
3011
+ headerRingObserver.observe(slot, { childList: true, subtree: true, characterData: true });
3012
+ headerRingObserved = slot;
3013
+ } catch {
3014
+ headerRingObserver = null;
3015
+ headerRingObserved = null;
3016
+ }
3017
+ }
3018
+
3019
+ /** Re-run the current value; used by the observer, which has no arguments. */
3020
+ function repaintHeaderRing() {
3021
+ paintHeaderRing(runningConfig, headerRingValue);
3022
+ }
3023
+
3024
+ /** Warn once, then give up for this page — the sidebar marks' exact posture. */
3025
+ function noteHeaderRingFailure(why) {
3026
+ headerRingFailures++;
3027
+ if (headerRingFailures === 1) {
3028
+ try { console.warn("dsh-icon-custom: 会话头部黄圈定位失败,已降级(不影响其他功能):", why); } catch {}
3029
+ return;
3030
+ }
3031
+ if (headerRingFailures < HEAD_RING_MAX_FAILURES || headerRingGivenUp) return;
3032
+ headerRingGivenUp = true;
3033
+ try { console.warn("dsh-icon-custom: 会话头部黄圈连续失败,本次会话内已自动关闭。"); } catch {}
3034
+ clearHeaderRing();
3035
+ }
3036
+
3037
+ /**
3038
+ * Reconcile the ring with the current number. Idempotent (writes only what
3039
+ * differs) and quiet when the anchor is not on this page (a sub-agent's own
3040
+ * conversation, the landing view, a boot).
3041
+ * @param config - the running config, for the `headerRing` switch.
3042
+ * @param count - the ring's number, from `headerRingCount`; -1 hides it.
3043
+ */
3044
+ function paintHeaderRing(config, count) {
3045
+ if (headerRingGivenUp) return;
3046
+ const value = headerRingVisibleCount(config, count);
3047
+ if (value <= 0) { clearHeaderRing(); return; }
3048
+ const button = headerRingButton();
3049
+ // No anchor is not a failure: child sessions render no header row, and the
3050
+ // page may simply be booting. Leave whatever is there alone and wait.
3051
+ if (button === null) { clearHeaderRing(); return; }
3052
+ const anchor = headerRingCountElement(button);
3053
+ if (anchor === null) { noteHeaderRingFailure("找不到计数元素"); return; }
3054
+ try {
3055
+ if (headerRingHost !== anchor) {
3056
+ clearHeaderRing();
3057
+ headerRingHost = anchor;
3058
+ headerRingHostPosition = anchor.style.position;
3059
+ anchor.setAttribute(HEAD_RING_HOST_ATTR, "1");
3060
+ if (window.getComputedStyle(anchor).position === "static") anchor.style.position = "relative";
3061
+ }
3062
+ observeHeaderRingAnchor(button);
3063
+ let node = anchor.querySelector("[" + HEAD_RING_ATTR + "]");
3064
+ if (node === null) {
3065
+ node = document.createElement("span");
3066
+ node.setAttribute(HEAD_RING_ATTR, "1");
3067
+ // Not a control and never announced: the official button's own label
3068
+ // already speaks the running count.
3069
+ node.setAttribute("aria-hidden", "true");
3070
+ // Looks like the sidebar workspace's red count: a rounded-square pill (never a
3071
+ // full circle), with a dark number on the running yellow.
3072
+ node.style.cssText = "position:absolute;pointer-events:none;top:-6px;right:-5px;display:inline-flex;"
3073
+ + "align-items:center;justify-content:center;min-width:16px;height:16px;padding:0 4px;box-sizing:border-box;"
3074
+ + "border-radius:6px;background:" + RUNNING_YELLOW + ";color:#1f2328;font:inherit;font-size:11px;"
3075
+ + "font-weight:600;line-height:16px;font-variant-numeric:tabular-nums;z-index:3;"
3076
+ + "box-shadow:0 0 0 1.5px var(--dsw-alias-bg-layer-1,#fff)";
3077
+ anchor.appendChild(node);
3078
+ }
3079
+ const text = badgeLabel(value);
3080
+ if (node.textContent !== text) node.textContent = text;
3081
+ headerRingFailures = 0;
3082
+ } catch (error) {
3083
+ noteHeaderRingFailure(String((error && error.message) || error));
3084
+ }
3085
+ }
3086
+ //#endregion
3087
+
2510
3088
  //#region components
2511
3089
  /**
2512
3090
  * Headless occupant of the frame-wide `shell.overlay` slot. It reads DSH's
@@ -2575,8 +3153,19 @@ window.__ModuleLoader__.load({
2575
3153
  }, []);
2576
3154
  /** Looking at the conversation right now: on screen, and this tab in front. */
2577
3155
  const watching = visible && panelActive !== true;
2578
- const currentIds = currentSessionIds(list, { displayed: displayedSessionId() });
2579
- const items = collectUnread(list, pending, config, seenState.seen);
3156
+ // Read the screen once per render: which session the visible conversation
3157
+ // renders (`displayed`), whether it is up but showing none (`idle` — the
3158
+ // landing view, a real "you are not in a session"), and whether it is not up
3159
+ // at all (neither ⇒ `known: false`, and the entered-at snapshots stay).
3160
+ const onScreen = conversationOnScreen();
3161
+ const viewed = viewedSessions(list, {
3162
+ displayed: onScreen.id,
3163
+ official: officialSessionId(),
3164
+ idle: onScreen.rendered === true && onScreen.id === undefined
3165
+ });
3166
+ const currentIds = viewed.ids;
3167
+ const stayKnown = viewed.known;
3168
+ const items = collectUnread(list, pending, config, seenState.seen, litUnreadIds);
2580
3169
  /**
2581
3170
  * Map session → its Workspace from the official snapshot: the ID anchors the
2582
3171
  * folder dot to the keyed row, the title is what the row displays (not always
@@ -2650,9 +3239,17 @@ window.__ModuleLoader__.load({
2650
3239
  const stayArmed = React.useRef(new Map());
2651
3240
  React.useEffect(() => {
2652
3241
  const snapshot = stayLive.current.list !== null && typeof stayLive.current.list === "object" && stayLive.current.list.byId !== null && typeof stayLive.current.list.byId === "object" ? stayLive.current.list.byId : {};
2653
- const live = new Set(stayIds);
2654
- for (const id of Array.from(stayArmed.current.keys())) {
2655
- if (!live.has(id)) stayArmed.current.delete(id);
3242
+ // ONLY A KNOWN ANSWER MAY DROP A SNAPSHOT. A frame in which the page could
3243
+ // not name any session (a panel unmounting the conversation while the
3244
+ // official binding is unavailable, a DOM gap, a build without either
3245
+ // signal) is not a leave: forgetting the snapshot there is what made the
3246
+ // clock re-enter the session behind the reader's back and acknowledge an
3247
+ // ending that arrived while they were sitting in front of it.
3248
+ if (stayKnown !== false) {
3249
+ const live = new Set(stayIds);
3250
+ for (const id of Array.from(stayArmed.current.keys())) {
3251
+ if (!live.has(id)) stayArmed.current.delete(id);
3252
+ }
2656
3253
  }
2657
3254
  for (const id of stayIds) {
2658
3255
  if (!stayArmed.current.has(id)) stayArmed.current.set(id, lastTurnEndAt(snapshot[id]));
@@ -2681,7 +3278,7 @@ window.__ModuleLoader__.load({
2681
3278
  cancelled = true;
2682
3279
  if (timer !== 0) window.clearTimeout(timer);
2683
3280
  };
2684
- }, [staySignature, delayMs, watching]);
3281
+ }, [staySignature, delayMs, watching, stayKnown]);
2685
3282
  React.useEffect(() => { emitRealBadge(count, itemsWithOwner); }, [count, itemsWithOwner]);
2686
3283
  /**
2687
3284
  * One session's displayed title, the way the sidebar shows it.
@@ -2708,6 +3305,21 @@ window.__ModuleLoader__.load({
2708
3305
  const runningRowSignature = runningItemsSignature(runningRows);
2709
3306
  const runningCount = runningIds.length;
2710
3307
  React.useEffect(() => { emitRunningState(runningCount, runningRows); }, [runningRowSignature]);
3308
+ // The conversation header's yellow ring: the session on screen, and ONLY
3309
+ // its direct children from the official `subagentCatalog`. A separate
3310
+ // number for a separate decoration — it never joins `runningIds`, so no
3311
+ // sidebar, tab or OS-badge count can move because of it.
3312
+ const ringSessionId = viewed.ids.length === 1 ? viewed.ids[0] : "";
3313
+ const ringSuppressed = headerRingSuppressed(list, ringSessionId);
3314
+ const ringCount = ringSuppressed ? -1 : headerRingCount(list, status, ringSessionId);
3315
+ const ringSignature = ringSessionId + "\u0000" + ringCount;
3316
+ React.useEffect(() => {
3317
+ headerRingValue = ringCount;
3318
+ // Not loaded yet is not "zero running": ask the host once, then let the
3319
+ // next render answer. Without a session service this is a quiet no-op.
3320
+ if (!ringSuppressed && !headerRingCatalogLoaded(list, ringSessionId)) requestHeaderRingRefresh(ringSessionId);
3321
+ paintHeaderRing(runningConfig, headerRingValue);
3322
+ }, [ringSignature, running.enabled, running.headerRing]);
2711
3323
  // The session rows, for naming the running ones the way the sidebar shows
2712
3324
  // them (`displayTitle`, the same fallback the red dots use).
2713
3325
  React.useEffect(() => {
@@ -3285,6 +3897,229 @@ window.__ModuleLoader__.load({
3285
3897
  }, children);
3286
3898
  }
3287
3899
 
3900
+ //#region hint disclosure
3901
+ /**
3902
+ * Every long hint used to be printed in full on the settings page, which made
3903
+ * one screen a wall of grey small print. Now each one keeps a one-line
3904
+ * summary on the page and moves the full text behind a "?" button.
3905
+ *
3906
+ * The bubble's material is borrowed, its behaviour is ours:
3907
+ * - the official `MenuSurface` primitive as the bubble container, when this
3908
+ * build exposes it to plugins;
3909
+ * - a self-drawn bubble carrying the same inline styling otherwise.
3910
+ * Opening and closing (click the "?", click it again, click away, Escape) is
3911
+ * the plugin's own code on BOTH paths, and placement stays in the pure
3912
+ * `hintBubblePlacement` below — `MenuSurface` only paints the surface, it
3913
+ * does not position or dismiss anything.
3914
+ *
3915
+ * Why not the official `Tooltip` (which is what this used to use): its
3916
+ * `label` is `string | (() => string)`
3917
+ * (`@deepseek-ai/dsh-client-ui-primitives/lib/types/Tooltip.d.ts:20`), so it
3918
+ * can only print plain text and can never render the emphasised phrases the
3919
+ * requirement asks for. `MenuSurface` takes arbitrary children
3920
+ * (`ComponentPropsWithoutRef<'div'>`), so the same self-managed disclosure
3921
+ * can render real React nodes — hence `**bold**` becomes a real `<strong>`.
3922
+ * See docs/adr/0012-hint-bubble-menusurface.md.
3923
+ *
3924
+ * The client half is loaded through the ModuleLoader's `require` with no build
3925
+ * step, so that package can be absent or not yet materialized — a `require`
3926
+ * miss must degrade to the fallback, never to a blank section.
3927
+ * Note: this package is resolved by the host at require time and is therefore
3928
+ * deliberately left out of `dsh.client.inject` and `peerDependencies`
3929
+ * (matching the official packages), so no injection gate can block mount.
3930
+ */
3931
+ const HINT_BUBBLE_WIDTH = 320;
3932
+ const HINT_BUBBLE_MARGIN = 8;
3933
+ const HINT_BUBBLE_GAP = 6;
3934
+ const HINT_ROW_STYLE = { display: "flex", alignItems: "flex-start", gap: "6px", marginTop: "4px" };
3935
+ const HINT_SUMMARY_STYLE = { fontSize: 11, color: "var(--dsw-alias-label-tertiary)", lineHeight: "16px" };
3936
+ const HINT_BUTTON_STYLE = { flex: "none", width: "16px", height: "16px", padding: 0, borderRadius: "50%", border: "1px solid var(--dsw-alias-border-l2)", background: "transparent", color: "var(--dsw-alias-label-tertiary)", fontSize: 11, lineHeight: "14px", textAlign: "center", cursor: "pointer" };
3937
+ /** The emphasised run: a real `<strong>`, so `**` never reaches the screen. */
3938
+ const HINT_STRONG_STYLE = { fontWeight: 600, color: "var(--dsw-alias-label-primary)" };
3939
+ /** The fallback bubble carries its own material; the official surface paints its own. */
3940
+ const HINT_BUBBLE_STYLE = { position: "fixed", zIndex: 70, padding: "8px 10px", borderRadius: "10px", border: "1px solid var(--dsw-alias-border-l2)", background: "var(--dsw-alias-bg-layer-1)", boxShadow: "0 10px 30px rgba(0,0,0,.18)", color: "var(--dsw-alias-label-primary)", fontSize: 12, lineHeight: "18px", whiteSpace: "pre-wrap", maxHeight: "40vh", overflowY: "auto" };
3941
+ /** Placement-only style for the official surface: no fill/border/radius of ours. */
3942
+ const HINT_BUBBLE_PLACEMENT_STYLE = { position: "fixed", zIndex: 70, padding: "8px 10px", color: "var(--dsw-alias-label-primary)", fontSize: 12, lineHeight: "18px", whiteSpace: "pre-wrap", maxHeight: "40vh", overflowY: "auto" };
3943
+
3944
+ /**
3945
+ * Resolve the official MenuSurface lazily: requiring it at factory time can
3946
+ * run before the primitive is materialized, and a miss throws (the loader's
3947
+ * module table has no fallback entry). Success is memoized; a miss is retried
3948
+ * on the next render, so a plugin that boots before the primitive still picks
3949
+ * it up instead of being stuck on the fallback forever.
3950
+ * @returns the MenuSurface component, or null when this build does not expose it.
3951
+ */
3952
+ let officialMenuSurface = null;
3953
+ function officialHintSurface() {
3954
+ if (officialMenuSurface !== null) return officialMenuSurface;
3955
+ try {
3956
+ const primitives = require("@deepseek-ai/dsh-client-ui-primitives");
3957
+ // "Present" is the whole judgement, not "is a function": the shipped
3958
+ // `MenuSurface` is a `forwardRef` object (`lib/index.js` builds it with
3959
+ // `forwardRef(...)`, and its type is `ForwardRefExoticComponent`), so
3960
+ // `typeof` is "object". The old `typeof === "function"` test therefore
3961
+ // never matched in production and left the official branch dead. A build
3962
+ // that exposes it as a plain function still works.
3963
+ const candidate = primitives !== null && typeof primitives === "object" ? primitives.MenuSurface : undefined;
3964
+ if (candidate !== null && candidate !== undefined
3965
+ && (typeof candidate === "function" || typeof candidate === "object")) {
3966
+ officialMenuSurface = candidate;
3967
+ }
3968
+ } catch (error) {
3969
+ officialMenuSurface = null;
3970
+ }
3971
+ return officialMenuSurface;
3972
+ }
3973
+
3974
+ /**
3975
+ * Turn one hint string into React children, converting every BALANCED
3976
+ * `**pair**` into a real `<strong>`.
3977
+ *
3978
+ * Only a closed pair is markup: an unpaired `**` (or a lone marker) is kept
3979
+ * verbatim, because dropping the characters would silently lose text — worse
3980
+ * than showing the marker. The marker itself never reaches the screen for a
3981
+ * balanced pair: what lands in the DOM is `<strong>pair</strong>`.
3982
+ * @param text - the locale string, possibly carrying `**` runs.
3983
+ * @returns an array of strings and `<strong>` elements, never empty.
3984
+ */
3985
+ function hintTextNodes(text) {
3986
+ const source = typeof text === "string" ? text : "";
3987
+ const nodes = [];
3988
+ const bold = /\*\*([\s\S]+?)\*\*/g;
3989
+ let last = 0;
3990
+ let match;
3991
+ while ((match = bold.exec(source)) !== null) {
3992
+ if (match.index > last) nodes.push(source.slice(last, match.index));
3993
+ nodes.push(React.createElement("strong", { key: "hint-bold-" + nodes.length, style: HINT_STRONG_STYLE }, match[1]));
3994
+ last = match.index + match[0].length;
3995
+ }
3996
+ if (last < source.length) nodes.push(source.slice(last));
3997
+ if (nodes.length === 0) nodes.push(source);
3998
+ return nodes;
3999
+ }
4000
+
4001
+ /**
4002
+ * Where the fallback bubble goes: just below the button's anchor rect, clamped
4003
+ * into the viewport. Pure, so the placement is testable without a DOM.
4004
+ * @param anchor - the button's client rect, or null.
4005
+ * @param viewportWidth - window width.
4006
+ * @param viewportHeight - window height.
4007
+ * @returns `{ left, top, width }` in viewport pixels.
4008
+ */
4009
+ function hintBubblePlacement(anchor, viewportWidth, viewportHeight) {
4010
+ const width = Math.min(HINT_BUBBLE_WIDTH, Math.max(120, viewportWidth - HINT_BUBBLE_MARGIN * 2));
4011
+ const anchorLeft = anchor !== null && typeof anchor === "object" && typeof anchor.left === "number" ? anchor.left : HINT_BUBBLE_MARGIN;
4012
+ const anchorBottom = anchor !== null && typeof anchor === "object" && typeof anchor.bottom === "number" ? anchor.bottom : HINT_BUBBLE_MARGIN;
4013
+ const left = Math.max(HINT_BUBBLE_MARGIN, Math.min(anchorLeft, viewportWidth - width - HINT_BUBBLE_MARGIN));
4014
+ const top = Math.max(HINT_BUBBLE_MARGIN, Math.min(anchorBottom + HINT_BUBBLE_GAP, viewportHeight - HINT_BUBBLE_MARGIN));
4015
+ return { left: Math.round(left), top: Math.round(top), width: Math.round(width) };
4016
+ }
4017
+
4018
+ /**
4019
+ * The bubble node for one disclosure: the official `MenuSurface` material when
4020
+ * `surface` is given, the self-drawn inline bubble otherwise. Pure, so both
4021
+ * paths are exercised without a DOM; closed renders nothing.
4022
+ *
4023
+ * `MenuSurface` is handed placement only (position/left/top/width/scroll) and
4024
+ * keeps its own fill, radius, border and blur; the fallback div carries the
4025
+ * whole look itself. `compact` asks the official surface for the smaller
4026
+ * radius a small bubble wants.
4027
+ * @param open - whether the bubble is showing.
4028
+ * @param surface - the official MenuSurface component, or null for the fallback.
4029
+ * @param label - accessible name for the dialog.
4030
+ * @param place - `{ left, top, width }` from `hintBubblePlacement`.
4031
+ * @param text - the full hint text, `**` markers included.
4032
+ * @returns the bubble element, or null while closed.
4033
+ */
4034
+ function hintBubbleNode(open, surface, label, place, text) {
4035
+ if (!open) return null;
4036
+ const placement = { left: place.left + "px", top: place.top + "px", width: place.width + "px" };
4037
+ const content = hintTextNodes(text);
4038
+ if (surface === null) {
4039
+ return React.createElement("div", {
4040
+ "data-icon-custom-hint": "1",
4041
+ role: "dialog",
4042
+ "aria-label": label,
4043
+ style: { ...HINT_BUBBLE_STYLE, ...placement }
4044
+ }, ...content);
4045
+ }
4046
+ return React.createElement(surface, {
4047
+ "data-icon-custom-hint": "1",
4048
+ role: "dialog",
4049
+ "aria-label": label,
4050
+ compact: true,
4051
+ style: { ...HINT_BUBBLE_PLACEMENT_STYLE, ...placement }
4052
+ }, ...content);
4053
+ }
4054
+
4055
+ /**
4056
+ * The button plus its bubble, with the open/close state managed here — the same
4057
+ * code on both paths, because the official surface only supplies material.
4058
+ * Opened by the "?", closed by the same button, an outside pointerdown, or
4059
+ * Escape. The button is the only way to open the text (hovering does not).
4060
+ */
4061
+ function HintDisclosureBubble(props) {
4062
+ const [rect, setRect] = React.useState(null);
4063
+ const open = rect !== null;
4064
+ React.useEffect(() => {
4065
+ if (!open) return undefined;
4066
+ const onKey = (event) => { if (event.key === "Escape") setRect(null); };
4067
+ const onDown = (event) => {
4068
+ const node = event.target;
4069
+ if (node === null || typeof node.closest !== "function") return;
4070
+ // Both surfaces carry this marker (MenuSurface forwards div props), so
4071
+ // a click inside the bubble never counts as "clicked away".
4072
+ if (node.closest('[data-icon-custom-hint="1"]') !== null) return;
4073
+ if (node.closest('[data-icon-custom-hint-button="1"]') !== null) return;
4074
+ setRect(null);
4075
+ };
4076
+ window.addEventListener("keydown", onKey);
4077
+ document.addEventListener("mousedown", onDown, true);
4078
+ return () => {
4079
+ window.removeEventListener("keydown", onKey);
4080
+ document.removeEventListener("mousedown", onDown, true);
4081
+ };
4082
+ }, [open]);
4083
+ const toggle = (event) => {
4084
+ if (open) {
4085
+ setRect(null);
4086
+ return;
4087
+ }
4088
+ const node = event.currentTarget;
4089
+ setRect(node !== null && typeof node.getBoundingClientRect === "function" ? node.getBoundingClientRect() : null);
4090
+ };
4091
+ const place = hintBubblePlacement(rect, window.innerWidth, window.innerHeight);
4092
+ const button = React.createElement("button", {
4093
+ type: "button",
4094
+ "aria-label": props.label,
4095
+ title: props.label,
4096
+ "data-icon-custom-hint-button": "1",
4097
+ onClick: toggle,
4098
+ style: HINT_BUTTON_STYLE
4099
+ }, "?");
4100
+ return React.createElement(React.Fragment, null,
4101
+ button,
4102
+ hintBubbleNode(open, props.surface, props.label, place, props.text)
4103
+ );
4104
+ }
4105
+
4106
+ /**
4107
+ * One hint row: the short summary (plain text, never folded) stays on the page,
4108
+ * the full text sits behind the "?" button. The official primitive is resolved
4109
+ * once per row and passed down; a missing package simply means `surface === null`.
4110
+ */
4111
+ function HintDisclosure(props) {
4112
+ return React.createElement("div", { style: HINT_ROW_STYLE },
4113
+ React.createElement("span", { style: HINT_SUMMARY_STYLE }, props.summary),
4114
+ React.createElement(HintDisclosureBubble, {
4115
+ text: props.text,
4116
+ label: props.label,
4117
+ surface: officialHintSurface()
4118
+ })
4119
+ );
4120
+ }
4121
+ //#endregion
4122
+
3288
4123
  function FaviconSection(props) {
3289
4124
  const t = props.t;
3290
4125
  const rpc = props.rpc;
@@ -3401,7 +4236,7 @@ window.__ModuleLoader__.load({
3401
4236
  // arrives — so a field missing HERE is a field that silently reverts to its
3402
4237
  // default on the next page load, however well the local toggle worked.
3403
4238
  rpc.call("/api", "iconCustom/setUnreadRule", {
3404
- args: { request: { reasons: next.reasons, pending: next.pending, workspaceDot: next.workspaceDot, appBadge: next.appBadge, clearDelaySec: next.clearDelaySec, revealOnOpen: next.revealOnOpen } }
4239
+ args: { request: { reasons: next.reasons, pending: next.pending, workspaceDot: next.workspaceDot, appBadge: next.appBadge, clearDelaySec: next.clearDelaySec, revealOnOpen: next.revealOnOpen, unreadIncludeSubagents: next.unreadIncludeSubagents } }
3405
4240
  }).catch(() => {});
3406
4241
  }, [rpc]);
3407
4242
  // Toggle one unread reason. Applies locally at once; the host keeps the
@@ -3421,6 +4256,12 @@ window.__ModuleLoader__.load({
3421
4256
  const onRevealToggle = React.useCallback((checked) => {
3422
4257
  saveUnreadRule({ revealOnOpen: checked });
3423
4258
  }, [saveUnreadRule]);
4259
+ // Whether a background sub-agent settling (which makes its parent run one
4260
+ // more turn) is worth telling the person about. Same one save path, so the
4261
+ // field cannot be dropped here and silently revert to ON after a reload.
4262
+ const onIncludeSubagentsToggle = React.useCallback((checked) => {
4263
+ saveUnreadRule({ unreadIncludeSubagents: checked });
4264
+ }, [saveUnreadRule]);
3424
4265
  // The system-icon projection of the same number. Saved through the same
3425
4266
  // one save path, so this field can never be dropped by a caller.
3426
4267
  const onAppBadgeToggle = React.useCallback((checked) => {
@@ -3432,13 +4273,25 @@ window.__ModuleLoader__.load({
3432
4273
  const saveRunningRule = React.useCallback((patch) => {
3433
4274
  const next = normalizeRunningConfig({ ...runningConfig, ...patch });
3434
4275
  emitRunningConfig(next);
4276
+ // Both switches travel together, always: the host record is one object, and
4277
+ // a caller that remembered only one field would otherwise silently reset
4278
+ // the other one on the next load.
3435
4279
  rpc.call("/api", "iconCustom/setRunningRule", {
3436
- args: { request: { enabled: next.enabled } }
4280
+ args: { request: { enabled: next.enabled, includeSubagents: next.includeSubagents, headerRing: next.headerRing } }
3437
4281
  }).catch(() => {});
3438
4282
  }, [rpc]);
3439
4283
  const onRunningMarkToggle = React.useCallback((checked) => {
3440
4284
  saveRunningRule({ enabled: checked });
3441
4285
  }, [saveRunningRule]);
4286
+ const onRunningIncludeSubagentsToggle = React.useCallback((checked) => {
4287
+ saveRunningRule({ includeSubagents: checked });
4288
+ }, [saveRunningRule]);
4289
+ // Third switch of the same feature: the yellow ring on the conversation
4290
+ // header's sub-agent count. Independent by construction — nothing on the
4291
+ // sidebar, the tab icon or the OS badge reads it.
4292
+ const onHeaderRingToggle = React.useCallback((checked) => {
4293
+ saveRunningRule({ headerRing: checked });
4294
+ }, [saveRunningRule]);
3442
4295
  // Seconds you must stay in a session before it counts as read; 0 = as soon
3443
4296
  // as you enter. Clamped by the shared normalizer, so a stray keystroke can
3444
4297
  // never persist an absurd delay.
@@ -3605,6 +4458,15 @@ window.__ModuleLoader__.load({
3605
4458
  hint: { fontSize: 11, color: "var(--dsw-alias-label-tertiary)", marginTop: "4px", lineHeight: "16px" }
3606
4459
  };
3607
4460
 
4461
+ // One disclosure row per long hint: the summary is on the page, the full
4462
+ // text (and nothing else) sits behind the "?" button.
4463
+ const hintLine = (hintKey) => React.createElement(HintDisclosure, {
4464
+ key: hintKey,
4465
+ summary: t(hintKey + "Summary"),
4466
+ text: t(hintKey),
4467
+ label: t("hintMoreLabel")
4468
+ });
4469
+
3608
4470
  return React.createElement("div", { style: { display: "flex", flexDirection: "column", gap: "4px" } },
3609
4471
  React.createElement("div", { style: style.row },
3610
4472
  React.createElement("div", { style: style.preview },
@@ -3630,13 +4492,13 @@ window.__ModuleLoader__.load({
3630
4492
  React.createElement("input", { type: "checkbox", checked: pwaEnabled, disabled: busy || !active, onChange: onTogglePwa, style: { cursor: busy ? "default" : "pointer" } }),
3631
4493
  React.createElement("span", null, t("pwa"))
3632
4494
  ),
3633
- React.createElement("div", { style: style.hint }, t("pwaHint")),
4495
+ hintLine("pwaHint"),
3634
4496
  React.createElement("label", { style: { display: "flex", alignItems: "center", gap: "8px", padding: "10px 0 0", fontSize: 12, color: "var(--dsw-alias-label-primary)", cursor: busy ? "default" : "pointer" } },
3635
4497
  React.createElement("input", { type: "checkbox", checked: logoEnabled, disabled: busy || !active, onChange: onToggleLogo, style: { cursor: busy ? "default" : "pointer" } }),
3636
4498
  React.createElement("span", null, t("logo"))
3637
4499
  ),
3638
- React.createElement("div", { style: style.hint }, t("logoHint")),
3639
- React.createElement("div", { style: style.hint }, t("uploadHint")),
4500
+ hintLine("logoHint"),
4501
+ hintLine("uploadHint"),
3640
4502
  React.createElement("div", { style: style.hint }, t("resetHint")),
3641
4503
  React.createElement("div", { style: { borderTop: "1px solid var(--dsw-alias-border-l2)", marginTop: "12px", paddingTop: "12px" } },
3642
4504
  React.createElement("div", { style: style.label }, t("badgeTest")),
@@ -3673,7 +4535,7 @@ window.__ModuleLoader__.load({
3673
4535
  style: { ...(badgeSizeValue === key ? style.btnPrimary : style.btn), padding: "4px 10px" }
3674
4536
  }, t(key === "sm" ? "badgeSizeSmall" : key === "md" ? "badgeSizeMedium" : "badgeSizeLarge")))
3675
4537
  ),
3676
- React.createElement("div", { style: style.hint }, t("badgeSizeHint")),
4538
+ hintLine("badgeSizeHint"),
3677
4539
  React.createElement("div", { style: { marginTop: "12px" } },
3678
4540
  React.createElement("div", { style: style.desc }, t("unreadReasonsLabel")),
3679
4541
  React.createElement("div", { style: { display: "flex", flexWrap: "wrap", alignItems: "center" } },
@@ -3681,6 +4543,18 @@ window.__ModuleLoader__.load({
3681
4543
  renderReasonCheckbox("pending", "reasonPending")
3682
4544
  )
3683
4545
  ),
4546
+ React.createElement("div", { style: { marginTop: "10px" } },
4547
+ React.createElement("label", { style: { display: "inline-flex", alignItems: "center", gap: "6px", fontSize: 12, color: "var(--dsw-alias-label-primary)", cursor: "pointer" } },
4548
+ React.createElement("input", {
4549
+ type: "checkbox",
4550
+ checked: unread.unreadIncludeSubagents !== false,
4551
+ onChange: (event) => onIncludeSubagentsToggle(event.target.checked === true),
4552
+ "aria-label": t("unreadIncludeSubagentsLabel")
4553
+ }),
4554
+ React.createElement("span", null, t("unreadIncludeSubagentsLabel"))
4555
+ ),
4556
+ hintLine("unreadIncludeSubagentsHint")
4557
+ ),
3684
4558
  React.createElement("div", { style: { marginTop: "12px" } },
3685
4559
  React.createElement("div", { style: style.desc }, t("clearDelayLabel")),
3686
4560
  React.createElement("label", { style: { display: "inline-flex", alignItems: "center", gap: "6px", marginTop: "6px", fontSize: 12, color: "var(--dsw-alias-label-primary)" } },
@@ -3695,7 +4569,7 @@ window.__ModuleLoader__.load({
3695
4569
  }),
3696
4570
  React.createElement("span", null, t("clearDelayUnit"))
3697
4571
  ),
3698
- React.createElement("div", { style: style.hint }, t("clearDelayHint"))
4572
+ hintLine("clearDelayHint")
3699
4573
  ),
3700
4574
  React.createElement("div", { style: { marginTop: "10px" } },
3701
4575
  React.createElement("label", { style: { display: "inline-flex", alignItems: "center", gap: "6px", fontSize: 12, color: "var(--dsw-alias-label-primary)", cursor: "pointer" } },
@@ -3706,7 +4580,7 @@ window.__ModuleLoader__.load({
3706
4580
  }),
3707
4581
  React.createElement("span", null, t("workspaceDotLabel"))
3708
4582
  ),
3709
- React.createElement("div", { style: style.hint }, t("workspaceDotHint"))
4583
+ hintLine("workspaceDotHint")
3710
4584
  ),
3711
4585
  React.createElement("div", { style: { marginTop: "10px" } },
3712
4586
  React.createElement("label", { style: { display: "inline-flex", alignItems: "center", gap: "6px", fontSize: 12, color: "var(--dsw-alias-label-primary)", cursor: "pointer" } },
@@ -3717,7 +4591,7 @@ window.__ModuleLoader__.load({
3717
4591
  }),
3718
4592
  React.createElement("span", null, t("revealLabel"))
3719
4593
  ),
3720
- React.createElement("div", { style: style.hint }, t("revealHint"))
4594
+ hintLine("revealHint")
3721
4595
  ),
3722
4596
  React.createElement("div", { style: { marginTop: "10px" } },
3723
4597
  React.createElement("label", { style: { display: "inline-flex", alignItems: "center", gap: "6px", fontSize: 12, color: "var(--dsw-alias-label-primary)", cursor: "pointer" } },
@@ -3728,7 +4602,7 @@ window.__ModuleLoader__.load({
3728
4602
  }),
3729
4603
  React.createElement("span", null, t("appBadgeLabel"))
3730
4604
  ),
3731
- React.createElement("div", { style: style.hint }, t("appBadgeHint")),
4605
+ hintLine("appBadgeHint"),
3732
4606
  // What THIS device is — never a claim about the platform.
3733
4607
  React.createElement("div", { style: style.hint }, t(appBadgeCapabilityKey(appBadgeProbe))),
3734
4608
  // The WebKit precondition as a live local fact, plus the one
@@ -3744,10 +4618,10 @@ window.__ModuleLoader__.load({
3744
4618
  }, t("notificationPermissionButton"))
3745
4619
  : null
3746
4620
  ),
3747
- React.createElement("div", { style: style.hint }, t("notificationPermissionHint")),
4621
+ hintLine("notificationPermissionHint"),
3748
4622
  React.createElement("div", { style: style.hint }, t("appBadgeFootnote"))
3749
4623
  ),
3750
- React.createElement("div", { style: style.hint }, t("badgeTestHint"))
4624
+ hintLine("badgeTestHint")
3751
4625
  ),
3752
4626
  // A second, independent feature in its own block: same settings page,
3753
4627
  // same save discipline, different fact.
@@ -3762,7 +4636,34 @@ window.__ModuleLoader__.load({
3762
4636
  }),
3763
4637
  React.createElement("span", null, t("runningMarkLabel"))
3764
4638
  ),
3765
- React.createElement("div", { style: style.hint }, t("runningMarkHint"))
4639
+ hintLine("runningMarkHint"),
4640
+ // The second switch of the same feature: still no host fact, only a
4641
+ // counting rule. Nested under the first one because it only matters
4642
+ // while the marks themselves are on.
4643
+ React.createElement("div", { style: { marginTop: "6px" } },
4644
+ React.createElement("label", { style: { display: "inline-flex", alignItems: "center", gap: "6px", fontSize: 12, color: "var(--dsw-alias-label-primary)", cursor: "pointer" } },
4645
+ React.createElement("input", {
4646
+ type: "checkbox",
4647
+ checked: running.includeSubagents !== false,
4648
+ onChange: (event) => onRunningIncludeSubagentsToggle(event.target.checked === true)
4649
+ }),
4650
+ React.createElement("span", null, t("runningIncludeSubagentsLabel"))
4651
+ ),
4652
+ hintLine("runningIncludeSubagentsHint")
4653
+ ),
4654
+ // The third switch of the same feature: one decoration on the
4655
+ // conversation header, independent of every count above.
4656
+ React.createElement("div", { style: { marginTop: "6px" } },
4657
+ React.createElement("label", { style: { display: "inline-flex", alignItems: "center", gap: "6px", fontSize: 12, color: "var(--dsw-alias-label-primary)", cursor: "pointer" } },
4658
+ React.createElement("input", {
4659
+ type: "checkbox",
4660
+ checked: running.headerRing !== false,
4661
+ onChange: (event) => onHeaderRingToggle(event.target.checked === true)
4662
+ }),
4663
+ React.createElement("span", null, t("headerRingLabel"))
4664
+ ),
4665
+ hintLine("headerRingHint")
4666
+ )
3766
4667
  )
3767
4668
  ),
3768
4669
  notice ? React.createElement("div", { style: style.notice }, notice) : null,
@@ -3783,6 +4684,25 @@ window.__ModuleLoader__.load({
3783
4684
  // Setting it here (not at module scope) is what keeps the store usable in a
3784
4685
  // test harness with no connection: pushes stay no-ops, the badge still works.
3785
4686
  seenRpc = rpc;
4687
+ // The official "Session on screen" binding, looked up lazily so a build
4688
+ // without it (or a plugin loaded before ui-session) simply answers nothing
4689
+ // and the DOM/retention fallbacks keep the behaviour this file shipped with.
4690
+ officialSessionId = () => {
4691
+ try {
4692
+ const service = ctx.get("uiSession");
4693
+ const current = service !== null && service !== undefined ? service.adapter?.current : undefined;
4694
+ const value = current !== null && current !== undefined && typeof current.getSnapshot === "function" ? current.getSnapshot() : undefined;
4695
+ const key = value !== null && typeof value === "object" ? value.key : undefined;
4696
+ return typeof key === "string" && key !== "" ? key : undefined;
4697
+ } catch { return undefined; }
4698
+ };
4699
+ // The sessions service, looked up lazily for the same reason as the binding
4700
+ // above: it is only needed to nudge the host when a session's
4701
+ // `subagentCatalog` projection has not been folded yet, and a build without
4702
+ // it simply waits for the next render (see `requestHeaderRingRefresh`).
4703
+ headerRingSessions = () => {
4704
+ try { return ctx.get("sessions"); } catch { return null; }
4705
+ };
3786
4706
  // Pull the host state once, so the brand mark and the badge are correct
3787
4707
  // before the settings section is ever opened. The unread rule rides its
3788
4708
  // own endpoint: the icon RPCs keep their pre-badge response shape.
@@ -3928,10 +4848,12 @@ window.__ModuleLoader__.load({
3928
4848
  // value that is already on the bridge. The brand mark reads it at
3929
4849
  // mount; the tab icon has to be told, or a plugin hot-reload would
3930
4850
  // leave it restored (the old effect's cleanup) until the next change.
3931
- composeFavicon(effectiveCount(), badgeSize);
4851
+ composeFavicon(effectiveCount(), badgeSize, true);
3932
4852
  return () => {
3933
4853
  unsubscribe();
3934
- composeFavicon(0);
4854
+ // Forced: the promise is that unloading this half leaves the page's
4855
+ // own icon behind, whatever this page load had drawn before.
4856
+ composeFavicon(0, badgeSize, true);
3935
4857
  };
3936
4858
  }, "dsh-icon-custom: favicon badge");
3937
4859
  // Watchdog. The tab icon is the one surface other parties can disturb
@@ -3976,6 +4898,9 @@ window.__ModuleLoader__.load({
3976
4898
  // Same region, same watchdog: the section-header badge is
3977
4899
  // re-injected whenever the shipped UI re-renders over it.
3978
4900
  paintHeaderBadge();
4901
+ // And the conversation header's ring, which lives outside this
4902
+ // container: any churn nearby is a fair moment to reconcile it.
4903
+ paintHeaderRing(runningConfig, headerRingValue);
3979
4904
  }, 200);
3980
4905
  };
3981
4906
  const attach = () => {
@@ -4009,14 +4934,16 @@ window.__ModuleLoader__.load({
4009
4934
  offRunning();
4010
4935
  clearWorkspaceDots();
4011
4936
  clearHeaderBadge();
4937
+ clearHeaderRing();
4012
4938
  // The one highlight this plugin ever puts on a SHIPPED row: it must not
4013
4939
  // outlive the plugin, even if the unload lands inside its flash window.
4014
4940
  clearRevealFlash();
4015
4941
  };
4016
4942
  }, "dsh-icon-custom: workspace dots");
4017
4943
  // The platform favicon.svg repaints itself for dark mode through
4018
- // prefers-color-scheme; a composited bitmap cannot, so rebuild it.
4019
- ctx.on("theme/change", () => { composeFavicon(effectiveCount(), badgeSize); });
4944
+ // prefers-color-scheme; a composited bitmap cannot, so rebuild it —
4945
+ // forced, because the base it was built from is exactly what changed.
4946
+ ctx.on("theme/change", () => { composeFavicon(effectiveCount(), badgeSize, true); });
4020
4947
  }
4021
4948
  exports.apply = apply;
4022
4949
  exports.inject = inject;