@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 +1051 -124
- package/lib/index.js +27 -8
- package/lib/running.js +26 -5
- package/lib/typert.host.js +19 -10
- package/lib/unread.js +66 -13
- package/package.json +2 -2
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
655
|
-
*
|
|
656
|
-
*
|
|
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
|
-
|
|
661
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
692
|
-
*
|
|
693
|
-
*
|
|
694
|
-
*
|
|
695
|
-
*
|
|
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
|
-
* `
|
|
699
|
-
*
|
|
700
|
-
* the
|
|
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
|
|
703
|
-
* @returns
|
|
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
|
|
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 (
|
|
709
|
-
if (
|
|
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
|
-
|
|
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).
|
|
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
|
-
|
|
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
|
|
836
|
-
* them: they are not part of the browsing the user does
|
|
837
|
-
* `sessionVisible` hides them outright), so
|
|
838
|
-
*
|
|
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")
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
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
|
-
|
|
1552
|
-
|
|
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
|
-
|
|
1558
|
-
//
|
|
1559
|
-
//
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
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
|
-
|
|
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 =
|
|
1594
|
-
if (
|
|
1595
|
-
if (
|
|
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)
|
|
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
|
-
|
|
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 (
|
|
1659
|
-
composeFavicon(
|
|
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
|
-
|
|
2579
|
-
|
|
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
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3639
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4621
|
+
hintLine("notificationPermissionHint"),
|
|
3748
4622
|
React.createElement("div", { style: style.hint }, t("appBadgeFootnote"))
|
|
3749
4623
|
),
|
|
3750
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|