@cowwo/dsh-icon-custom 0.19.1 → 0.20.1

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
 
@@ -271,13 +313,20 @@ window.__ModuleLoader__.load({
271
313
  // page should show. A cached/proxied page, a stale HTML rewrite, or a
272
314
  // browser extension can all leave the wrong link behind; this makes the
273
315
  // client authoritative about the icon it composites onto.
316
+ const previousHref = desiredIconHref();
274
317
  iconState = {
275
318
  active: known && status.active === true,
276
319
  rev: known && typeof status.rev === "string" && status.rev !== "" ? status.rev : null,
277
320
  png: known && status.png === true,
278
321
  mime: known && typeof status.mime === "string" ? status.mime : null
279
322
  };
280
- reconcileFavicon();
323
+ // A different base image must be rebuilt from the NEW artwork right now:
324
+ // the ordinary reconcile is not enough, because a compose already in flight
325
+ // would land carrying the old base and nothing would touch the icon again
326
+ // until the next 5-second watchdog tick. Same move the settings page makes
327
+ // after an upload (`refreshFaviconBase`).
328
+ if (desiredIconHref() !== previousHref) refreshFaviconBase();
329
+ else reconcileFavicon();
281
330
  }
282
331
 
283
332
  //#endregion
@@ -313,7 +362,9 @@ window.__ModuleLoader__.load({
313
362
  const UNREAD_APP_BADGE_FALLBACK = true;
314
363
  /** Mirror of the host's default (see lib/unread.js) — locating a clicked row is on. */
315
364
  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 };
365
+ /** Mirror of the host's default (see lib/unread.js) — sub-agent-caused endings count as unread. */
366
+ const UNREAD_INCLUDE_SUBAGENTS_FALLBACK = true;
367
+ 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
368
  const unreadConfigListeners = new Set();
318
369
  /** Mirror of the host's normalizer: any input becomes an integer 0..cap seconds. */
319
370
  function normalizeClearDelay(input) {
@@ -336,7 +387,12 @@ window.__ModuleLoader__.load({
336
387
  workspaceDot: typeof source.workspaceDot === "boolean" ? source.workspaceDot : true,
337
388
  appBadge: typeof source.appBadge === "boolean" ? source.appBadge : UNREAD_APP_BADGE_FALLBACK,
338
389
  clearDelaySec: normalizeClearDelay(source.clearDelaySec),
339
- revealOnOpen: typeof source.revealOnOpen === "boolean" ? source.revealOnOpen : UNREAD_REVEAL_FALLBACK
390
+ revealOnOpen: typeof source.revealOnOpen === "boolean" ? source.revealOnOpen : UNREAD_REVEAL_FALLBACK,
391
+ // Missing or dirty falls back to ON (= today's behaviour): an old host that
392
+ // never heard of this field must not make the badge quieter. Spelled as a
393
+ // literal like `pending` / `workspaceDot` above, so a partial slice of this
394
+ // normalizer (the tests take exactly this function) needs no extra constant.
395
+ unreadIncludeSubagents: typeof source.unreadIncludeSubagents === "boolean" ? source.unreadIncludeSubagents : true
340
396
  };
341
397
  }
342
398
  function emitUnreadConfig(input) {
@@ -365,11 +421,35 @@ window.__ModuleLoader__.load({
365
421
  const RUNNING_YELLOW = "#f5c518";
366
422
  /** Mirror of the host default (lib/running.js). */
367
423
  const RUNNING_MARK_FALLBACK = true;
368
- let runningConfig = { enabled: RUNNING_MARK_FALLBACK };
424
+ /**
425
+ * Mirror of the host's second running default (lib/running.js): rolling a
426
+ * running sub-agent up to the ancestor session that owns it is ON. The two
427
+ * questions are independent — whether to draw the marks at all is not the same
428
+ * as whether work you cannot see should light the session it belongs to.
429
+ */
430
+ const RUNNING_INCLUDE_SUBAGENTS_FALLBACK = true;
431
+ /**
432
+ * Mirror of the host's third running default (lib/running.js): the yellow ring
433
+ * on the conversation header's sub-agent count is ON. A third, independent
434
+ * question — the two above decide whether marks are drawn at all and whether a
435
+ * sub-agent's work is credited to its ancestor; this one only decides whether
436
+ * that one header count also carries a running number. It feeds no other count.
437
+ */
438
+ const RUNNING_HEADER_RING_FALLBACK = true;
439
+ let runningConfig = { enabled: RUNNING_MARK_FALLBACK, includeSubagents: RUNNING_INCLUDE_SUBAGENTS_FALLBACK, headerRing: RUNNING_HEADER_RING_FALLBACK };
369
440
  const runningConfigListeners = new Set();
370
441
  function normalizeRunningConfig(input) {
371
442
  const source = input !== null && typeof input === "object" ? input : {};
372
- return { enabled: typeof source.enabled === "boolean" ? source.enabled : RUNNING_MARK_FALLBACK };
443
+ // `typeof` (not a bare read) keeps this normalizer usable when a regression
444
+ // test lifts it out of the module: an absent mirror is simply the default.
445
+ const enabledFallback = typeof RUNNING_MARK_FALLBACK === "boolean" ? RUNNING_MARK_FALLBACK : true;
446
+ const includeFallback = typeof RUNNING_INCLUDE_SUBAGENTS_FALLBACK === "boolean" ? RUNNING_INCLUDE_SUBAGENTS_FALLBACK : true;
447
+ const headerRingFallback = typeof RUNNING_HEADER_RING_FALLBACK === "boolean" ? RUNNING_HEADER_RING_FALLBACK : true;
448
+ return {
449
+ enabled: typeof source.enabled === "boolean" ? source.enabled : enabledFallback,
450
+ includeSubagents: typeof source.includeSubagents === "boolean" ? source.includeSubagents : includeFallback,
451
+ headerRing: typeof source.headerRing === "boolean" ? source.headerRing : headerRingFallback
452
+ };
373
453
  }
374
454
  function emitRunningConfig(input) {
375
455
  runningConfig = normalizeRunningConfig(input);
@@ -787,7 +867,17 @@ window.__ModuleLoader__.load({
787
867
  /**
788
868
  * How many sessions deserve the badge right now: endings whose reason is
789
869
  * enabled and that this browser has not seen since, plus sessions waiting
790
- * for you (when that source is enabled). Only sub-agent sessions never count.
870
+ * for you (when that source is enabled). Sub-agent sessions never count on
871
+ * their own; whether an ending caused by a sub-agent's settlement counts is
872
+ * decided by the `unreadIncludeSubagents` rule, not by this function.
873
+ *
874
+ * A PARENT session woken by a sub-agent settling is a second kind of
875
+ * sub-agent-caused unread: the child has no row of its own, but its
876
+ * settlement notice makes the parent run a turn, and that turn's ending used
877
+ * to light the parent's dot. The fold marks those endings
878
+ * (`subagentCaused`, see lib/unread.js) instead of hiding them, and this
879
+ * function drops them only while the rule says so — the fact stays in the
880
+ * projection either way, so flipping the switch never re-folds a log.
791
881
  *
792
882
  * The session you are LOOKING AT counts too. It used to be excluded, on the
793
883
  * theory that "you have it open, so you have seen it" — but that premise is
@@ -805,6 +895,9 @@ window.__ModuleLoader__.load({
805
895
  */
806
896
  function collectUnread(list, pending, config, seen, lit) {
807
897
  const reasons = config !== null && typeof config === "object" && config.reasons !== null && typeof config.reasons === "object" ? config.reasons : {};
898
+ // Absent, dirty or an old host's answer → ON (= everything this function
899
+ // did before the switch existed).
900
+ const includeSubagents = !(config !== null && typeof config === "object" && config.unreadIncludeSubagents === false);
808
901
  // The watermark is a parameter, not module state: this stays a pure
809
902
  // decision function that a test can drive with any marks it likes.
810
903
  const marks = seen !== null && typeof seen === "object" ? seen : {};
@@ -823,9 +916,27 @@ window.__ModuleLoader__.load({
823
916
  const values = entry.projectionValues;
824
917
  const value = values !== null && typeof values === "object" ? values[LAST_TURN_END_KEY] : undefined;
825
918
  if (value === null || typeof value !== "object") continue;
919
+ // The switch is OFF: an ending the fold attributed to a sub-agent
920
+ // settling is not news for the person sitting here. This is NOT the
921
+ // reason-toggle exception below — that one protects an already lit dot
922
+ // from a vocabulary change, while this one is the user explicitly
923
+ // saying they do not want this class of news at all.
924
+ if (!includeSubagents && value.subagentCaused === true) continue;
826
925
  const endAt = typeof value.endAt === "number" ? value.endAt : 0;
827
- const seenAt = marks[id];
828
- if (endAt <= 0 || typeof seenAt !== "number" || endAt <= seenAt) continue;
926
+ // No watermark is the NORMAL first sight of a session: `ensureSeenBaseline`
927
+ // writes one from an effect AFTER this render, so skipping the row here
928
+ // would report 0 on the one frame the user actually sees — and the page
929
+ // would keep that 0 for its whole life (the baseline write pushed no
930
+ // re-render, so nothing recomputed it).
931
+ // Fall back to EXACTLY the value that effect writes — this browser's last
932
+ // time here (`seenBootAt`), or the Host's now when it has none — so the
933
+ // first frame and the write agree and the number cannot flicker.
934
+ // Guarded with `typeof` so a regression test may lift this function out
935
+ // of the module: an absent mirror simply means "no baseline".
936
+ const seenAt = typeof marks[id] === "number"
937
+ ? marks[id]
938
+ : (typeof seenBootAt === "number" && seenBootAt > 0 ? seenBootAt : (typeof hostNow === "function" ? hostNow() : Date.now()));
939
+ if (endAt <= 0 || endAt <= seenAt) continue;
829
940
  // Unknown reasons (a newer DSH adds one) stay hidden until the user
830
941
  // turns them on, so a vocabulary growth can never surprise the badge.
831
942
  // A reason the user turned OFF behaves the same way — with ONE
@@ -877,12 +988,20 @@ window.__ModuleLoader__.load({
877
988
  * so it is NOT running here either. That is why a session with the
878
989
  * amber dot never also gets a yellow one.
879
990
  *
880
- * Sub-agent sessions are skipped for the same reason `collectUnread` skips
881
- * them: they are not part of the browsing the user does (ui-workspace's
882
- * `sessionVisible` hides them outright), so counting them would leave the
883
- * mark lit by work nobody can open.
991
+ * Sub-agent sessions get no ids of their own for the same reason
992
+ * `collectUnread` skips them: they are not part of the browsing the user does
993
+ * (ui-workspace's `sessionVisible` hides them outright), so a mark on their row
994
+ * would point at work nobody can open. With `includeSubagents` on (the
995
+ * default), a running sub-agent still credits the topmost non-sub-agent
996
+ * ancestor session that owns it, walking `parentSessionId` upwards: that is the
997
+ * session the user actually opened, and without the roll-up it looks idle while
998
+ * its sub-agents work. Several sub-agents under one ancestor collapse into that
999
+ * single id, so the number stays a count of sessions. The ancestor's own
1000
+ * `pendingInteraction` still wins, switch or no switch: a session parked
1001
+ * waiting for you is never also "running".
884
1002
  * @param list - the session list snapshot (may be absent).
885
1003
  * @param status - the official session-status map (may be absent).
1004
+ * @param includeSubagents - optional override for the switch; absent means "ask the live config", whose documented default is ON.
886
1005
  * @returns the running session ids, in list order.
887
1006
  */
888
1007
  function runningSessionIds(list, status) {
@@ -893,17 +1012,68 @@ window.__ModuleLoader__.load({
893
1012
  const lookup = status !== null && status !== undefined && typeof status.get === "function"
894
1013
  ? (id) => status.get(id)
895
1014
  : () => undefined;
1015
+ // The roll-up switch. A third argument wins (the regression tests drive it
1016
+ // that way); otherwise the live config answers, and a copy lifted out of the
1017
+ // module with no config in scope is the documented default: ON.
1018
+ const includeSubagents = arguments.length > 2
1019
+ ? arguments[2] !== false
1020
+ : !(typeof runningConfig === "object" && runningConfig !== null && runningConfig.includeSubagents === false);
1021
+ /** Is this row parked waiting for the user (the amber state)? */
1022
+ const waitingForYou = (id) => {
1023
+ const row = lookup(id);
1024
+ return row !== null && typeof row === "object" && row.pendingInteraction !== null && row.pendingInteraction !== undefined;
1025
+ };
1026
+ /** The official running fact for a row, sidebar precedence, minus the wait state. */
1027
+ const isRunning = (id) => {
1028
+ if (waitingForYou(id)) return false;
1029
+ const row = lookup(id);
1030
+ const known = row !== null && typeof row === "object";
1031
+ const entry = byId[id];
1032
+ const running = known && typeof row.running === "boolean"
1033
+ ? row.running
1034
+ : entry !== null && typeof entry === "object" ? entry.running : undefined;
1035
+ return running === true;
1036
+ };
1037
+ /**
1038
+ * Up the `parentSessionId` chain to the topmost ancestor that is not itself a
1039
+ * sub-agent. A broken, cyclic or unknown chain yields "" (unattributable):
1040
+ * guessing an owner would light a session that has nothing to do with the
1041
+ * sub-agent.
1042
+ */
1043
+ const ownerOf = (id) => {
1044
+ let current = id;
1045
+ const walked = new Set([id]);
1046
+ for (let hops = 0; hops < 64; hops++) {
1047
+ const entry = byId[current];
1048
+ const parent = entry !== null && typeof entry === "object" && typeof entry.parentSessionId === "string" ? entry.parentSessionId : "";
1049
+ if (parent === "" || walked.has(parent)) return "";
1050
+ walked.add(parent);
1051
+ const next = byId[parent];
1052
+ if (next !== null && typeof next === "object" && next.origin === "subagent") {
1053
+ current = parent;
1054
+ continue;
1055
+ }
1056
+ if (next === null || typeof next !== "object") return "";
1057
+ return parent;
1058
+ }
1059
+ return "";
1060
+ };
1061
+ /** Ancestors are deduplicated: two sub-agents under one parent are one session. */
1062
+ const push = (id) => { if (!out.includes(id)) out.push(id); };
896
1063
  for (const id of order) {
897
1064
  if (typeof id !== "string" || id === "") continue;
898
1065
  const entry = byId[id];
899
1066
  if (entry === null || typeof entry !== "object") continue;
900
- if (entry.origin === "subagent") continue;
901
- const row = lookup(id);
902
- const known = row !== null && typeof row === "object";
903
- if (known && row.pendingInteraction !== null && row.pendingInteraction !== undefined) continue;
904
- const running = known && typeof row.running === "boolean" ? row.running : entry.running;
905
- if (running !== true) continue;
906
- out.push(id);
1067
+ if (entry.origin === "subagent") {
1068
+ if (!includeSubagents || !isRunning(id)) continue;
1069
+ const owner = ownerOf(id);
1070
+ // Only the ancestor's own wait state counts: the sub-agent has no row, so
1071
+ // it can never be the session the user is being asked about.
1072
+ if (owner !== "" && !waitingForYou(owner)) push(owner);
1073
+ continue;
1074
+ }
1075
+ if (!isRunning(id)) continue;
1076
+ push(id);
907
1077
  }
908
1078
  return out;
909
1079
  }
@@ -1472,6 +1642,45 @@ window.__ModuleLoader__.load({
1472
1642
  let faviconPendingSignature = "";
1473
1643
  /** Whether the last compose failed: the watchdog must retry instead of trusting the DOM. */
1474
1644
  let faviconFailed = false;
1645
+ /**
1646
+ * Short backoff after a failed compose: 300 ms → 1 s → 2 s, then the 5-second
1647
+ * watchdog owns it.
1648
+ *
1649
+ * The watchdog alone is too slow to feel like "the tab icon follows the
1650
+ * number": a base image that is merely SLOW (a cold cache, a proxy waking up, a
1651
+ * laptop resuming) left the badge missing for up to five seconds after a reload
1652
+ * — and a reload is exactly when the number is freshest. Three quick attempts
1653
+ * cover that window; a genuinely missing icon still falls through to the
1654
+ * watchdog, which keeps warning once per reason. Any success resets the ladder.
1655
+ */
1656
+ const FAVICON_RETRY_DELAYS_MS = [300, 1000, 2000];
1657
+ let faviconRetryTimer = 0;
1658
+ let faviconRetryStep = 0;
1659
+ function clearFaviconRetry() {
1660
+ if (faviconRetryTimer === 0) return;
1661
+ window.clearTimeout(faviconRetryTimer);
1662
+ faviconRetryTimer = 0;
1663
+ }
1664
+ /**
1665
+ * Queue one retry of the compose that just failed.
1666
+ *
1667
+ * At most one timer is ever pending, and the ladder stops after the last rung
1668
+ * (the 5-second watchdog is the safety net below it). The callback re-checks
1669
+ * `faviconFailed`: a compose that already succeeded, or the teardown that
1670
+ * restored the shipped icons, has cleared it — and rebuilding then would paint
1671
+ * a number back onto a page that was deliberately left alone.
1672
+ */
1673
+ function scheduleFaviconRetry() {
1674
+ if (faviconRetryTimer !== 0) return;
1675
+ if (faviconRetryStep >= FAVICON_RETRY_DELAYS_MS.length) return;
1676
+ const delay = FAVICON_RETRY_DELAYS_MS[faviconRetryStep];
1677
+ faviconRetryStep++;
1678
+ faviconRetryTimer = window.setTimeout(() => {
1679
+ faviconRetryTimer = 0;
1680
+ if (faviconFailed !== true) return; // already repaired, or torn down
1681
+ composeFavicon(effectiveCount(), badgeSize, true);
1682
+ }, delay);
1683
+ }
1475
1684
  /** Which icon the plugin considers current (filled from the status RPC). */
1476
1685
  let iconState = { active: false, rev: null, png: false, mime: null };
1477
1686
  /** The icon URL this page SHOULD be showing, per the plugin's own status. */
@@ -1778,7 +1987,17 @@ window.__ModuleLoader__.load({
1778
1987
  if (signature === faviconPendingSignature) return; // this very picture is being fetched
1779
1988
  if (force !== true && signature === faviconDrawnSignature && faviconFailed !== true) return; // already on the tab
1780
1989
  try {
1781
- Promise.resolve(applyBadgeToFavicon(count, size)).catch(warnFaviconCompose);
1990
+ Promise.resolve(applyBadgeToFavicon(count, size)).then(
1991
+ () => {
1992
+ // The compose reports its own failure through `faviconFailed` (it never
1993
+ // rejects), so this is where one becomes a SHORT retry instead of a
1994
+ // wait for the next watchdog tick. A good compose resets the ladder.
1995
+ if (faviconFailed === true) { scheduleFaviconRetry(); return; }
1996
+ faviconRetryStep = 0;
1997
+ clearFaviconRetry();
1998
+ },
1999
+ (error) => { warnFaviconCompose(error); }
2000
+ );
1782
2001
  } catch (error) {
1783
2002
  // An `async` callee never throws synchronously, so this arm is dead TODAY —
1784
2003
  // it is here so a future refactor (an early-returning sync path) cannot
@@ -2666,6 +2885,274 @@ window.__ModuleLoader__.load({
2666
2885
  }
2667
2886
  //#endregion
2668
2887
 
2888
+ //#region header ring
2889
+ /**
2890
+ * One yellow ring on the conversation header's sub-agent count.
2891
+ *
2892
+ * The official "N sub-agents" row is a `button[aria-haspopup="tree"]` inside
2893
+ * the `conversation.session.header.actions` slot, and its count is the LAST
2894
+ * `<span>` child of that button. The ring is stacked on that span's top-right
2895
+ * corner — the same corner logic as the workspace folder's yellow dot — and it
2896
+ * is display only: not clickable, `pointer-events:none`, `aria-hidden` because
2897
+ * the official button's own label already announces the running count.
2898
+ *
2899
+ * The number is the official `subagentCatalog` projection of the session on
2900
+ * screen, counting only DIRECT children that are running. It never joins any
2901
+ * other count this plugin draws (sidebar dots, the top-left number, the tab
2902
+ * icon, the OS badge), so this switch can never move one of them.
2903
+ *
2904
+ * Class names are build hashes and are never used; the anchor is found by the
2905
+ * official `data-slot` plus structure/role only. Like the sidebar marks this
2906
+ * half is injected into shipped DOM, reconciled instead of redrawn, quiet when
2907
+ * it cannot find the anchor, and self-disabling after repeated failures.
2908
+ */
2909
+ const HEAD_RING_ATTR = "data-icon-custom-headring";
2910
+ /** Marks the count element whose inline `position` we changed, for restore. */
2911
+ const HEAD_RING_HOST_ATTR = "data-icon-custom-headring-host";
2912
+ /** Breaker ceiling: a changed header structure must not warn forever. */
2913
+ const HEAD_RING_MAX_FAILURES = 20;
2914
+ /** The number for the session on screen; -1 means "no answer, draw nothing". */
2915
+ let headerRingValue = -1;
2916
+ let headerRingFailures = 0;
2917
+ let headerRingGivenUp = false;
2918
+ let headerRingHost = null;
2919
+ let headerRingHostPosition = "";
2920
+ let headerRingObserved = null;
2921
+ let headerRingObserver = null;
2922
+ /** The session service lookup, filled in by `apply` (may stay null). */
2923
+ let headerRingSessions = null;
2924
+ /** Throttle for the "catalog projection not loaded yet" refresh ask. */
2925
+ let headerRingRefreshSession = "";
2926
+ let headerRingRefreshAt = 0;
2927
+
2928
+ /**
2929
+ * How many DIRECT children of `sessionId` are running, from the official
2930
+ * `subagentCatalog` projection.
2931
+ *
2932
+ * Grandchildren are not part of a parent's catalog, so they are excluded by
2933
+ * construction. `status` (the official session-status map) wins over the list
2934
+ * row exactly the way the shipped sidebar reads it. An unloaded projection is
2935
+ * 0, never a guess.
2936
+ * @param list - the session list snapshot (may be absent).
2937
+ * @param status - the official session-status map (may be absent).
2938
+ * @param sessionId - the session whose header is on screen.
2939
+ * @returns the count of running direct children.
2940
+ */
2941
+ function headerRingCount(list, status, sessionId) {
2942
+ if (typeof sessionId !== "string" || sessionId === "") return 0;
2943
+ if (list === null || typeof list !== "object") return 0;
2944
+ const projections = list.projectionsBySession;
2945
+ if (projections === null || typeof projections !== "object") return 0;
2946
+ const snapshot = projections[sessionId];
2947
+ const values = snapshot !== null && typeof snapshot === "object" ? snapshot.values : undefined;
2948
+ const catalog = values !== null && typeof values === "object" ? values.subagentCatalog : undefined;
2949
+ if (!Array.isArray(catalog)) return 0;
2950
+ const byId = list.byId !== null && typeof list.byId === "object" ? list.byId : {};
2951
+ const lookup = status !== null && status !== undefined && typeof status.get === "function" ? (id) => status.get(id) : () => undefined;
2952
+ let count = 0;
2953
+ for (const entry of catalog) {
2954
+ if (entry === null || typeof entry !== "object" || typeof entry.id !== "string" || entry.id === "") continue;
2955
+ const row = lookup(entry.id);
2956
+ const known = row !== null && typeof row === "object" && typeof row.running === "boolean";
2957
+ const fallback = byId[entry.id];
2958
+ const running = known ? row.running : fallback !== null && typeof fallback === "object" ? fallback.running : undefined;
2959
+ if (running === true) count++;
2960
+ }
2961
+ return count;
2962
+ }
2963
+
2964
+ /**
2965
+ * Whether that session's catalog projection is loaded at all. `undefined`
2966
+ * means "not asked yet" — the one case worth nudging the host about (see
2967
+ * `requestHeaderRingRefresh`); an empty array is a real answer.
2968
+ */
2969
+ function headerRingCatalogLoaded(list, sessionId) {
2970
+ if (list === null || typeof list !== "object" || typeof sessionId !== "string" || sessionId === "") return false;
2971
+ const projections = list.projectionsBySession;
2972
+ const snapshot = projections !== null && typeof projections === "object" ? projections[sessionId] : undefined;
2973
+ const values = snapshot !== null && typeof snapshot === "object" ? snapshot.values : undefined;
2974
+ return values !== null && typeof values === "object" && Array.isArray(values.subagentCatalog);
2975
+ }
2976
+
2977
+ /**
2978
+ * Ask the host to fold that session's projections, at most once per 10 s. The
2979
+ * service lookup is optional (`apply` fills it in): without it the next render
2980
+ * simply tries again, which is the documented fallback.
2981
+ */
2982
+ function requestHeaderRingRefresh(sessionId) {
2983
+ const now = Date.now();
2984
+ if (sessionId === headerRingRefreshSession && now - headerRingRefreshAt < 10000) return;
2985
+ headerRingRefreshSession = sessionId;
2986
+ headerRingRefreshAt = now;
2987
+ try {
2988
+ const service = typeof headerRingSessions === "function" ? headerRingSessions() : null;
2989
+ const refresh = service !== null && service !== undefined ? service.refreshProjections : undefined;
2990
+ if (typeof refresh !== "function") return;
2991
+ const result = refresh.call(service, sessionId);
2992
+ if (result !== null && typeof result === "object" && typeof result.catch === "function") result.catch(() => {});
2993
+ } catch { /* best effort: the next render is the fallback */ }
2994
+ }
2995
+
2996
+ /** The rendered action button for the session on screen, or null. */
2997
+ function headerRingButton() {
2998
+ try {
2999
+ const slots = document.querySelectorAll('[data-slot="conversation.session.header.actions"]');
3000
+ if (slots === null || slots === undefined) return null;
3001
+ for (const slot of slots) {
3002
+ // The right sidebar keeps an embedded conversation MOUNTED while
3003
+ // collapsed (hidden by CSS and `inert`), so its header actions must
3004
+ // never be the anchor for the visible conversation's ring. Same guard
3005
+ // as `conversationOnScreen` uses for the same duplicate.
3006
+ if (typeof slot.closest === "function" && (slot.closest("[data-sidebar-right-panel]") !== null || slot.closest("[inert]") !== null)) continue;
3007
+ if (typeof slot.querySelectorAll !== "function") continue;
3008
+ for (const button of slot.querySelectorAll("button")) {
3009
+ if (button.getAttribute("aria-haspopup") === "tree") return button;
3010
+ }
3011
+ }
3012
+ return null;
3013
+ } catch { return null; }
3014
+ }
3015
+
3016
+ /** The count element: the LAST `<span>` direct child of that button. */
3017
+ function headerRingCountElement(button) {
3018
+ let found = null;
3019
+ if (button === null || button === undefined || button.children === undefined) return null;
3020
+ for (const child of button.children) {
3021
+ if (child === null || child === undefined || child.tagName !== "SPAN") continue;
3022
+ if (child.getAttribute(HEAD_RING_ATTR) !== null) continue;
3023
+ found = child;
3024
+ }
3025
+ return found;
3026
+ }
3027
+
3028
+ /**
3029
+ * Whether the decoration must stay off this conversation. A sub-agent's own
3030
+ * session renders no header row at all, so it must render no ring either.
3031
+ */
3032
+ function headerRingSuppressed(list, sessionId) {
3033
+ if (typeof sessionId !== "string" || sessionId === "") return true;
3034
+ if (list === null || typeof list !== "object") return true;
3035
+ const byId = list.byId !== null && typeof list.byId === "object" ? list.byId : {};
3036
+ const entry = byId[sessionId];
3037
+ // No row means the page cannot prove this is not a sub-agent's own
3038
+ // conversation: suppress rather than draw on a page we cannot read.
3039
+ if (entry === null || typeof entry !== "object") return true;
3040
+ return entry.origin === "subagent";
3041
+ }
3042
+
3043
+ /**
3044
+ * The effective number to draw. `-1` (no answer) and `0` both mean "draw
3045
+ * nothing"; the switch is the only other input. Pure, so the switch and the
3046
+ * zero case are testable without a DOM.
3047
+ */
3048
+ function headerRingVisibleCount(config, count) {
3049
+ const enabled = !(config !== null && typeof config === "object" && config.headerRing === false);
3050
+ if (!enabled) return -1;
3051
+ const value = Number(count);
3052
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
3053
+ }
3054
+
3055
+ /** Remove the ring and put the count element's own `position` back. */
3056
+ function clearHeaderRing() {
3057
+ try { document.querySelectorAll("[" + HEAD_RING_ATTR + "]").forEach((node) => node.remove()); } catch {}
3058
+ if (headerRingHost !== null) {
3059
+ try { headerRingHost.style.position = headerRingHostPosition; } catch {}
3060
+ try { headerRingHost.removeAttribute(HEAD_RING_HOST_ATTR); } catch {}
3061
+ }
3062
+ headerRingHost = null;
3063
+ headerRingHostPosition = "";
3064
+ if (headerRingObserver !== null) {
3065
+ try { headerRingObserver.disconnect(); } catch {}
3066
+ headerRingObserver = null;
3067
+ }
3068
+ headerRingObserved = null;
3069
+ }
3070
+
3071
+ /** Watch the header slot, so a re-render that dropped the ring gets it back. */
3072
+ function observeHeaderRingAnchor(button) {
3073
+ if (typeof window === "undefined" || window === null || typeof window.MutationObserver !== "function") return;
3074
+ const slot = button !== null && typeof button.closest === "function" ? button.closest('[data-slot="conversation.session.header.actions"]') : null;
3075
+ if (slot === null || slot === undefined || slot === headerRingObserved) return;
3076
+ try {
3077
+ if (headerRingObserver !== null) headerRingObserver.disconnect();
3078
+ headerRingObserver = new window.MutationObserver(() => { repaintHeaderRing(); });
3079
+ headerRingObserver.observe(slot, { childList: true, subtree: true, characterData: true });
3080
+ headerRingObserved = slot;
3081
+ } catch {
3082
+ headerRingObserver = null;
3083
+ headerRingObserved = null;
3084
+ }
3085
+ }
3086
+
3087
+ /** Re-run the current value; used by the observer, which has no arguments. */
3088
+ function repaintHeaderRing() {
3089
+ paintHeaderRing(runningConfig, headerRingValue);
3090
+ }
3091
+
3092
+ /** Warn once, then give up for this page — the sidebar marks' exact posture. */
3093
+ function noteHeaderRingFailure(why) {
3094
+ headerRingFailures++;
3095
+ if (headerRingFailures === 1) {
3096
+ try { console.warn("dsh-icon-custom: 会话头部黄圈定位失败,已降级(不影响其他功能):", why); } catch {}
3097
+ return;
3098
+ }
3099
+ if (headerRingFailures < HEAD_RING_MAX_FAILURES || headerRingGivenUp) return;
3100
+ headerRingGivenUp = true;
3101
+ try { console.warn("dsh-icon-custom: 会话头部黄圈连续失败,本次会话内已自动关闭。"); } catch {}
3102
+ clearHeaderRing();
3103
+ }
3104
+
3105
+ /**
3106
+ * Reconcile the ring with the current number. Idempotent (writes only what
3107
+ * differs) and quiet when the anchor is not on this page (a sub-agent's own
3108
+ * conversation, the landing view, a boot).
3109
+ * @param config - the running config, for the `headerRing` switch.
3110
+ * @param count - the ring's number, from `headerRingCount`; -1 hides it.
3111
+ */
3112
+ function paintHeaderRing(config, count) {
3113
+ if (headerRingGivenUp) return;
3114
+ const value = headerRingVisibleCount(config, count);
3115
+ if (value <= 0) { clearHeaderRing(); return; }
3116
+ const button = headerRingButton();
3117
+ // No anchor is not a failure: child sessions render no header row, and the
3118
+ // page may simply be booting. Leave whatever is there alone and wait.
3119
+ if (button === null) { clearHeaderRing(); return; }
3120
+ const anchor = headerRingCountElement(button);
3121
+ if (anchor === null) { noteHeaderRingFailure("找不到计数元素"); return; }
3122
+ try {
3123
+ if (headerRingHost !== anchor) {
3124
+ clearHeaderRing();
3125
+ headerRingHost = anchor;
3126
+ headerRingHostPosition = anchor.style.position;
3127
+ anchor.setAttribute(HEAD_RING_HOST_ATTR, "1");
3128
+ if (window.getComputedStyle(anchor).position === "static") anchor.style.position = "relative";
3129
+ }
3130
+ observeHeaderRingAnchor(button);
3131
+ let node = anchor.querySelector("[" + HEAD_RING_ATTR + "]");
3132
+ if (node === null) {
3133
+ node = document.createElement("span");
3134
+ node.setAttribute(HEAD_RING_ATTR, "1");
3135
+ // Not a control and never announced: the official button's own label
3136
+ // already speaks the running count.
3137
+ node.setAttribute("aria-hidden", "true");
3138
+ // Looks like the sidebar workspace's red count: a rounded-square pill (never a
3139
+ // full circle), with a dark number on the running yellow.
3140
+ node.style.cssText = "position:absolute;pointer-events:none;top:-6px;right:-5px;display:inline-flex;"
3141
+ + "align-items:center;justify-content:center;min-width:16px;height:16px;padding:0 4px;box-sizing:border-box;"
3142
+ + "border-radius:6px;background:" + RUNNING_YELLOW + ";color:#1f2328;font:inherit;font-size:11px;"
3143
+ + "font-weight:600;line-height:16px;font-variant-numeric:tabular-nums;z-index:3;"
3144
+ + "box-shadow:0 0 0 1.5px var(--dsw-alias-bg-layer-1,#fff)";
3145
+ anchor.appendChild(node);
3146
+ }
3147
+ const text = badgeLabel(value);
3148
+ if (node.textContent !== text) node.textContent = text;
3149
+ headerRingFailures = 0;
3150
+ } catch (error) {
3151
+ noteHeaderRingFailure(String((error && error.message) || error));
3152
+ }
3153
+ }
3154
+ //#endregion
3155
+
2669
3156
  //#region components
2670
3157
  /**
2671
3158
  * Headless occupant of the frame-wide `shell.overlay` slot. It reads DSH's
@@ -2714,6 +3201,10 @@ window.__ModuleLoader__.load({
2714
3201
  if (!ensureSeenBaseline(ids)) return;
2715
3202
  pruneSeenState();
2716
3203
  saveSeenState();
3204
+ // The watermark moved under a render that was already counted without it:
3205
+ // repaint in the same breath, exactly like the Host-merge path below, so
3206
+ // nothing on screen can keep a number the new baseline already answers.
3207
+ emitUnreadPoke();
2717
3208
  }, [list]);
2718
3209
  // What the page says about the screen, refreshed on the two moments that can
2719
3210
  // change it without the session list changing: a global panel replacing the
@@ -2886,6 +3377,21 @@ window.__ModuleLoader__.load({
2886
3377
  const runningRowSignature = runningItemsSignature(runningRows);
2887
3378
  const runningCount = runningIds.length;
2888
3379
  React.useEffect(() => { emitRunningState(runningCount, runningRows); }, [runningRowSignature]);
3380
+ // The conversation header's yellow ring: the session on screen, and ONLY
3381
+ // its direct children from the official `subagentCatalog`. A separate
3382
+ // number for a separate decoration — it never joins `runningIds`, so no
3383
+ // sidebar, tab or OS-badge count can move because of it.
3384
+ const ringSessionId = viewed.ids.length === 1 ? viewed.ids[0] : "";
3385
+ const ringSuppressed = headerRingSuppressed(list, ringSessionId);
3386
+ const ringCount = ringSuppressed ? -1 : headerRingCount(list, status, ringSessionId);
3387
+ const ringSignature = ringSessionId + "\u0000" + ringCount;
3388
+ React.useEffect(() => {
3389
+ headerRingValue = ringCount;
3390
+ // Not loaded yet is not "zero running": ask the host once, then let the
3391
+ // next render answer. Without a session service this is a quiet no-op.
3392
+ if (!ringSuppressed && !headerRingCatalogLoaded(list, ringSessionId)) requestHeaderRingRefresh(ringSessionId);
3393
+ paintHeaderRing(runningConfig, headerRingValue);
3394
+ }, [ringSignature, running.enabled, running.headerRing]);
2889
3395
  // The session rows, for naming the running ones the way the sidebar shows
2890
3396
  // them (`displayTitle`, the same fallback the red dots use).
2891
3397
  React.useEffect(() => {
@@ -3463,6 +3969,229 @@ window.__ModuleLoader__.load({
3463
3969
  }, children);
3464
3970
  }
3465
3971
 
3972
+ //#region hint disclosure
3973
+ /**
3974
+ * Every long hint used to be printed in full on the settings page, which made
3975
+ * one screen a wall of grey small print. Now each one keeps a one-line
3976
+ * summary on the page and moves the full text behind a "?" button.
3977
+ *
3978
+ * The bubble's material is borrowed, its behaviour is ours:
3979
+ * - the official `MenuSurface` primitive as the bubble container, when this
3980
+ * build exposes it to plugins;
3981
+ * - a self-drawn bubble carrying the same inline styling otherwise.
3982
+ * Opening and closing (click the "?", click it again, click away, Escape) is
3983
+ * the plugin's own code on BOTH paths, and placement stays in the pure
3984
+ * `hintBubblePlacement` below — `MenuSurface` only paints the surface, it
3985
+ * does not position or dismiss anything.
3986
+ *
3987
+ * Why not the official `Tooltip` (which is what this used to use): its
3988
+ * `label` is `string | (() => string)`
3989
+ * (`@deepseek-ai/dsh-client-ui-primitives/lib/types/Tooltip.d.ts:20`), so it
3990
+ * can only print plain text and can never render the emphasised phrases the
3991
+ * requirement asks for. `MenuSurface` takes arbitrary children
3992
+ * (`ComponentPropsWithoutRef<'div'>`), so the same self-managed disclosure
3993
+ * can render real React nodes — hence `**bold**` becomes a real `<strong>`.
3994
+ * See docs/adr/0012-hint-bubble-menusurface.md.
3995
+ *
3996
+ * The client half is loaded through the ModuleLoader's `require` with no build
3997
+ * step, so that package can be absent or not yet materialized — a `require`
3998
+ * miss must degrade to the fallback, never to a blank section.
3999
+ * Note: this package is resolved by the host at require time and is therefore
4000
+ * deliberately left out of `dsh.client.inject` and `peerDependencies`
4001
+ * (matching the official packages), so no injection gate can block mount.
4002
+ */
4003
+ const HINT_BUBBLE_WIDTH = 320;
4004
+ const HINT_BUBBLE_MARGIN = 8;
4005
+ const HINT_BUBBLE_GAP = 6;
4006
+ const HINT_ROW_STYLE = { display: "flex", alignItems: "flex-start", gap: "6px", marginTop: "4px" };
4007
+ const HINT_SUMMARY_STYLE = { fontSize: 11, color: "var(--dsw-alias-label-tertiary)", lineHeight: "16px" };
4008
+ 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" };
4009
+ /** The emphasised run: a real `<strong>`, so `**` never reaches the screen. */
4010
+ const HINT_STRONG_STYLE = { fontWeight: 600, color: "var(--dsw-alias-label-primary)" };
4011
+ /** The fallback bubble carries its own material; the official surface paints its own. */
4012
+ 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" };
4013
+ /** Placement-only style for the official surface: no fill/border/radius of ours. */
4014
+ 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" };
4015
+
4016
+ /**
4017
+ * Resolve the official MenuSurface lazily: requiring it at factory time can
4018
+ * run before the primitive is materialized, and a miss throws (the loader's
4019
+ * module table has no fallback entry). Success is memoized; a miss is retried
4020
+ * on the next render, so a plugin that boots before the primitive still picks
4021
+ * it up instead of being stuck on the fallback forever.
4022
+ * @returns the MenuSurface component, or null when this build does not expose it.
4023
+ */
4024
+ let officialMenuSurface = null;
4025
+ function officialHintSurface() {
4026
+ if (officialMenuSurface !== null) return officialMenuSurface;
4027
+ try {
4028
+ const primitives = require("@deepseek-ai/dsh-client-ui-primitives");
4029
+ // "Present" is the whole judgement, not "is a function": the shipped
4030
+ // `MenuSurface` is a `forwardRef` object (`lib/index.js` builds it with
4031
+ // `forwardRef(...)`, and its type is `ForwardRefExoticComponent`), so
4032
+ // `typeof` is "object". The old `typeof === "function"` test therefore
4033
+ // never matched in production and left the official branch dead. A build
4034
+ // that exposes it as a plain function still works.
4035
+ const candidate = primitives !== null && typeof primitives === "object" ? primitives.MenuSurface : undefined;
4036
+ if (candidate !== null && candidate !== undefined
4037
+ && (typeof candidate === "function" || typeof candidate === "object")) {
4038
+ officialMenuSurface = candidate;
4039
+ }
4040
+ } catch (error) {
4041
+ officialMenuSurface = null;
4042
+ }
4043
+ return officialMenuSurface;
4044
+ }
4045
+
4046
+ /**
4047
+ * Turn one hint string into React children, converting every BALANCED
4048
+ * `**pair**` into a real `<strong>`.
4049
+ *
4050
+ * Only a closed pair is markup: an unpaired `**` (or a lone marker) is kept
4051
+ * verbatim, because dropping the characters would silently lose text — worse
4052
+ * than showing the marker. The marker itself never reaches the screen for a
4053
+ * balanced pair: what lands in the DOM is `<strong>pair</strong>`.
4054
+ * @param text - the locale string, possibly carrying `**` runs.
4055
+ * @returns an array of strings and `<strong>` elements, never empty.
4056
+ */
4057
+ function hintTextNodes(text) {
4058
+ const source = typeof text === "string" ? text : "";
4059
+ const nodes = [];
4060
+ const bold = /\*\*([\s\S]+?)\*\*/g;
4061
+ let last = 0;
4062
+ let match;
4063
+ while ((match = bold.exec(source)) !== null) {
4064
+ if (match.index > last) nodes.push(source.slice(last, match.index));
4065
+ nodes.push(React.createElement("strong", { key: "hint-bold-" + nodes.length, style: HINT_STRONG_STYLE }, match[1]));
4066
+ last = match.index + match[0].length;
4067
+ }
4068
+ if (last < source.length) nodes.push(source.slice(last));
4069
+ if (nodes.length === 0) nodes.push(source);
4070
+ return nodes;
4071
+ }
4072
+
4073
+ /**
4074
+ * Where the fallback bubble goes: just below the button's anchor rect, clamped
4075
+ * into the viewport. Pure, so the placement is testable without a DOM.
4076
+ * @param anchor - the button's client rect, or null.
4077
+ * @param viewportWidth - window width.
4078
+ * @param viewportHeight - window height.
4079
+ * @returns `{ left, top, width }` in viewport pixels.
4080
+ */
4081
+ function hintBubblePlacement(anchor, viewportWidth, viewportHeight) {
4082
+ const width = Math.min(HINT_BUBBLE_WIDTH, Math.max(120, viewportWidth - HINT_BUBBLE_MARGIN * 2));
4083
+ const anchorLeft = anchor !== null && typeof anchor === "object" && typeof anchor.left === "number" ? anchor.left : HINT_BUBBLE_MARGIN;
4084
+ const anchorBottom = anchor !== null && typeof anchor === "object" && typeof anchor.bottom === "number" ? anchor.bottom : HINT_BUBBLE_MARGIN;
4085
+ const left = Math.max(HINT_BUBBLE_MARGIN, Math.min(anchorLeft, viewportWidth - width - HINT_BUBBLE_MARGIN));
4086
+ const top = Math.max(HINT_BUBBLE_MARGIN, Math.min(anchorBottom + HINT_BUBBLE_GAP, viewportHeight - HINT_BUBBLE_MARGIN));
4087
+ return { left: Math.round(left), top: Math.round(top), width: Math.round(width) };
4088
+ }
4089
+
4090
+ /**
4091
+ * The bubble node for one disclosure: the official `MenuSurface` material when
4092
+ * `surface` is given, the self-drawn inline bubble otherwise. Pure, so both
4093
+ * paths are exercised without a DOM; closed renders nothing.
4094
+ *
4095
+ * `MenuSurface` is handed placement only (position/left/top/width/scroll) and
4096
+ * keeps its own fill, radius, border and blur; the fallback div carries the
4097
+ * whole look itself. `compact` asks the official surface for the smaller
4098
+ * radius a small bubble wants.
4099
+ * @param open - whether the bubble is showing.
4100
+ * @param surface - the official MenuSurface component, or null for the fallback.
4101
+ * @param label - accessible name for the dialog.
4102
+ * @param place - `{ left, top, width }` from `hintBubblePlacement`.
4103
+ * @param text - the full hint text, `**` markers included.
4104
+ * @returns the bubble element, or null while closed.
4105
+ */
4106
+ function hintBubbleNode(open, surface, label, place, text) {
4107
+ if (!open) return null;
4108
+ const placement = { left: place.left + "px", top: place.top + "px", width: place.width + "px" };
4109
+ const content = hintTextNodes(text);
4110
+ if (surface === null) {
4111
+ return React.createElement("div", {
4112
+ "data-icon-custom-hint": "1",
4113
+ role: "dialog",
4114
+ "aria-label": label,
4115
+ style: { ...HINT_BUBBLE_STYLE, ...placement }
4116
+ }, ...content);
4117
+ }
4118
+ return React.createElement(surface, {
4119
+ "data-icon-custom-hint": "1",
4120
+ role: "dialog",
4121
+ "aria-label": label,
4122
+ compact: true,
4123
+ style: { ...HINT_BUBBLE_PLACEMENT_STYLE, ...placement }
4124
+ }, ...content);
4125
+ }
4126
+
4127
+ /**
4128
+ * The button plus its bubble, with the open/close state managed here — the same
4129
+ * code on both paths, because the official surface only supplies material.
4130
+ * Opened by the "?", closed by the same button, an outside pointerdown, or
4131
+ * Escape. The button is the only way to open the text (hovering does not).
4132
+ */
4133
+ function HintDisclosureBubble(props) {
4134
+ const [rect, setRect] = React.useState(null);
4135
+ const open = rect !== null;
4136
+ React.useEffect(() => {
4137
+ if (!open) return undefined;
4138
+ const onKey = (event) => { if (event.key === "Escape") setRect(null); };
4139
+ const onDown = (event) => {
4140
+ const node = event.target;
4141
+ if (node === null || typeof node.closest !== "function") return;
4142
+ // Both surfaces carry this marker (MenuSurface forwards div props), so
4143
+ // a click inside the bubble never counts as "clicked away".
4144
+ if (node.closest('[data-icon-custom-hint="1"]') !== null) return;
4145
+ if (node.closest('[data-icon-custom-hint-button="1"]') !== null) return;
4146
+ setRect(null);
4147
+ };
4148
+ window.addEventListener("keydown", onKey);
4149
+ document.addEventListener("mousedown", onDown, true);
4150
+ return () => {
4151
+ window.removeEventListener("keydown", onKey);
4152
+ document.removeEventListener("mousedown", onDown, true);
4153
+ };
4154
+ }, [open]);
4155
+ const toggle = (event) => {
4156
+ if (open) {
4157
+ setRect(null);
4158
+ return;
4159
+ }
4160
+ const node = event.currentTarget;
4161
+ setRect(node !== null && typeof node.getBoundingClientRect === "function" ? node.getBoundingClientRect() : null);
4162
+ };
4163
+ const place = hintBubblePlacement(rect, window.innerWidth, window.innerHeight);
4164
+ const button = React.createElement("button", {
4165
+ type: "button",
4166
+ "aria-label": props.label,
4167
+ title: props.label,
4168
+ "data-icon-custom-hint-button": "1",
4169
+ onClick: toggle,
4170
+ style: HINT_BUTTON_STYLE
4171
+ }, "?");
4172
+ return React.createElement(React.Fragment, null,
4173
+ button,
4174
+ hintBubbleNode(open, props.surface, props.label, place, props.text)
4175
+ );
4176
+ }
4177
+
4178
+ /**
4179
+ * One hint row: the short summary (plain text, never folded) stays on the page,
4180
+ * the full text sits behind the "?" button. The official primitive is resolved
4181
+ * once per row and passed down; a missing package simply means `surface === null`.
4182
+ */
4183
+ function HintDisclosure(props) {
4184
+ return React.createElement("div", { style: HINT_ROW_STYLE },
4185
+ React.createElement("span", { style: HINT_SUMMARY_STYLE }, props.summary),
4186
+ React.createElement(HintDisclosureBubble, {
4187
+ text: props.text,
4188
+ label: props.label,
4189
+ surface: officialHintSurface()
4190
+ })
4191
+ );
4192
+ }
4193
+ //#endregion
4194
+
3466
4195
  function FaviconSection(props) {
3467
4196
  const t = props.t;
3468
4197
  const rpc = props.rpc;
@@ -3579,7 +4308,7 @@ window.__ModuleLoader__.load({
3579
4308
  // arrives — so a field missing HERE is a field that silently reverts to its
3580
4309
  // default on the next page load, however well the local toggle worked.
3581
4310
  rpc.call("/api", "iconCustom/setUnreadRule", {
3582
- args: { request: { reasons: next.reasons, pending: next.pending, workspaceDot: next.workspaceDot, appBadge: next.appBadge, clearDelaySec: next.clearDelaySec, revealOnOpen: next.revealOnOpen } }
4311
+ args: { request: { reasons: next.reasons, pending: next.pending, workspaceDot: next.workspaceDot, appBadge: next.appBadge, clearDelaySec: next.clearDelaySec, revealOnOpen: next.revealOnOpen, unreadIncludeSubagents: next.unreadIncludeSubagents } }
3583
4312
  }).catch(() => {});
3584
4313
  }, [rpc]);
3585
4314
  // Toggle one unread reason. Applies locally at once; the host keeps the
@@ -3599,6 +4328,12 @@ window.__ModuleLoader__.load({
3599
4328
  const onRevealToggle = React.useCallback((checked) => {
3600
4329
  saveUnreadRule({ revealOnOpen: checked });
3601
4330
  }, [saveUnreadRule]);
4331
+ // Whether a background sub-agent settling (which makes its parent run one
4332
+ // more turn) is worth telling the person about. Same one save path, so the
4333
+ // field cannot be dropped here and silently revert to ON after a reload.
4334
+ const onIncludeSubagentsToggle = React.useCallback((checked) => {
4335
+ saveUnreadRule({ unreadIncludeSubagents: checked });
4336
+ }, [saveUnreadRule]);
3602
4337
  // The system-icon projection of the same number. Saved through the same
3603
4338
  // one save path, so this field can never be dropped by a caller.
3604
4339
  const onAppBadgeToggle = React.useCallback((checked) => {
@@ -3610,13 +4345,25 @@ window.__ModuleLoader__.load({
3610
4345
  const saveRunningRule = React.useCallback((patch) => {
3611
4346
  const next = normalizeRunningConfig({ ...runningConfig, ...patch });
3612
4347
  emitRunningConfig(next);
4348
+ // Both switches travel together, always: the host record is one object, and
4349
+ // a caller that remembered only one field would otherwise silently reset
4350
+ // the other one on the next load.
3613
4351
  rpc.call("/api", "iconCustom/setRunningRule", {
3614
- args: { request: { enabled: next.enabled } }
4352
+ args: { request: { enabled: next.enabled, includeSubagents: next.includeSubagents, headerRing: next.headerRing } }
3615
4353
  }).catch(() => {});
3616
4354
  }, [rpc]);
3617
4355
  const onRunningMarkToggle = React.useCallback((checked) => {
3618
4356
  saveRunningRule({ enabled: checked });
3619
4357
  }, [saveRunningRule]);
4358
+ const onRunningIncludeSubagentsToggle = React.useCallback((checked) => {
4359
+ saveRunningRule({ includeSubagents: checked });
4360
+ }, [saveRunningRule]);
4361
+ // Third switch of the same feature: the yellow ring on the conversation
4362
+ // header's sub-agent count. Independent by construction — nothing on the
4363
+ // sidebar, the tab icon or the OS badge reads it.
4364
+ const onHeaderRingToggle = React.useCallback((checked) => {
4365
+ saveRunningRule({ headerRing: checked });
4366
+ }, [saveRunningRule]);
3620
4367
  // Seconds you must stay in a session before it counts as read; 0 = as soon
3621
4368
  // as you enter. Clamped by the shared normalizer, so a stray keystroke can
3622
4369
  // never persist an absurd delay.
@@ -3783,6 +4530,15 @@ window.__ModuleLoader__.load({
3783
4530
  hint: { fontSize: 11, color: "var(--dsw-alias-label-tertiary)", marginTop: "4px", lineHeight: "16px" }
3784
4531
  };
3785
4532
 
4533
+ // One disclosure row per long hint: the summary is on the page, the full
4534
+ // text (and nothing else) sits behind the "?" button.
4535
+ const hintLine = (hintKey) => React.createElement(HintDisclosure, {
4536
+ key: hintKey,
4537
+ summary: t(hintKey + "Summary"),
4538
+ text: t(hintKey),
4539
+ label: t("hintMoreLabel")
4540
+ });
4541
+
3786
4542
  return React.createElement("div", { style: { display: "flex", flexDirection: "column", gap: "4px" } },
3787
4543
  React.createElement("div", { style: style.row },
3788
4544
  React.createElement("div", { style: style.preview },
@@ -3808,13 +4564,13 @@ window.__ModuleLoader__.load({
3808
4564
  React.createElement("input", { type: "checkbox", checked: pwaEnabled, disabled: busy || !active, onChange: onTogglePwa, style: { cursor: busy ? "default" : "pointer" } }),
3809
4565
  React.createElement("span", null, t("pwa"))
3810
4566
  ),
3811
- React.createElement("div", { style: style.hint }, t("pwaHint")),
4567
+ hintLine("pwaHint"),
3812
4568
  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" } },
3813
4569
  React.createElement("input", { type: "checkbox", checked: logoEnabled, disabled: busy || !active, onChange: onToggleLogo, style: { cursor: busy ? "default" : "pointer" } }),
3814
4570
  React.createElement("span", null, t("logo"))
3815
4571
  ),
3816
- React.createElement("div", { style: style.hint }, t("logoHint")),
3817
- React.createElement("div", { style: style.hint }, t("uploadHint")),
4572
+ hintLine("logoHint"),
4573
+ hintLine("uploadHint"),
3818
4574
  React.createElement("div", { style: style.hint }, t("resetHint")),
3819
4575
  React.createElement("div", { style: { borderTop: "1px solid var(--dsw-alias-border-l2)", marginTop: "12px", paddingTop: "12px" } },
3820
4576
  React.createElement("div", { style: style.label }, t("badgeTest")),
@@ -3851,7 +4607,7 @@ window.__ModuleLoader__.load({
3851
4607
  style: { ...(badgeSizeValue === key ? style.btnPrimary : style.btn), padding: "4px 10px" }
3852
4608
  }, t(key === "sm" ? "badgeSizeSmall" : key === "md" ? "badgeSizeMedium" : "badgeSizeLarge")))
3853
4609
  ),
3854
- React.createElement("div", { style: style.hint }, t("badgeSizeHint")),
4610
+ hintLine("badgeSizeHint"),
3855
4611
  React.createElement("div", { style: { marginTop: "12px" } },
3856
4612
  React.createElement("div", { style: style.desc }, t("unreadReasonsLabel")),
3857
4613
  React.createElement("div", { style: { display: "flex", flexWrap: "wrap", alignItems: "center" } },
@@ -3859,6 +4615,18 @@ window.__ModuleLoader__.load({
3859
4615
  renderReasonCheckbox("pending", "reasonPending")
3860
4616
  )
3861
4617
  ),
4618
+ React.createElement("div", { style: { marginTop: "10px" } },
4619
+ React.createElement("label", { style: { display: "inline-flex", alignItems: "center", gap: "6px", fontSize: 12, color: "var(--dsw-alias-label-primary)", cursor: "pointer" } },
4620
+ React.createElement("input", {
4621
+ type: "checkbox",
4622
+ checked: unread.unreadIncludeSubagents !== false,
4623
+ onChange: (event) => onIncludeSubagentsToggle(event.target.checked === true),
4624
+ "aria-label": t("unreadIncludeSubagentsLabel")
4625
+ }),
4626
+ React.createElement("span", null, t("unreadIncludeSubagentsLabel"))
4627
+ ),
4628
+ hintLine("unreadIncludeSubagentsHint")
4629
+ ),
3862
4630
  React.createElement("div", { style: { marginTop: "12px" } },
3863
4631
  React.createElement("div", { style: style.desc }, t("clearDelayLabel")),
3864
4632
  React.createElement("label", { style: { display: "inline-flex", alignItems: "center", gap: "6px", marginTop: "6px", fontSize: 12, color: "var(--dsw-alias-label-primary)" } },
@@ -3873,7 +4641,7 @@ window.__ModuleLoader__.load({
3873
4641
  }),
3874
4642
  React.createElement("span", null, t("clearDelayUnit"))
3875
4643
  ),
3876
- React.createElement("div", { style: style.hint }, t("clearDelayHint"))
4644
+ hintLine("clearDelayHint")
3877
4645
  ),
3878
4646
  React.createElement("div", { style: { marginTop: "10px" } },
3879
4647
  React.createElement("label", { style: { display: "inline-flex", alignItems: "center", gap: "6px", fontSize: 12, color: "var(--dsw-alias-label-primary)", cursor: "pointer" } },
@@ -3884,7 +4652,7 @@ window.__ModuleLoader__.load({
3884
4652
  }),
3885
4653
  React.createElement("span", null, t("workspaceDotLabel"))
3886
4654
  ),
3887
- React.createElement("div", { style: style.hint }, t("workspaceDotHint"))
4655
+ hintLine("workspaceDotHint")
3888
4656
  ),
3889
4657
  React.createElement("div", { style: { marginTop: "10px" } },
3890
4658
  React.createElement("label", { style: { display: "inline-flex", alignItems: "center", gap: "6px", fontSize: 12, color: "var(--dsw-alias-label-primary)", cursor: "pointer" } },
@@ -3895,7 +4663,7 @@ window.__ModuleLoader__.load({
3895
4663
  }),
3896
4664
  React.createElement("span", null, t("revealLabel"))
3897
4665
  ),
3898
- React.createElement("div", { style: style.hint }, t("revealHint"))
4666
+ hintLine("revealHint")
3899
4667
  ),
3900
4668
  React.createElement("div", { style: { marginTop: "10px" } },
3901
4669
  React.createElement("label", { style: { display: "inline-flex", alignItems: "center", gap: "6px", fontSize: 12, color: "var(--dsw-alias-label-primary)", cursor: "pointer" } },
@@ -3906,7 +4674,7 @@ window.__ModuleLoader__.load({
3906
4674
  }),
3907
4675
  React.createElement("span", null, t("appBadgeLabel"))
3908
4676
  ),
3909
- React.createElement("div", { style: style.hint }, t("appBadgeHint")),
4677
+ hintLine("appBadgeHint"),
3910
4678
  // What THIS device is — never a claim about the platform.
3911
4679
  React.createElement("div", { style: style.hint }, t(appBadgeCapabilityKey(appBadgeProbe))),
3912
4680
  // The WebKit precondition as a live local fact, plus the one
@@ -3922,10 +4690,10 @@ window.__ModuleLoader__.load({
3922
4690
  }, t("notificationPermissionButton"))
3923
4691
  : null
3924
4692
  ),
3925
- React.createElement("div", { style: style.hint }, t("notificationPermissionHint")),
4693
+ hintLine("notificationPermissionHint"),
3926
4694
  React.createElement("div", { style: style.hint }, t("appBadgeFootnote"))
3927
4695
  ),
3928
- React.createElement("div", { style: style.hint }, t("badgeTestHint"))
4696
+ hintLine("badgeTestHint")
3929
4697
  ),
3930
4698
  // A second, independent feature in its own block: same settings page,
3931
4699
  // same save discipline, different fact.
@@ -3940,7 +4708,34 @@ window.__ModuleLoader__.load({
3940
4708
  }),
3941
4709
  React.createElement("span", null, t("runningMarkLabel"))
3942
4710
  ),
3943
- React.createElement("div", { style: style.hint }, t("runningMarkHint"))
4711
+ hintLine("runningMarkHint"),
4712
+ // The second switch of the same feature: still no host fact, only a
4713
+ // counting rule. Nested under the first one because it only matters
4714
+ // while the marks themselves are on.
4715
+ React.createElement("div", { style: { marginTop: "6px" } },
4716
+ React.createElement("label", { style: { display: "inline-flex", alignItems: "center", gap: "6px", fontSize: 12, color: "var(--dsw-alias-label-primary)", cursor: "pointer" } },
4717
+ React.createElement("input", {
4718
+ type: "checkbox",
4719
+ checked: running.includeSubagents !== false,
4720
+ onChange: (event) => onRunningIncludeSubagentsToggle(event.target.checked === true)
4721
+ }),
4722
+ React.createElement("span", null, t("runningIncludeSubagentsLabel"))
4723
+ ),
4724
+ hintLine("runningIncludeSubagentsHint")
4725
+ ),
4726
+ // The third switch of the same feature: one decoration on the
4727
+ // conversation header, independent of every count above.
4728
+ React.createElement("div", { style: { marginTop: "6px" } },
4729
+ React.createElement("label", { style: { display: "inline-flex", alignItems: "center", gap: "6px", fontSize: 12, color: "var(--dsw-alias-label-primary)", cursor: "pointer" } },
4730
+ React.createElement("input", {
4731
+ type: "checkbox",
4732
+ checked: running.headerRing !== false,
4733
+ onChange: (event) => onHeaderRingToggle(event.target.checked === true)
4734
+ }),
4735
+ React.createElement("span", null, t("headerRingLabel"))
4736
+ ),
4737
+ hintLine("headerRingHint")
4738
+ )
3944
4739
  )
3945
4740
  ),
3946
4741
  notice ? React.createElement("div", { style: style.notice }, notice) : null,
@@ -3973,6 +4768,13 @@ window.__ModuleLoader__.load({
3973
4768
  return typeof key === "string" && key !== "" ? key : undefined;
3974
4769
  } catch { return undefined; }
3975
4770
  };
4771
+ // The sessions service, looked up lazily for the same reason as the binding
4772
+ // above: it is only needed to nudge the host when a session's
4773
+ // `subagentCatalog` projection has not been folded yet, and a build without
4774
+ // it simply waits for the next render (see `requestHeaderRingRefresh`).
4775
+ headerRingSessions = () => {
4776
+ try { return ctx.get("sessions"); } catch { return null; }
4777
+ };
3976
4778
  // Pull the host state once, so the brand mark and the badge are correct
3977
4779
  // before the settings section is ever opened. The unread rule rides its
3978
4780
  // own endpoint: the icon RPCs keep their pre-badge response shape.
@@ -4168,6 +4970,9 @@ window.__ModuleLoader__.load({
4168
4970
  // Same region, same watchdog: the section-header badge is
4169
4971
  // re-injected whenever the shipped UI re-renders over it.
4170
4972
  paintHeaderBadge();
4973
+ // And the conversation header's ring, which lives outside this
4974
+ // container: any churn nearby is a fair moment to reconcile it.
4975
+ paintHeaderRing(runningConfig, headerRingValue);
4171
4976
  }, 200);
4172
4977
  };
4173
4978
  const attach = () => {
@@ -4201,6 +5006,7 @@ window.__ModuleLoader__.load({
4201
5006
  offRunning();
4202
5007
  clearWorkspaceDots();
4203
5008
  clearHeaderBadge();
5009
+ clearHeaderRing();
4204
5010
  // The one highlight this plugin ever puts on a SHIPPED row: it must not
4205
5011
  // outlive the plugin, even if the unload lands inside its flash window.
4206
5012
  clearRevealFlash();