@cowwo/dsh-icon-custom 0.18.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/README.zh.md +11 -1
- package/client/client.js +431 -95
- package/lib/typert.host.js +6 -3
- package/lib/unread.js +18 -4
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -51,7 +51,9 @@ While a session is **running**, a yellow mark appears in four places: after the
|
|
|
51
51
|
- **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 / plan review" and the yellow goes dark. It tracks the official row's **grey spinner** exactly; the only difference is colour — the official one is deliberately quiet, this one is meant to be seen.
|
|
52
52
|
- **Every fact is official**: each session's `running` from the session list (computed by the host, pushed live). The plugin stores nothing and infers nothing, so a reload is immediately right and a **process restart never leaves a yellow mark stuck on**.
|
|
53
53
|
- **Sub-agents never count**: sub-agent sessions are not rendered in the sidebar at all (the same rule the red dot and the official row follow).
|
|
54
|
+
- **The yellow number is clickable**: it opens the list of **sessions running right now**, grouped by workspace (workspace · session · turn in flight · short id), and clicking a row **jumps to that session** and **locates it in the sidebar** (expands its workspace, scrolls to the row, flashes it once — see the "Locating" section below). It shares ONE floating panel with the red count's "needing you" list — opening either closes the other, since two panels under the same header row would stack. The list shows **no "running for how long"**: the official host only gives `running` as a boolean, with no started-at fact, and this plugin does not invent one. It also **never touches the seen watermark** — running is a live fact, not a queue, so there is no "mark all read". With nothing running the yellow number is not shown at all, so there is no entry point to click.
|
|
54
55
|
- Above 99 it shows `99+`, the same rule as the red count; **the two numbers are always the same size** — when both appear they shrink together to the largest pair that fits, stacked and never overlapping (at a 16px tab icon or a 24px logo two digits are already tight: a big one beside a small one, or two overlapping, would make both unreadable), and a number shown alone keeps the normal size (so the small/medium/large setting applies only while a number is on its own — with both shown, all three sizes draw the same).
|
|
56
|
+
- **Clicking a row also locates it in the sidebar**: the session opens AND, in the browsing list, its workspace expands, the row scrolls into view and flashes once (~1s) so you can see where it went. The official sidebar has this exact flow, but only for **its own search box** — the state behind it is private and no service exposes it (`openSession` only switches the main view) — so this drives the shipped workspace row and "show more" button **the way a click would**. It therefore reads the page structure: a DSH upgrade can break it, and the only symptom is a sidebar that does not move (the session still opens). It has its own switch in **Settings → icon管理**, on by default. Note that the expanded state is remembered by the official sidebar itself, so it will not collapse again on its own, and this feature only ever expands — never collapses — a workspace.
|
|
55
57
|
- The switch is in **Settings → icon管理 → Running marks** and is **on by default**.
|
|
56
58
|
|
|
57
59
|
> Why is the red count top-right and the yellow dot bottom-right instead of simply recolouring the official grey spinner? See [`docs/adr/0010-running-mark-is-a-second-projection.md`](./docs/adr/0010-running-mark-is-a-second-projection.md).
|
package/README.zh.md
CHANGED
|
@@ -65,7 +65,7 @@ SVG 会在浏览器里先栅格化成 512×512 PNG 再上传,ICO 则由后端提
|
|
|
65
65
|
- 数据全部来自官方:`turn/end` 事件经官方 session projection 折叠,加上官方的"等待交互"注册表——**不依赖任何其他插件**。
|
|
66
66
|
- 设置页的**数据来源**可以切到"手动测试",手填数字试看效果。
|
|
67
67
|
- **工作区和会话行上的红点(实验)**:侧栏每个**工作区**那一行的文件夹图标右上角一个小红点(收起的工作区里有多少未读,就看它),每个**有未读的会话**在标题右边跟一个小红点——一眼看出"事情在哪个工作区的哪个会话"。定位走 DSH 自己的 `data-row-key`(工作区、会话都按 **id**,不靠标题文字):标题重复不会串行,**折叠起来的会话不单独画点**——它的行没有渲染,由工作区那一行的点和「待处理」清单代表它。这一项是**贴着页面结构**做的——DSH 大版本升级后**可能失效**,失效时只会变成"不显示",**不影响**红点数字、清单和跳转;设置页里有一个开关可以随时关掉它。
|
|
68
|
-
- **想知道具体是哪个会话?** 侧栏「工作区」标题的**右上角**有一个**红底数字**(没有待处理时是灰色 0,位置不变);点开是待处理会话清单,**按工作区分组**(工作区名 · 会话名 · 在等你/刚结束)
|
|
68
|
+
- **想知道具体是哪个会话?** 侧栏「工作区」标题的**右上角**有一个**红底数字**(没有待处理时是灰色 0,位置不变);点开是待处理会话清单,**按工作区分组**(工作区名 · 会话名 · 在等你/刚结束),点其中一条**直接跳到那个会话**,并**在侧栏定位到它**(自动展开所在工作区、滚到那一行、闪一下;见下面的「定位」一节)。
|
|
69
69
|
|
|
70
70
|
## 进行中黄点
|
|
71
71
|
|
|
@@ -75,11 +75,21 @@ SVG 会在浏览器里先栅格化成 512×512 PNG 再上传,ICO 则由后端提
|
|
|
75
75
|
- **黄点就是官方的「进行中」**:会话有回合正在跑、而且没有停下来等你——你停下等审批 / 回答 / 计划待审的时候,官方那一行显示的是琥珀色标记,黄点也就不亮。它和官方行首那颗**灰色转圈**同进同出,差别只是颜色:官方做得安静,这里做得看得见。
|
|
76
76
|
- **事实全部来自官方**:会话列表里每个会话的 `running`(宿主算好、实时推给浏览器),插件不落盘、不推断。所以刷新页面立刻就是对的,**进程重启也不会留下一个永远亮着的黄点**。
|
|
77
77
|
- **子代理不算**:子代理会话根本不在侧栏里显示,它的运行不会让黄点亮起来(与红点同一条规矩,也和官方那一行一致)。
|
|
78
|
+
- **黄数字点得开**:点它就弹出**进行中的会话**清单,按工作区分组(工作区名 · 会话名 · 正在跑 · 会话短号),点其中一条**直接跳到那个会话**,并**在侧栏定位到它**(同红字清单:展开工作区 + 滚到那一行 + 闪一下,设置里可关)。它和红数字的「待处理」清单**共用同一块浮层**,开一个另一个自动关——两个数字在同一行,同时开就是两层叠在一起。清单里**没有"跑了多久"**:官方只给 `running` 一个布尔,没有"开跑时刻",插件不编造;它也**不碰已读水位线**——进行中是活的事实,不是待办队列,所以没有"全部标记已读"这一项。没有会话在跑时黄数字本来就不显示,也就没有可点的入口。
|
|
78
79
|
- 数字超过 99 显示 `99+`,和红点角标同一条规矩;**两颗数字徽标一样大**:同时出现时它们一起缩到能容下的尺寸、上下排开不重叠(16px 标签页与 24px logo 上两个数字本来就挤,画成一大一小或叠在一起都会读不出来),只有一颗时保持原来的大小(也就是说"红点大小"那三档只在单独出现时生效,两颗同框时三档一样)。
|
|
79
80
|
- 开关在**设置 → icon管理 → 进行中黄点**,**默认打开**。
|
|
80
81
|
|
|
81
82
|
> 为什么红在上、黄在下,而不是干脆把官方那颗灰转圈染黄?见 [`docs/adr/0010-running-mark-is-a-second-projection.md`](./docs/adr/0010-running-mark-is-a-second-projection.md)。
|
|
82
83
|
|
|
84
|
+
## 定位(点清单里的会话之后)
|
|
85
|
+
|
|
86
|
+
点「待处理」或「进行中」清单里的任意一条(见上面两节),除了**打开那个会话**,插件还会在侧栏**把它找出来**:展开它所在的工作区、滚到那一行、并**闪一下**(约 1 秒),免得你切过去之后不知道它在哪。
|
|
87
|
+
|
|
88
|
+
- **官方只开了半个口子**:侧栏自己有一套"展开 + 滚动",但它只服务于侧栏自己的**搜索框**,而且那套状态是它内部的,没有对插件开放的接口(`openSession` 只管切换主画面)。所以这一项是**模拟点击**官方的工作区行与「显示更多」按钮做到的。
|
|
89
|
+
- **因此它贴产品结构**:DSH 大版本升级后可能失效。失效的表现只有一个——**点进去照常打开会话,只是侧栏不动**。它有自己的开关(设置 → icon管理 → 未读红点 那一节里),**默认开**,随时可关。
|
|
90
|
+
- **展开是官方自己记住的**:那个工作区的展开状态由 DSH 存在浏览器里,**不会自己收回去**;不想留着就手动点一下收起。官方自己的搜索跳转也是这个行为。
|
|
91
|
+
- **它不会做过头**:只展开、不收起;目标会话被折叠得很深、一次放不出来时,最多点几次「显示更多」就停手(会话照样已经打开了)。
|
|
92
|
+
|
|
83
93
|
## 应用图标角标(系统图标上的红点)
|
|
84
94
|
|
|
85
95
|
把网页**安装为应用**后,同一个未读数字也会显示在**系统图标**上。设置在**设置 → icon管理 → 未读红点**里的「在系统应用图标上显示红点」,**默认打开**;数字与页内红点**严格同值**(同一份计算,不额外截断),所以两个地方永远一致。
|
package/client/client.js
CHANGED
|
@@ -62,6 +62,11 @@ window.__ModuleLoader__.load({
|
|
|
62
62
|
unreadEnded: "刚结束,还没看",
|
|
63
63
|
unreadPanelOther: "其他",
|
|
64
64
|
unreadMarkAllRead: "全部标记已读",
|
|
65
|
+
/** 进行中清单(点黄字打开):和那个黄数字是同一个事实,不落盘、不推断。 */
|
|
66
|
+
runningPanelTitle: "进行中的会话",
|
|
67
|
+
runningPanelEmpty: "现在没有会话在运行",
|
|
68
|
+
runningPanelTrigger: "{n} 个进行中,点击查看",
|
|
69
|
+
runningPanelRow: "回合正在跑",
|
|
65
70
|
/** 与官方侧栏同一形状("13小时"),方便和列表里的时间戳逐字对上。 */
|
|
66
71
|
ageNow: "刚刚",
|
|
67
72
|
ageMinutes: "{n}分钟",
|
|
@@ -71,6 +76,8 @@ window.__ModuleLoader__.load({
|
|
|
71
76
|
ageYears: "{n}年",
|
|
72
77
|
workspaceDotLabel: "在工作区和会话行上显示红点(实验)",
|
|
73
78
|
workspaceDotHint: "在侧栏工作区那一行的文件夹图标上点一个小红点(收起的工作区里有多少未读,就看它),有未读的会话在其标题右边也跟一个小红点。会话行上的点只在那一行真的显示着时才画:折叠起来的会话由工作区那一行的点和「待处理」清单代表。这一项是贴着页面结构做的,DSH 升级后可能失效;失效时只会变成\"不显示\",不会影响红点数字、清单、跳转这些功能,随时可以关掉它。",
|
|
79
|
+
revealLabel: "点清单里的会话时,在侧栏定位到它(实验)",
|
|
80
|
+
revealHint: "点「待处理」或「进行中」清单里的一条时,除了打开那个会话,还会在侧栏把它所在的工作区展开、滚到那一行、并闪一下。这是贴着页面结构做的:官方只把\"展开 + 滚动\"留给了它自己的搜索框,没有给插件留接口,所以这里只能模拟点击官方的工作区行和「显示更多」。DSH 升级后可能失效,失效的表现只是\"侧栏不动\",打开会话完全不受影响。**展开状态是官方自己记住的**(存在浏览器里),所以它不会自己收回去——不想要就手动收起那个工作区,或者关掉这个开关。",
|
|
74
81
|
appBadgeLabel: "在系统应用图标上显示红点",
|
|
75
82
|
appBadgeHint: "安装为应用后,把同一个数字也放到系统图标上:Windows 是任务栏图标上的角标,macOS 是 Dock 角标,iOS 是主屏图标上的数字。样子由系统决定——Windows 上 Chrome 画深色圆+白字,Edge 走 Windows 系统徽章通道(是否显示数字由 Edge/Windows 决定);插件会周期重设数字,但只在应用窗口开着时更新。Safari 的程序坞网页应用 / 主屏应用还要先允许通知(见下面的「通知权限」),否则角标不会出现。",
|
|
76
83
|
appBadgeFootnote: "角标只在应用窗口开着时由本插件更新;数字超过 99 时由系统显示为 99+。",
|
|
@@ -148,6 +155,11 @@ window.__ModuleLoader__.load({
|
|
|
148
155
|
unreadEnded: "ended, not seen yet",
|
|
149
156
|
unreadPanelOther: "Other",
|
|
150
157
|
unreadMarkAllRead: "Mark all read",
|
|
158
|
+
/** The running list (opened by the yellow number): the very fact that number counts. */
|
|
159
|
+
runningPanelTitle: "Sessions running",
|
|
160
|
+
runningPanelEmpty: "Nothing is running right now",
|
|
161
|
+
runningPanelTrigger: "{n} running — click for the list",
|
|
162
|
+
runningPanelRow: "turn in flight",
|
|
151
163
|
/** Same shape as the shipped sidebar ("13h"), so the two can be read side by side. */
|
|
152
164
|
ageNow: "now",
|
|
153
165
|
ageMinutes: "{n}min",
|
|
@@ -157,6 +169,8 @@ window.__ModuleLoader__.load({
|
|
|
157
169
|
ageYears: "{n}y",
|
|
158
170
|
workspaceDotLabel: "Dots on workspace and session rows (experimental)",
|
|
159
171
|
workspaceDotHint: "Adds a small red dot to the folder icon of each workspace row (a collapsed workspace shows how many of its sessions are unread there), and one beside the title of every unread session row. A session dot is only drawn while that row is really rendered: folded sessions are represented by their workspace row's dot and the pending panel. This one reads the page structure, so a DSH upgrade may break it; when it does it simply stops showing, never affecting the counts, the list, or navigation. Turn it off any time.",
|
|
172
|
+
revealLabel: "Locate the session in the sidebar when a list row is clicked (experimental)",
|
|
173
|
+
revealHint: "Clicking a row in the \"needing you\" or \"running\" list opens that session AND, in the sidebar, expands its workspace, scrolls to the row and flashes it once. This reads the page structure: the official sidebar keeps expand-and-scroll for its own search box and exposes no interface for it, so this drives the shipped workspace row and \"show more\" button the way a click would. A DSH upgrade may break it, and the only symptom is a sidebar that does not move — opening the session is never affected. **The expanded state is remembered by the official sidebar itself** (in the browser), so it will not collapse again on its own: collapse that workspace by hand, or turn this off.",
|
|
160
174
|
appBadgeLabel: "Badge the system app icon",
|
|
161
175
|
appBadgeHint: "Once installed as an app, the same number also goes to the system icon: a taskbar badge on Windows, a Dock badge on macOS, a number on the iOS home-screen icon. The system decides how it looks — on Windows, Chrome draws a dark circle with white text, while Edge goes through the Windows badge channel (whether it shows the number is up to Edge/Windows). The plugin re-asserts the number periodically, but only while the app window is open. Safari's Dock and home-screen web apps additionally need notifications allowed first (see the permission row below), or the badge never appears.",
|
|
162
176
|
appBadgeFootnote: "The badge is only updated by this plugin while the app window is open; above 99 the system shows 99+.",
|
|
@@ -297,7 +311,9 @@ window.__ModuleLoader__.load({
|
|
|
297
311
|
const UNREAD_CLEAR_DELAY_MAX_SEC = 600;
|
|
298
312
|
/** Mirror of the host's default (see lib/unread.js) — badging the app icon is on. */
|
|
299
313
|
const UNREAD_APP_BADGE_FALLBACK = true;
|
|
300
|
-
|
|
314
|
+
/** Mirror of the host's default (see lib/unread.js) — locating a clicked row is on. */
|
|
315
|
+
const UNREAD_REVEAL_FALLBACK = true;
|
|
316
|
+
let unreadConfig = { reasons: { ...UNREAD_FALLBACK_REASONS }, pending: true, workspaceDot: true, appBadge: UNREAD_APP_BADGE_FALLBACK, clearDelaySec: 0, revealOnOpen: UNREAD_REVEAL_FALLBACK };
|
|
301
317
|
const unreadConfigListeners = new Set();
|
|
302
318
|
/** Mirror of the host's normalizer: any input becomes an integer 0..cap seconds. */
|
|
303
319
|
function normalizeClearDelay(input) {
|
|
@@ -319,7 +335,8 @@ window.__ModuleLoader__.load({
|
|
|
319
335
|
pending: typeof source.pending === "boolean" ? source.pending : true,
|
|
320
336
|
workspaceDot: typeof source.workspaceDot === "boolean" ? source.workspaceDot : true,
|
|
321
337
|
appBadge: typeof source.appBadge === "boolean" ? source.appBadge : UNREAD_APP_BADGE_FALLBACK,
|
|
322
|
-
clearDelaySec: normalizeClearDelay(source.clearDelaySec)
|
|
338
|
+
clearDelaySec: normalizeClearDelay(source.clearDelaySec),
|
|
339
|
+
revealOnOpen: typeof source.revealOnOpen === "boolean" ? source.revealOnOpen : UNREAD_REVEAL_FALLBACK
|
|
323
340
|
};
|
|
324
341
|
}
|
|
325
342
|
function emitUnreadConfig(input) {
|
|
@@ -713,6 +730,23 @@ window.__ModuleLoader__.load({
|
|
|
713
730
|
const value = values !== null && typeof values === "object" ? values[LAST_TURN_END_KEY] : undefined;
|
|
714
731
|
return value !== null && typeof value === "object" && typeof value.endAt === "number" ? value.endAt : 0;
|
|
715
732
|
}
|
|
733
|
+
/**
|
|
734
|
+
* Which workspace a session is shown under: the basename of its cwd.
|
|
735
|
+
*
|
|
736
|
+
* The sidebar's own workspace rows are labelled with this basename, so a list
|
|
737
|
+
* points at the same place the reader sees — the sidebar group cannot be
|
|
738
|
+
* expanded for us (that state is private to the shipped browser), so naming
|
|
739
|
+
* it is the honest substitute. Shared by the pending list and the running
|
|
740
|
+
* list: two lists of the same sessions must not disagree about where they are.
|
|
741
|
+
* @param entry - the session-list row (may be absent).
|
|
742
|
+
* @returns the basename, or "" when the row carries no cwd.
|
|
743
|
+
*/
|
|
744
|
+
function sessionWhereOf(entry) {
|
|
745
|
+
const cwd = entry !== null && typeof entry === "object" && typeof entry.cwd === "string" ? entry.cwd : "";
|
|
746
|
+
if (cwd === "") return "";
|
|
747
|
+
const parts = cwd.replace(/[\\/]+$/, "").split(/[\\/]/);
|
|
748
|
+
return parts[parts.length - 1] || cwd;
|
|
749
|
+
}
|
|
716
750
|
/**
|
|
717
751
|
* How many sessions deserve the badge right now: endings whose reason is
|
|
718
752
|
* enabled and that this browser has not seen since, plus sessions waiting
|
|
@@ -740,16 +774,6 @@ window.__ModuleLoader__.load({
|
|
|
740
774
|
const ids = new Set();
|
|
741
775
|
const byId = list !== null && typeof list === "object" && list.byId !== null && typeof list.byId === "object" ? list.byId : {};
|
|
742
776
|
const titleOf = (id, entry) => (entry !== null && typeof entry === "object" && typeof entry.displayTitle === "string" && entry.displayTitle !== "" ? entry.displayTitle : id);
|
|
743
|
-
// Which workspace the session lives in. The sidebar's own workspace rows are
|
|
744
|
-
// labelled with this cwd basename, so the list points at the same place the
|
|
745
|
-
// reader sees — the sidebar group cannot be expanded for us (that state is
|
|
746
|
-
// private to the shipped browser), so naming it is the honest substitute.
|
|
747
|
-
const whereOf = (entry) => {
|
|
748
|
-
const cwd = entry !== null && typeof entry === "object" && typeof entry.cwd === "string" ? entry.cwd : "";
|
|
749
|
-
if (cwd === "") return "";
|
|
750
|
-
const parts = cwd.replace(/[\\/]+$/, "").split(/[\\/]/);
|
|
751
|
-
return parts[parts.length - 1] || cwd;
|
|
752
|
-
};
|
|
753
777
|
if (list !== null && typeof list === "object") {
|
|
754
778
|
const order = Array.isArray(list.ids) ? list.ids : Object.keys(byId);
|
|
755
779
|
for (const id of order) {
|
|
@@ -766,7 +790,7 @@ window.__ModuleLoader__.load({
|
|
|
766
790
|
// turns them on, so a vocabulary growth can never surprise the badge.
|
|
767
791
|
if (reasons[value.reason] !== true) continue;
|
|
768
792
|
ids.add(id);
|
|
769
|
-
hits.push({ id, title: titleOf(id, entry), where:
|
|
793
|
+
hits.push({ id, title: titleOf(id, entry), where: sessionWhereOf(entry), waiting: false, kind: value.reason, at: endAt });
|
|
770
794
|
}
|
|
771
795
|
}
|
|
772
796
|
if (config === null || typeof config !== "object" || config.pending !== false) {
|
|
@@ -780,7 +804,7 @@ window.__ModuleLoader__.load({
|
|
|
780
804
|
return;
|
|
781
805
|
}
|
|
782
806
|
ids.add(id);
|
|
783
|
-
hits.push({ id, title: titleOf(id, byId[id]), where:
|
|
807
|
+
hits.push({ id, title: titleOf(id, byId[id]), where: sessionWhereOf(byId[id]), waiting: true, kind, at: Date.now() });
|
|
784
808
|
});
|
|
785
809
|
}
|
|
786
810
|
}
|
|
@@ -887,6 +911,8 @@ window.__ModuleLoader__.load({
|
|
|
887
911
|
* being shown.
|
|
888
912
|
*/
|
|
889
913
|
let runningTotal = 0;
|
|
914
|
+
/** The sessions behind `runningTotal` (`{ id, title, where }`), for the running list. */
|
|
915
|
+
let runningItems = [];
|
|
890
916
|
let manualCount = 0;
|
|
891
917
|
let badgeSize = "md";
|
|
892
918
|
const badgeListeners = new Set();
|
|
@@ -894,11 +920,30 @@ window.__ModuleLoader__.load({
|
|
|
894
920
|
const n = Number(value);
|
|
895
921
|
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
|
|
896
922
|
}
|
|
923
|
+
/**
|
|
924
|
+
* A stable identity for one running list.
|
|
925
|
+
*
|
|
926
|
+
* The number alone is not enough: with one session running, handing over to a
|
|
927
|
+
* DIFFERENT session keeps the count at 1, so a guard that only compares counts
|
|
928
|
+
* leaves the open list naming the session that already stopped — the mark and
|
|
929
|
+
* the tab icon stay right while the list lies. Same reason the sidebar marks
|
|
930
|
+
* key off `runningSignature`: compare the list by value.
|
|
931
|
+
* @param items - `{ id, title, where }` rows (anything else is ignored).
|
|
932
|
+
* @returns a string that changes whenever the list does.
|
|
933
|
+
*/
|
|
934
|
+
function runningItemsSignature(items) {
|
|
935
|
+
const out = [];
|
|
936
|
+
for (const item of Array.isArray(items) ? items : []) {
|
|
937
|
+
if (item === null || typeof item !== "object") continue;
|
|
938
|
+
out.push(`${item.id}\u0000${item.title}\u0000${item.where}`);
|
|
939
|
+
}
|
|
940
|
+
return out.join("\u0001");
|
|
941
|
+
}
|
|
897
942
|
function effectiveCount() {
|
|
898
943
|
return badgeSource === "manual" ? manualCount : realCount;
|
|
899
944
|
}
|
|
900
945
|
function badgeSnapshot() {
|
|
901
|
-
return { count: effectiveCount(), size: badgeSize, source: badgeSource, items: badgeSource === "manual" ? [] : realItems, running: runningTotal };
|
|
946
|
+
return { count: effectiveCount(), size: badgeSize, source: badgeSource, items: badgeSource === "manual" ? [] : realItems, running: runningTotal, runningItems };
|
|
902
947
|
}
|
|
903
948
|
function notifyBadge() {
|
|
904
949
|
const snapshot = badgeSnapshot();
|
|
@@ -921,7 +966,8 @@ window.__ModuleLoader__.load({
|
|
|
921
966
|
notifyBadge();
|
|
922
967
|
}
|
|
923
968
|
/**
|
|
924
|
-
* The running sessions, as one number on the same bridge
|
|
969
|
+
* The running sessions, as one number on the same bridge — plus the list
|
|
970
|
+
* behind it, so the yellow count can be opened like the red one.
|
|
925
971
|
*
|
|
926
972
|
* Emitted from every render of the headless badge source, so the "did it
|
|
927
973
|
* actually change" guard is what keeps a re-render from repainting the tab
|
|
@@ -929,11 +975,14 @@ window.__ModuleLoader__.load({
|
|
|
929
975
|
* `emitRealBadge`: the unread number and the running number are independent
|
|
930
976
|
* facts, and a manual test number must not disturb the other.
|
|
931
977
|
* @param value - how many sessions are running; anything non-positive is 0.
|
|
978
|
+
* @param items - `{ id, title, where }`, one per running session, in list order.
|
|
932
979
|
*/
|
|
933
|
-
function emitRunningState(value) {
|
|
980
|
+
function emitRunningState(value, items) {
|
|
934
981
|
const next = normalizeCount(value);
|
|
935
|
-
|
|
982
|
+
const nextItems = Array.isArray(items) ? items : [];
|
|
983
|
+
if (next === runningTotal && runningItemsSignature(nextItems) === runningItemsSignature(runningItems)) return;
|
|
936
984
|
runningTotal = next;
|
|
985
|
+
runningItems = nextItems;
|
|
937
986
|
notifyBadge();
|
|
938
987
|
}
|
|
939
988
|
function emitBadgeSize(value) {
|
|
@@ -1618,22 +1667,38 @@ window.__ModuleLoader__.load({
|
|
|
1618
1667
|
//#endregion
|
|
1619
1668
|
|
|
1620
1669
|
//#region unread panel
|
|
1670
|
+
//#region header panel
|
|
1621
1671
|
/**
|
|
1622
|
-
*
|
|
1623
|
-
*
|
|
1672
|
+
* Which list is open, and where the button that opened it sits.
|
|
1673
|
+
*
|
|
1674
|
+
* One store for both lists, and one open slot: the two numbers sit in the same
|
|
1675
|
+
* header row, so two panels open at once would stack on top of each other
|
|
1676
|
+
* under the same anchor. `mode` is therefore "unread" | "running" | null, and
|
|
1677
|
+
* opening either closes the other by construction.
|
|
1678
|
+
*
|
|
1679
|
+
* The buttons and the floating list live in two different slots, so they
|
|
1624
1680
|
* cannot share React state — this tiny store is what they agree through.
|
|
1625
1681
|
*/
|
|
1626
|
-
let
|
|
1627
|
-
let
|
|
1628
|
-
const
|
|
1629
|
-
function
|
|
1630
|
-
|
|
1631
|
-
if (anchor !== undefined)
|
|
1632
|
-
|
|
1682
|
+
let panelMode = null;
|
|
1683
|
+
let panelAnchor = null;
|
|
1684
|
+
const panelListeners = new Set();
|
|
1685
|
+
function emitPanel(mode, anchor) {
|
|
1686
|
+
panelMode = mode === "unread" || mode === "running" ? mode : null;
|
|
1687
|
+
if (anchor !== undefined) panelAnchor = anchor;
|
|
1688
|
+
panelListeners.forEach((fn) => { try { fn(); } catch {} });
|
|
1633
1689
|
}
|
|
1634
|
-
function
|
|
1635
|
-
|
|
1636
|
-
return () => {
|
|
1690
|
+
function subscribePanel(fn) {
|
|
1691
|
+
panelListeners.add(fn);
|
|
1692
|
+
return () => { panelListeners.delete(fn); };
|
|
1693
|
+
}
|
|
1694
|
+
/**
|
|
1695
|
+
* One marker's button: open its list, or close it when it is already the open
|
|
1696
|
+
* one. Clicking the OTHER marker switches lists instead of stacking them.
|
|
1697
|
+
* @param mode - "unread" (the red count) or "running" (the yellow count).
|
|
1698
|
+
* @param anchor - the clicked node's viewport rect, or undefined to keep the last one.
|
|
1699
|
+
*/
|
|
1700
|
+
function togglePanel(mode, anchor) {
|
|
1701
|
+
emitPanel(panelMode === mode ? null : mode, anchor === undefined ? null : anchor);
|
|
1637
1702
|
}
|
|
1638
1703
|
/**
|
|
1639
1704
|
* Tells the badge source that the marks moved somewhere it did not touch —
|
|
@@ -1648,9 +1713,6 @@ window.__ModuleLoader__.load({
|
|
|
1648
1713
|
unreadPokeListeners.add(fn);
|
|
1649
1714
|
return () => { unreadPokeListeners.delete(fn); };
|
|
1650
1715
|
}
|
|
1651
|
-
function toggleUnreadPanel(anchor) {
|
|
1652
|
-
emitUnreadPanel(!unreadPanelOpen, anchor === undefined ? null : anchor);
|
|
1653
|
-
}
|
|
1654
1716
|
/**
|
|
1655
1717
|
* Roll the pending sessions up to one dot per workspace.
|
|
1656
1718
|
*
|
|
@@ -1778,6 +1840,22 @@ window.__ModuleLoader__.load({
|
|
|
1778
1840
|
if (short !== "") parts.push(short);
|
|
1779
1841
|
return parts.join(" · ");
|
|
1780
1842
|
}
|
|
1843
|
+
/**
|
|
1844
|
+
* The dim second line of one running row: that it is running, and which
|
|
1845
|
+
* session it is. Deliberately no age — the official session list carries no
|
|
1846
|
+
* "started at" fact (only `running: boolean`), and this plugin would rather
|
|
1847
|
+
* print less than invent a time. Shape matches `unreadRowSubtitle` so the two
|
|
1848
|
+
* lists read as one panel with two modes.
|
|
1849
|
+
* @param item - a row from the badge bridge's `runningItems`.
|
|
1850
|
+
* @param t - locale binder.
|
|
1851
|
+
* @returns the line, already joined.
|
|
1852
|
+
*/
|
|
1853
|
+
function runningRowSubtitle(item, t) {
|
|
1854
|
+
const parts = [t("runningPanelRow")];
|
|
1855
|
+
const short = shortSessionId(item.id);
|
|
1856
|
+
if (short !== "") parts.push(short);
|
|
1857
|
+
return parts.join(" · ");
|
|
1858
|
+
}
|
|
1781
1859
|
/**
|
|
1782
1860
|
* What "mark all read" may actually acknowledge: the turn-end rows.
|
|
1783
1861
|
*
|
|
@@ -1798,6 +1876,175 @@ window.__ModuleLoader__.load({
|
|
|
1798
1876
|
}
|
|
1799
1877
|
return targets;
|
|
1800
1878
|
}
|
|
1879
|
+
//#region reveal in sidebar
|
|
1880
|
+
/**
|
|
1881
|
+
* Locating a clicked session in the sidebar: expand its workspace, scroll to the
|
|
1882
|
+
* row, light it up for a moment.
|
|
1883
|
+
*
|
|
1884
|
+
* The official sidebar does exactly this by itself — but ONLY for its own search
|
|
1885
|
+
* results. The state behind that (`revealSessionId`) is private to that component
|
|
1886
|
+
* and no Service exposes it, while `uiWorkspace.openSession` switches the main
|
|
1887
|
+
* view and nothing else. So this drives the shipped rows the way a user's click
|
|
1888
|
+
* would: the workspace row toggles its group, the "show more" button raises the
|
|
1889
|
+
* collapsed-session limit.
|
|
1890
|
+
*
|
|
1891
|
+
* It therefore reads the page structure, and it is DECORATIVE: by the time it runs
|
|
1892
|
+
* the session is already open. Moved markup (a DSH upgrade), a rail sidebar or an
|
|
1893
|
+
* open search box all degrade to "the sidebar did not move" — never to an error,
|
|
1894
|
+
* and never to a blocked click.
|
|
1895
|
+
*/
|
|
1896
|
+
const REVEAL_ROW_KEY_ATTR = "data-row-key";
|
|
1897
|
+
const REVEAL_FLASH_ATTR = "data-icon-custom-flash";
|
|
1898
|
+
const REVEAL_FLASH_STYLE_ID = "dsh-icon-custom-flash-css";
|
|
1899
|
+
/** How long the located row stays lit. */
|
|
1900
|
+
const REVEAL_FLASH_MS = 1200;
|
|
1901
|
+
/**
|
|
1902
|
+
* How many DOM steps one reveal may take.
|
|
1903
|
+
*
|
|
1904
|
+
* The official flow does this in one shot by writing its own state; we only have
|
|
1905
|
+
* clicks, and every click needs a commit before the row can exist. The realistic
|
|
1906
|
+
* worst case is a collapsed workspace whose session sits past "show more", which
|
|
1907
|
+
* releases five rows per click — so this covers a workspace of roughly twenty
|
|
1908
|
+
* sessions and then stops, rather than driving a shipped control in a loop.
|
|
1909
|
+
*/
|
|
1910
|
+
const REVEAL_MAX_STEPS = 6;
|
|
1911
|
+
/**
|
|
1912
|
+
* The flash rules: a short outline pulse, not a lasting class. The point is to say
|
|
1913
|
+
* "here it is", not to leave a mark on a shipped row.
|
|
1914
|
+
*/
|
|
1915
|
+
const REVEAL_FLASH_CSS = [
|
|
1916
|
+
`[${REVEAL_FLASH_ATTR}]{animation:dsh-icon-custom-flash ${REVEAL_FLASH_MS}ms ease-out}`,
|
|
1917
|
+
`@keyframes dsh-icon-custom-flash{0%{outline:2px solid var(--dsw-alias-state-business-primary,#4d6bfe);outline-offset:-2px}100%{outline:2px solid transparent;outline-offset:-2px}}`,
|
|
1918
|
+
`@media (prefers-reduced-motion:reduce){[${REVEAL_FLASH_ATTR}]{animation:none;outline:2px solid var(--dsw-alias-state-business-primary,#4d6bfe);outline-offset:-2px}}`
|
|
1919
|
+
].join("");
|
|
1920
|
+
/** The row currently lit, and the timer that will un-light it. */
|
|
1921
|
+
let revealFlashRow = null;
|
|
1922
|
+
let revealFlashTimer = 0;
|
|
1923
|
+
|
|
1924
|
+
/**
|
|
1925
|
+
* What to do next to make one session's row visible.
|
|
1926
|
+
*
|
|
1927
|
+
* Pure, so the order — and the giving up — is testable without a browser: a row
|
|
1928
|
+
* that is already there gets scrolled to; otherwise its workspace is expanded;
|
|
1929
|
+
* otherwise "show more" is clicked; otherwise nothing is touched at all.
|
|
1930
|
+
* @param state - `{ hasRow, hasWorkspace, expanded, hasOverflow, overflowExpanded, steps }`.
|
|
1931
|
+
* @returns `"scroll" | "expand" | "overflow" | "give-up"`.
|
|
1932
|
+
*/
|
|
1933
|
+
function revealAction(state) {
|
|
1934
|
+
if (state.hasRow === true) return "scroll";
|
|
1935
|
+
if (state.steps >= REVEAL_MAX_STEPS) return "give-up";
|
|
1936
|
+
if (state.hasWorkspace === true && state.expanded !== true) return "expand";
|
|
1937
|
+
// `overflowExpanded` must be false: that button toggles, and this feature has
|
|
1938
|
+
// no business COLLAPSING a workspace the reader deliberately opened up.
|
|
1939
|
+
if (state.hasOverflow === true && state.overflowExpanded !== true) return "overflow";
|
|
1940
|
+
return "give-up";
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
/**
|
|
1944
|
+
* The shipped row carrying one `data-row-key`, matched by VALUE.
|
|
1945
|
+
*
|
|
1946
|
+
* Not by a selector built from the key: session ids go into the key verbatim, so a
|
|
1947
|
+
* selector would need escaping rules that a plain comparison simply does not have.
|
|
1948
|
+
* @param container - the browsing region root.
|
|
1949
|
+
* @param key - e.g. `session:<id>` / `workspace:<id>` / `overflow:<id>`.
|
|
1950
|
+
* @returns the element, or null.
|
|
1951
|
+
*/
|
|
1952
|
+
function findRowByKey(container, key) {
|
|
1953
|
+
try {
|
|
1954
|
+
for (const node of container.querySelectorAll(`[${REVEAL_ROW_KEY_ATTR}]`)) {
|
|
1955
|
+
if (node.getAttribute(REVEAL_ROW_KEY_ATTR) === key) return node;
|
|
1956
|
+
}
|
|
1957
|
+
} catch { /* a broken subtree must not become a thrown click */ }
|
|
1958
|
+
return null;
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
/** One paint's worth of waiting, so a click can commit before the row is looked for. */
|
|
1962
|
+
function nextPaint() {
|
|
1963
|
+
return new Promise((resolve) => {
|
|
1964
|
+
try {
|
|
1965
|
+
if (typeof window.requestAnimationFrame === "function") window.requestAnimationFrame(() => resolve());
|
|
1966
|
+
else window.setTimeout(resolve, 16);
|
|
1967
|
+
} catch { resolve(); }
|
|
1968
|
+
});
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
/** Install the flash rules once per page. */
|
|
1972
|
+
function ensureRevealFlashStyle() {
|
|
1973
|
+
try {
|
|
1974
|
+
if (document.getElementById(REVEAL_FLASH_STYLE_ID) !== null) return;
|
|
1975
|
+
const style = document.createElement("style");
|
|
1976
|
+
style.id = REVEAL_FLASH_STYLE_ID;
|
|
1977
|
+
style.textContent = REVEAL_FLASH_CSS;
|
|
1978
|
+
document.head.appendChild(style);
|
|
1979
|
+
} catch { /* styling is cosmetic; never let it reach a click */ }
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
/** Take the flash off, whoever put it there. Also the unload path. */
|
|
1983
|
+
function clearRevealFlash() {
|
|
1984
|
+
if (revealFlashTimer !== 0) {
|
|
1985
|
+
try { window.clearTimeout(revealFlashTimer); } catch { /* the page is going away */ }
|
|
1986
|
+
revealFlashTimer = 0;
|
|
1987
|
+
}
|
|
1988
|
+
if (revealFlashRow !== null) {
|
|
1989
|
+
try { revealFlashRow.removeAttribute(REVEAL_FLASH_ATTR); } catch { /* already detached */ }
|
|
1990
|
+
revealFlashRow = null;
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
/**
|
|
1995
|
+
* Scroll one shipped row into view and light it for a moment.
|
|
1996
|
+
*
|
|
1997
|
+
* `block:"nearest"` is the official call and the reason a reveal does not yank the
|
|
1998
|
+
* whole list: it moves the list only as far as the row needs.
|
|
1999
|
+
* @param row - the session row element.
|
|
2000
|
+
*/
|
|
2001
|
+
function flashRow(row) {
|
|
2002
|
+
try {
|
|
2003
|
+
clearRevealFlash();
|
|
2004
|
+
ensureRevealFlashStyle();
|
|
2005
|
+
row.setAttribute(REVEAL_FLASH_ATTR, "1");
|
|
2006
|
+
revealFlashRow = row;
|
|
2007
|
+
revealFlashTimer = window.setTimeout(() => { clearRevealFlash(); }, REVEAL_FLASH_MS);
|
|
2008
|
+
} catch { /* cosmetic */ }
|
|
2009
|
+
try { row.scrollIntoView({ block: "nearest" }); } catch { /* older engines take no options */ }
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
/**
|
|
2013
|
+
* Put one session's row in front of the reader.
|
|
2014
|
+
*
|
|
2015
|
+
* Best effort from the first line to the last: the session is ALREADY open when
|
|
2016
|
+
* this runs, so every exit here — no container, no workspace id, a sidebar that
|
|
2017
|
+
* never renders the row — costs the reader nothing but the scroll.
|
|
2018
|
+
* @param sessionId - the session to locate.
|
|
2019
|
+
* @param workspaceId - its workspace, when the official snapshot knows one.
|
|
2020
|
+
*/
|
|
2021
|
+
async function revealSessionRow(sessionId, workspaceId) {
|
|
2022
|
+
if (typeof sessionId !== "string" || sessionId === "") return;
|
|
2023
|
+
if (unreadConfig === null || typeof unreadConfig !== "object" || unreadConfig.revealOnOpen === false) return;
|
|
2024
|
+
const container = workspaceDotContainer();
|
|
2025
|
+
if (container === null) return;
|
|
2026
|
+
const wsKey = typeof workspaceId === "string" && workspaceId !== "" ? `workspace:${workspaceId}` : null;
|
|
2027
|
+
const moreKey = typeof workspaceId === "string" && workspaceId !== "" ? `overflow:${workspaceId}` : null;
|
|
2028
|
+
for (let steps = 0; steps < REVEAL_MAX_STEPS; steps++) {
|
|
2029
|
+
const row = findRowByKey(container, `session:${sessionId}`);
|
|
2030
|
+
const wsRow = wsKey === null ? null : findRowByKey(container, wsKey);
|
|
2031
|
+
const more = moreKey === null ? null : findRowByKey(container, moreKey);
|
|
2032
|
+
const action = revealAction({
|
|
2033
|
+
hasRow: row !== null,
|
|
2034
|
+
hasWorkspace: wsRow !== null,
|
|
2035
|
+
expanded: wsRow !== null && wsRow.getAttribute("aria-expanded") === "true",
|
|
2036
|
+
hasOverflow: more !== null,
|
|
2037
|
+
overflowExpanded: more !== null && more.getAttribute("aria-expanded") === "true",
|
|
2038
|
+
steps
|
|
2039
|
+
});
|
|
2040
|
+
if (action === "scroll") { flashRow(row); return; }
|
|
2041
|
+
if (action === "give-up") return;
|
|
2042
|
+
const target = action === "expand" ? wsRow : more;
|
|
2043
|
+
if (target === null) return;
|
|
2044
|
+
try { target.click(); } catch { return; }
|
|
2045
|
+
await nextPaint();
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
1801
2048
|
//#endregion
|
|
1802
2049
|
|
|
1803
2050
|
//#region workspace dots
|
|
@@ -2330,6 +2577,36 @@ window.__ModuleLoader__.load({
|
|
|
2330
2577
|
const watching = visible && panelActive !== true;
|
|
2331
2578
|
const currentIds = currentSessionIds(list, { displayed: displayedSessionId() });
|
|
2332
2579
|
const items = collectUnread(list, pending, config, seenState.seen);
|
|
2580
|
+
/**
|
|
2581
|
+
* Map session → its Workspace from the official snapshot: the ID anchors the
|
|
2582
|
+
* folder dot to the keyed row, the title is what the row displays (not always
|
|
2583
|
+
* the cwd basename, and not always equal to the stored title either).
|
|
2584
|
+
*
|
|
2585
|
+
* The same map answers a second question: which workspace row to expand when a
|
|
2586
|
+
* list row is clicked. The pending list only carries the cwd basename, which
|
|
2587
|
+
* cannot be turned back into a workspace id.
|
|
2588
|
+
*/
|
|
2589
|
+
const owners = React.useMemo(() => {
|
|
2590
|
+
const out = {};
|
|
2591
|
+
const workspaceItems = workspaces !== null && typeof workspaces === "object" && Array.isArray(workspaces.items) ? workspaces.items : [];
|
|
2592
|
+
for (const workspace of workspaceItems) {
|
|
2593
|
+
if (workspace === null || typeof workspace !== "object") continue;
|
|
2594
|
+
if (!Array.isArray(workspace.sessionIds)) continue;
|
|
2595
|
+
const id = typeof workspace.workspaceId === "string" ? workspace.workspaceId : "";
|
|
2596
|
+
const title = typeof workspace.title === "string" ? workspace.title : "";
|
|
2597
|
+
for (const sessionId of workspace.sessionIds) out[sessionId] = { id, title };
|
|
2598
|
+
}
|
|
2599
|
+
return out;
|
|
2600
|
+
}, [workspaces]);
|
|
2601
|
+
/**
|
|
2602
|
+
* The pending rows plus the workspace they belong to. A session the official
|
|
2603
|
+
* snapshot places nowhere keeps its row untouched: `revealSessionRow` then has
|
|
2604
|
+
* nothing to expand, which is the honest answer, not a guess.
|
|
2605
|
+
*/
|
|
2606
|
+
const itemsWithOwner = React.useMemo(() => items.map((item) => {
|
|
2607
|
+
const owner = owners[item.id];
|
|
2608
|
+
return owner === undefined || owner.id === "" ? item : { ...item, workspaceId: owner.id };
|
|
2609
|
+
}), [items, owners]);
|
|
2333
2610
|
const count = items.length;
|
|
2334
2611
|
// Being in a session is what marks it read — and the clock that does it is
|
|
2335
2612
|
// armed by ENTERING the session, never by a new ending.
|
|
@@ -2405,15 +2682,7 @@ window.__ModuleLoader__.load({
|
|
|
2405
2682
|
if (timer !== 0) window.clearTimeout(timer);
|
|
2406
2683
|
};
|
|
2407
2684
|
}, [staySignature, delayMs, watching]);
|
|
2408
|
-
React.useEffect(() => { emitRealBadge(count,
|
|
2409
|
-
// The running fact, straight off the same two official props and with no
|
|
2410
|
-
// host round trip at all. `runningSignature` is what the marks effect below
|
|
2411
|
-
// depends on, so a list re-render with the same set of running sessions
|
|
2412
|
-
// does not repaint the sidebar.
|
|
2413
|
-
const runningIds = running.enabled === false ? [] : runningSessionIds(list, status);
|
|
2414
|
-
const runningSignature = runningIds.join("|");
|
|
2415
|
-
const runningCount = runningIds.length;
|
|
2416
|
-
React.useEffect(() => { emitRunningState(runningCount); }, [runningCount]);
|
|
2685
|
+
React.useEffect(() => { emitRealBadge(count, itemsWithOwner); }, [count, itemsWithOwner]);
|
|
2417
2686
|
/**
|
|
2418
2687
|
* One session's displayed title, the way the sidebar shows it.
|
|
2419
2688
|
*
|
|
@@ -2423,32 +2692,35 @@ window.__ModuleLoader__.load({
|
|
|
2423
2692
|
* title has not landed yet would get no yellow dot at all.
|
|
2424
2693
|
*/
|
|
2425
2694
|
const titleOf = (id, entry) => (entry !== null && typeof entry === "object" && typeof entry.displayTitle === "string" && entry.displayTitle !== "" ? entry.displayTitle : id);
|
|
2426
|
-
//
|
|
2427
|
-
//
|
|
2428
|
-
//
|
|
2695
|
+
// The running fact, straight off the same two official props and with no
|
|
2696
|
+
// host round trip at all. `runningSignature` is what the marks effect below
|
|
2697
|
+
// depends on, so a list re-render with the same set of running sessions
|
|
2698
|
+
// does not repaint the sidebar.
|
|
2699
|
+
const runningIds = running.enabled === false ? [] : runningSessionIds(list, status);
|
|
2700
|
+
const byId = list !== null && typeof list === "object" && list.byId !== null && typeof list.byId === "object" ? list.byId : {};
|
|
2701
|
+
// The rows behind the yellow count, for the list it opens (`titleOf` +
|
|
2702
|
+
// the same workspace naming the pending list uses, so the two modes of the
|
|
2703
|
+
// panel cannot disagree about what a session is called or where it lives).
|
|
2704
|
+
const runningRows = runningIds.map((id) => ({ id, title: titleOf(id, byId[id]), where: sessionWhereOf(byId[id]), workspaceId: owners[id] === undefined ? "" : owners[id].id }));
|
|
2705
|
+
// Two keys for two consumers: the sidebar repaints on the SET OF IDS, while
|
|
2706
|
+
// the panel must also refresh when a title or a workspace changes.
|
|
2707
|
+
const runningSignature = runningIds.join("|");
|
|
2708
|
+
const runningRowSignature = runningItemsSignature(runningRows);
|
|
2709
|
+
const runningCount = runningIds.length;
|
|
2710
|
+
React.useEffect(() => { emitRunningState(runningCount, runningRows); }, [runningRowSignature]);
|
|
2711
|
+
// The session rows, for naming the running ones the way the sidebar shows
|
|
2712
|
+
// them (`displayTitle`, the same fallback the red dots use).
|
|
2429
2713
|
React.useEffect(() => {
|
|
2430
|
-
const owners = {};
|
|
2431
|
-
const workspaceItems = workspaces !== null && typeof workspaces === "object" && Array.isArray(workspaces.items) ? workspaces.items : [];
|
|
2432
|
-
for (const workspace of workspaceItems) {
|
|
2433
|
-
if (workspace === null || typeof workspace !== "object") continue;
|
|
2434
|
-
if (!Array.isArray(workspace.sessionIds)) continue;
|
|
2435
|
-
const id = typeof workspace.workspaceId === "string" ? workspace.workspaceId : "";
|
|
2436
|
-
const title = typeof workspace.title === "string" ? workspace.title : "";
|
|
2437
|
-
for (const sessionId of workspace.sessionIds) owners[sessionId] = { id, title };
|
|
2438
|
-
}
|
|
2439
|
-
// The session rows, for naming the running ones the way the sidebar shows
|
|
2440
|
-
// them (`displayTitle`, the same fallback the red dots use).
|
|
2441
|
-
const byId = list !== null && typeof list === "object" && list.byId !== null && typeof list.byId === "object" ? list.byId : {};
|
|
2442
2714
|
emitSidebarMarks({
|
|
2443
2715
|
workspaces: dotsFromItems(items, owners),
|
|
2444
2716
|
sessions: items.map((item) => ({ id: item.id, title: item.title })),
|
|
2445
2717
|
// The same rows, the other fact. Both arrays travel together, so the
|
|
2446
2718
|
// paint pass can put the yellow dot in front of the red one
|
|
2447
2719
|
// deterministically instead of depending on which feature changed last.
|
|
2448
|
-
runningSessions:
|
|
2720
|
+
runningSessions: runningRows.map((row) => ({ id: row.id, title: row.title })),
|
|
2449
2721
|
runningWorkspaces: runningWorkspaceDots(runningIds, owners)
|
|
2450
2722
|
});
|
|
2451
|
-
}, [items,
|
|
2723
|
+
}, [items, owners, runningSignature, running]);
|
|
2452
2724
|
return null;
|
|
2453
2725
|
}
|
|
2454
2726
|
|
|
@@ -2472,13 +2744,16 @@ window.__ModuleLoader__.load({
|
|
|
2472
2744
|
*
|
|
2473
2745
|
* The count is shown for EVERY value including zero: a grey 0 keeps the
|
|
2474
2746
|
* position from jumping, and doubles as the only way back to the panel once
|
|
2475
|
-
* the old sidebar-foot button is gone.
|
|
2747
|
+
* the old sidebar-foot button is gone. The YELLOW count, by contrast, has no
|
|
2748
|
+
* zero state and is a button: with nothing running there is no list to open.
|
|
2476
2749
|
*/
|
|
2477
2750
|
const HEAD_BADGE_ATTR = "data-icon-custom-unreadbadge";
|
|
2478
2751
|
/** The running count's own node in the same header row — bottom-right of the label. */
|
|
2479
2752
|
const HEAD_RUN_ATTR = "data-icon-custom-runbadge";
|
|
2480
2753
|
const HEAD_BADGE_STYLE_ID = "dsh-icon-custom-unreadbadge-css";
|
|
2481
2754
|
const HEAD_BADGE_WIRED = "data-icon-custom-unreadbadge-wired";
|
|
2755
|
+
/** The yellow pill's own wiring flag — it is a button now, exactly like the red one. */
|
|
2756
|
+
const HEAD_RUN_WIRED = "data-icon-custom-runbadge-wired";
|
|
2482
2757
|
const HEAD_BADGE_MAX_FAILURES = 40;
|
|
2483
2758
|
/** How many ancestors to walk up looking for the header row. */
|
|
2484
2759
|
const HEAD_BADGE_MAX_STEPS = 6;
|
|
@@ -2514,9 +2789,13 @@ window.__ModuleLoader__.load({
|
|
|
2514
2789
|
`[${HEAD_BADGE_ATTR}]::after{content:"";position:absolute;inset:0 -5px -5px -5px;border-radius:999px}`,
|
|
2515
2790
|
`[${HEAD_BADGE_ATTR}]:hover{filter:brightness(1.12)}`,
|
|
2516
2791
|
`[${HEAD_BADGE_ATTR}][data-zero="1"]{background:var(--dsw-alias-state-idle-primary,#b6bcc4)}`,
|
|
2517
|
-
// The running count: same pill, other corner,
|
|
2518
|
-
//
|
|
2519
|
-
|
|
2792
|
+
// The running count: same pill, other corner, and now a button like the red
|
|
2793
|
+
// one — it opens the list of the very sessions it counts. It has no zero
|
|
2794
|
+
// state (a yellow 0 would be pure noise), so with nothing running there is
|
|
2795
|
+
// nothing to open either: the pill is simply not there.
|
|
2796
|
+
`[${HEAD_RUN_ATTR}]{position:absolute;display:none;align-items:center;justify-content:center;min-width:15px;height:15px;padding:0 3.5px;box-sizing:border-box;border:0;border-radius:999px;background:${RUNNING_YELLOW};color:#1f1f1f;font:inherit;font-size:9.5px;font-weight:700;line-height:1;font-variant-numeric:tabular-nums;letter-spacing:-.02em;cursor:pointer;z-index:3;box-shadow:0 0 0 1.5px var(--dsw-specific-sidebar-fill,#fff)}`,
|
|
2797
|
+
`[${HEAD_RUN_ATTR}]::after{content:"";position:absolute;inset:0 -5px -5px -5px;border-radius:999px}`,
|
|
2798
|
+
`[${HEAD_RUN_ATTR}]:hover{filter:brightness(1.12)}`
|
|
2520
2799
|
].join("");
|
|
2521
2800
|
|
|
2522
2801
|
/** Install the badge rules once per page. */
|
|
@@ -2625,7 +2904,7 @@ window.__ModuleLoader__.load({
|
|
|
2625
2904
|
event.stopPropagation();
|
|
2626
2905
|
const rect = node.getBoundingClientRect();
|
|
2627
2906
|
// `bottom` is what the panel drops below; see unreadPanelPlacement.
|
|
2628
|
-
|
|
2907
|
+
togglePanel("unread", { left: rect.left, top: rect.top, bottom: rect.bottom });
|
|
2629
2908
|
});
|
|
2630
2909
|
}
|
|
2631
2910
|
const count = effectiveCount();
|
|
@@ -2641,17 +2920,35 @@ window.__ModuleLoader__.load({
|
|
|
2641
2920
|
}
|
|
2642
2921
|
if (node.textContent !== text) node.textContent = text;
|
|
2643
2922
|
// The running count, in the same row's other corner: top-right is the unread
|
|
2644
|
-
// number (where it has always been), bottom-right is this one.
|
|
2923
|
+
// number (where it has always been), bottom-right is this one. A button, not
|
|
2924
|
+
// a label — it opens the list of the sessions it is counting.
|
|
2645
2925
|
let runNode = row.querySelector(`[${HEAD_RUN_ATTR}]`);
|
|
2646
2926
|
if (runNode === null) {
|
|
2647
|
-
runNode = document.createElement("
|
|
2927
|
+
runNode = document.createElement("button");
|
|
2928
|
+
runNode.type = "button";
|
|
2648
2929
|
runNode.setAttribute(HEAD_RUN_ATTR, "1");
|
|
2930
|
+
// Same as the red one: appended AFTER the shipped controls, so it can
|
|
2931
|
+
// never be mistaken for the header's first button.
|
|
2649
2932
|
row.appendChild(runNode);
|
|
2650
2933
|
}
|
|
2934
|
+
if (runNode.getAttribute(HEAD_RUN_WIRED) !== "1") {
|
|
2935
|
+
runNode.setAttribute(HEAD_RUN_WIRED, "1");
|
|
2936
|
+
runNode.addEventListener("click", (event) => {
|
|
2937
|
+
// The row belongs to the sidebar; keep the click to ourselves.
|
|
2938
|
+
event.preventDefault();
|
|
2939
|
+
event.stopPropagation();
|
|
2940
|
+
const rect = runNode.getBoundingClientRect();
|
|
2941
|
+
// The same anchor shape the red badge hands over; see
|
|
2942
|
+
// `unreadPanelPlacement` for what `bottom` is used for.
|
|
2943
|
+
togglePanel("running", { left: rect.left, top: rect.top, bottom: rect.bottom });
|
|
2944
|
+
});
|
|
2945
|
+
}
|
|
2651
2946
|
const running = normalizeCount(runningTotal);
|
|
2652
2947
|
const runText = badgeLabel(running);
|
|
2653
2948
|
if (typeof badgeT === "function") {
|
|
2654
|
-
|
|
2949
|
+
// Says what the number is AND that it opens: it is a control now, and a
|
|
2950
|
+
// bare "2 running" would leave a screen reader with no idea it is one.
|
|
2951
|
+
const spoken = badgeT("runningPanelTrigger").replace("{n}", String(running));
|
|
2655
2952
|
if (runNode.getAttribute("aria-label") !== spoken) {
|
|
2656
2953
|
runNode.setAttribute("aria-label", spoken);
|
|
2657
2954
|
runNode.setAttribute("title", spoken);
|
|
@@ -2757,30 +3054,33 @@ window.__ModuleLoader__.load({
|
|
|
2757
3054
|
}
|
|
2758
3055
|
|
|
2759
3056
|
/**
|
|
2760
|
-
* The list behind
|
|
2761
|
-
* the sidebar's clipping and scroll container.
|
|
2762
|
-
*
|
|
3057
|
+
* The list behind those numbers, in the frame-wide overlay layer so it escapes
|
|
3058
|
+
* the sidebar's clipping and scroll container. ONE panel with two modes: the
|
|
3059
|
+
* red count opens "who needs you", the yellow count opens "who is running".
|
|
3060
|
+
* Clicking a row opens that session — which is also what marks an unread one
|
|
3061
|
+
* read. The running mode has no "mark all read": running is a live fact, not a
|
|
3062
|
+
* queue, so there is nothing there to acknowledge.
|
|
2763
3063
|
*/
|
|
2764
|
-
function
|
|
3064
|
+
function HeaderPopup(props) {
|
|
2765
3065
|
const [snapshot, setSnapshot] = React.useState(badgeSnapshot);
|
|
2766
|
-
const [
|
|
3066
|
+
const [mode, setMode] = React.useState(panelMode);
|
|
2767
3067
|
// Ages are computed against the HOST's clock (the endings are host
|
|
2768
3068
|
// timestamps); re-tick while open so "刚刚" does not stay stale.
|
|
2769
3069
|
const [, tick] = React.useState(0);
|
|
2770
3070
|
React.useEffect(() => subscribeBadge(setSnapshot), []);
|
|
2771
|
-
React.useEffect(() =>
|
|
3071
|
+
React.useEffect(() => subscribePanel(() => setMode(panelMode)), []);
|
|
2772
3072
|
React.useEffect(() => {
|
|
2773
|
-
if (
|
|
3073
|
+
if (mode === null) return undefined;
|
|
2774
3074
|
const interval = window.setInterval(() => tick((n) => n + 1), 30000);
|
|
2775
3075
|
return () => window.clearInterval(interval);
|
|
2776
|
-
}, [
|
|
3076
|
+
}, [mode]);
|
|
2777
3077
|
React.useEffect(() => {
|
|
2778
|
-
if (
|
|
2779
|
-
const onKey = (event) => { if (event.key === "Escape")
|
|
3078
|
+
if (mode === null) return undefined;
|
|
3079
|
+
const onKey = (event) => { if (event.key === "Escape") emitPanel(null); };
|
|
2780
3080
|
const onDown = (event) => {
|
|
2781
3081
|
const node = event.target;
|
|
2782
|
-
if (node !== null && typeof node.closest === "function" && node.closest('[data-icon-custom-
|
|
2783
|
-
|
|
3082
|
+
if (node !== null && typeof node.closest === "function" && node.closest('[data-icon-custom-panel="1"]') !== null) return;
|
|
3083
|
+
emitPanel(null);
|
|
2784
3084
|
};
|
|
2785
3085
|
window.addEventListener("keydown", onKey);
|
|
2786
3086
|
document.addEventListener("mousedown", onDown, true);
|
|
@@ -2788,10 +3088,12 @@ window.__ModuleLoader__.load({
|
|
|
2788
3088
|
window.removeEventListener("keydown", onKey);
|
|
2789
3089
|
document.removeEventListener("mousedown", onDown, true);
|
|
2790
3090
|
};
|
|
2791
|
-
}, [
|
|
2792
|
-
if (
|
|
3091
|
+
}, [mode]);
|
|
3092
|
+
if (mode === null) return null;
|
|
2793
3093
|
const t = props.t;
|
|
2794
|
-
|
|
3094
|
+
/** Which of the two lists this render is: same shell, different rows. */
|
|
3095
|
+
const running = mode === "running";
|
|
3096
|
+
const anchor = panelAnchor;
|
|
2795
3097
|
// Measured, not assumed: the panel has to clear whatever width the sidebar
|
|
2796
3098
|
// currently has (it is resizable and collapsible).
|
|
2797
3099
|
let sidebarRight = null;
|
|
@@ -2806,8 +3108,12 @@ window.__ModuleLoader__.load({
|
|
|
2806
3108
|
border: "1px solid var(--dsw-alias-border-l2)", background: "var(--dsw-alias-bg-layer-1)",
|
|
2807
3109
|
boxShadow: "0 10px 30px rgba(0,0,0,.18)", color: "var(--dsw-alias-label-primary)", fontSize: "12px"
|
|
2808
3110
|
};
|
|
2809
|
-
|
|
2810
|
-
|
|
3111
|
+
// The rows of whichever list is open. Both carry `{ id, title, where }`;
|
|
3112
|
+
// only the unread ones carry an ending to acknowledge.
|
|
3113
|
+
const items = running
|
|
3114
|
+
? (Array.isArray(snapshot.runningItems) ? snapshot.runningItems : [])
|
|
3115
|
+
: (Array.isArray(snapshot.items) ? snapshot.items : []);
|
|
3116
|
+
const ackTargets = running ? [] : unreadAckTargets(items);
|
|
2811
3117
|
/**
|
|
2812
3118
|
* Acknowledge every turn-end row at once.
|
|
2813
3119
|
*
|
|
@@ -2821,12 +3127,12 @@ window.__ModuleLoader__.load({
|
|
|
2821
3127
|
if (!changed) return;
|
|
2822
3128
|
saveSeenState();
|
|
2823
3129
|
emitUnreadPoke();
|
|
2824
|
-
if (ackTargets.length === items.length)
|
|
3130
|
+
if (ackTargets.length === items.length) emitPanel(null);
|
|
2825
3131
|
};
|
|
2826
|
-
return React.createElement("div", { "data-icon-custom-
|
|
2827
|
-
React.createElement("div", { style: { fontWeight: 600, padding: "2px 6px 8px" } }, t("unreadPanelTitle")),
|
|
3132
|
+
return React.createElement("div", { "data-icon-custom-panel": "1", style },
|
|
3133
|
+
React.createElement("div", { style: { fontWeight: 600, padding: "2px 6px 8px" } }, t(running ? "runningPanelTitle" : "unreadPanelTitle")),
|
|
2828
3134
|
items.length === 0
|
|
2829
|
-
? React.createElement("div", { style: { padding: "2px 6px 8px", opacity: 0.7 } }, t("unreadPanelEmpty"))
|
|
3135
|
+
? React.createElement("div", { style: { padding: "2px 6px 8px", opacity: 0.7 } }, t(running ? "runningPanelEmpty" : "unreadPanelEmpty"))
|
|
2830
3136
|
: React.createElement("div", { style: { display: "flex", flexDirection: "column", maxHeight: place.listMaxHeight + "px", overflowY: "auto" } },
|
|
2831
3137
|
groupUnreadByWorkspace(items).map((group) => React.createElement("div", { key: group.where === "" ? "\u0000none" : group.where },
|
|
2832
3138
|
React.createElement("div", { style: { padding: "7px 8px 3px", fontSize: "11px", fontWeight: 600, opacity: 0.55, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } },
|
|
@@ -2834,11 +3140,11 @@ window.__ModuleLoader__.load({
|
|
|
2834
3140
|
),
|
|
2835
3141
|
group.items.map((item) => React.createElement("button", {
|
|
2836
3142
|
key: item.id, type: "button",
|
|
2837
|
-
onClick: () => {
|
|
3143
|
+
onClick: () => { emitPanel(null); props.onOpen(item); },
|
|
2838
3144
|
style: { display: "block", width: "100%", textAlign: "left", padding: "6px 8px", border: "0", borderRadius: "8px", background: "transparent", color: "inherit", font: "inherit", cursor: "pointer" }
|
|
2839
3145
|
},
|
|
2840
3146
|
React.createElement("span", { style: { display: "block", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, item.title),
|
|
2841
|
-
React.createElement("span", { style: { display: "block", opacity: 0.6, fontSize: "11px" } }, unreadRowSubtitle(item, hostNow(), t))
|
|
3147
|
+
React.createElement("span", { style: { display: "block", opacity: 0.6, fontSize: "11px" } }, running ? runningRowSubtitle(item, t) : unreadRowSubtitle(item, hostNow(), t))
|
|
2842
3148
|
))
|
|
2843
3149
|
))
|
|
2844
3150
|
),
|
|
@@ -3091,8 +3397,11 @@ window.__ModuleLoader__.load({
|
|
|
3091
3397
|
const saveUnreadRule = React.useCallback((patch) => {
|
|
3092
3398
|
const next = normalizeUnreadConfig({ ...unreadConfig, ...patch });
|
|
3093
3399
|
emitUnreadConfig(next);
|
|
3400
|
+
// Every field is sent explicitly, and the host replaces its copy with what
|
|
3401
|
+
// arrives — so a field missing HERE is a field that silently reverts to its
|
|
3402
|
+
// default on the next page load, however well the local toggle worked.
|
|
3094
3403
|
rpc.call("/api", "iconCustom/setUnreadRule", {
|
|
3095
|
-
args: { request: { reasons: next.reasons, pending: next.pending, workspaceDot: next.workspaceDot, appBadge: next.appBadge, clearDelaySec: next.clearDelaySec } }
|
|
3404
|
+
args: { request: { reasons: next.reasons, pending: next.pending, workspaceDot: next.workspaceDot, appBadge: next.appBadge, clearDelaySec: next.clearDelaySec, revealOnOpen: next.revealOnOpen } }
|
|
3096
3405
|
}).catch(() => {});
|
|
3097
3406
|
}, [rpc]);
|
|
3098
3407
|
// Toggle one unread reason. Applies locally at once; the host keeps the
|
|
@@ -3107,6 +3416,11 @@ window.__ModuleLoader__.load({
|
|
|
3107
3416
|
const onWorkspaceDotToggle = React.useCallback((checked) => {
|
|
3108
3417
|
saveUnreadRule({ workspaceDot: checked });
|
|
3109
3418
|
}, [saveUnreadRule]);
|
|
3419
|
+
// The second fragile piece's own fuse (see the reveal region): locating a
|
|
3420
|
+
// clicked session drives the shipped sidebar rows.
|
|
3421
|
+
const onRevealToggle = React.useCallback((checked) => {
|
|
3422
|
+
saveUnreadRule({ revealOnOpen: checked });
|
|
3423
|
+
}, [saveUnreadRule]);
|
|
3110
3424
|
// The system-icon projection of the same number. Saved through the same
|
|
3111
3425
|
// one save path, so this field can never be dropped by a caller.
|
|
3112
3426
|
const onAppBadgeToggle = React.useCallback((checked) => {
|
|
@@ -3394,6 +3708,17 @@ window.__ModuleLoader__.load({
|
|
|
3394
3708
|
),
|
|
3395
3709
|
React.createElement("div", { style: style.hint }, t("workspaceDotHint"))
|
|
3396
3710
|
),
|
|
3711
|
+
React.createElement("div", { style: { marginTop: "10px" } },
|
|
3712
|
+
React.createElement("label", { style: { display: "inline-flex", alignItems: "center", gap: "6px", fontSize: 12, color: "var(--dsw-alias-label-primary)", cursor: "pointer" } },
|
|
3713
|
+
React.createElement("input", {
|
|
3714
|
+
type: "checkbox",
|
|
3715
|
+
checked: unread.revealOnOpen !== false,
|
|
3716
|
+
onChange: (event) => onRevealToggle(event.target.checked === true)
|
|
3717
|
+
}),
|
|
3718
|
+
React.createElement("span", null, t("revealLabel"))
|
|
3719
|
+
),
|
|
3720
|
+
React.createElement("div", { style: style.hint }, t("revealHint"))
|
|
3721
|
+
),
|
|
3397
3722
|
React.createElement("div", { style: { marginTop: "10px" } },
|
|
3398
3723
|
React.createElement("label", { style: { display: "inline-flex", alignItems: "center", gap: "6px", fontSize: 12, color: "var(--dsw-alias-label-primary)", cursor: "pointer" } },
|
|
3399
3724
|
React.createElement("input", {
|
|
@@ -3557,7 +3882,7 @@ window.__ModuleLoader__.load({
|
|
|
3557
3882
|
// The badge itself is NOT a slot occupant: it is injected into the
|
|
3558
3883
|
// sidebar's section header by `paintHeaderBadge()` (see above), because
|
|
3559
3884
|
// `sidebar.workspaces` is a single slot covering the whole region. Only
|
|
3560
|
-
// the
|
|
3885
|
+
// the lists they open still ride a slot — the frame-wide overlay, which
|
|
3561
3886
|
// escapes the sidebar's clipping.
|
|
3562
3887
|
//
|
|
3563
3888
|
// 0.2.x has no `ctx.sessions.open(id)` (the ClientSessions service exposes
|
|
@@ -3566,15 +3891,23 @@ window.__ModuleLoader__.load({
|
|
|
3566
3891
|
// `ctx.uiWorkspace.openSession(id)` (ui-chat does exactly this), so the entry
|
|
3567
3892
|
// is looked up optionally: navigation is best effort and must never make the
|
|
3568
3893
|
// favicon half depend on the workspace UI being mounted.
|
|
3569
|
-
const openSession = (
|
|
3894
|
+
const openSession = (row) => {
|
|
3895
|
+
// A list row (`{ id, workspaceId, … }`) since 0.19.0: the workspace is what
|
|
3896
|
+
// the sidebar reveal needs, and the pending list cannot supply it later. A
|
|
3897
|
+
// bare id still opens the session — it just cannot expand anything.
|
|
3898
|
+
const id = row !== null && typeof row === "object" ? row.id : row;
|
|
3899
|
+
const workspaceId = row !== null && typeof row === "object" && typeof row.workspaceId === "string" ? row.workspaceId : "";
|
|
3570
3900
|
try { ctx.get("uiWorkspace")?.openSession(id); } catch { /* navigation is best effort */ }
|
|
3901
|
+
// Deliberately not awaited, and never allowed to matter: the session is open
|
|
3902
|
+
// either way (see the reveal region for why this is all best effort).
|
|
3903
|
+
void revealSessionRow(id, workspaceId);
|
|
3571
3904
|
};
|
|
3572
3905
|
// The injected node labels itself for screen readers, so it needs the
|
|
3573
3906
|
// locale binder that only exists inside `apply()`.
|
|
3574
3907
|
badgeT = t;
|
|
3575
3908
|
ctx.slots.inject("shell.overlay", () => ctx.slots.register(
|
|
3576
|
-
{ name: "shell.overlay", id: "icon-custom-
|
|
3577
|
-
() => React.createElement(
|
|
3909
|
+
{ name: "shell.overlay", id: "icon-custom-header-popup", order: 30 },
|
|
3910
|
+
() => React.createElement(HeaderPopup, { t, onOpen: openSession })
|
|
3578
3911
|
));
|
|
3579
3912
|
|
|
3580
3913
|
// Real number source: a headless occupant of the frame-wide overlay
|
|
@@ -3676,6 +4009,9 @@ window.__ModuleLoader__.load({
|
|
|
3676
4009
|
offRunning();
|
|
3677
4010
|
clearWorkspaceDots();
|
|
3678
4011
|
clearHeaderBadge();
|
|
4012
|
+
// The one highlight this plugin ever puts on a SHIPPED row: it must not
|
|
4013
|
+
// outlive the plugin, even if the unload lands inside its flash window.
|
|
4014
|
+
clearRevealFlash();
|
|
3679
4015
|
};
|
|
3680
4016
|
}, "dsh-icon-custom: workspace dots");
|
|
3681
4017
|
// The platform favicon.svg repaints itself for dark mode through
|
package/lib/typert.host.js
CHANGED
|
@@ -57,6 +57,8 @@ const unreadRuleSchema = z.object({
|
|
|
57
57
|
appBadge: z.boolean(),
|
|
58
58
|
/** 进入会话后要连续待满多少秒才算"看过";0 = 进入即已读。上限 10 分钟。 */
|
|
59
59
|
clearDelaySec: z.number().int().nonnegative().max(600),
|
|
60
|
+
/** 实验项:点清单条目时在侧栏定位到该会话(展开工作区 + 滚动 + 闪一下;贴产品 DOM,可一键关闭)。 */
|
|
61
|
+
revealOnOpen: z.boolean(),
|
|
60
62
|
/**
|
|
61
63
|
* 宿主此刻的 `Date.now()`。
|
|
62
64
|
* 客户端用它把"看过"水位换算进宿主时钟域(`turn/end.time` 也是宿主时钟):
|
|
@@ -77,7 +79,8 @@ const setUnreadRuleRequestSchema = z.object({
|
|
|
77
79
|
pending: z.boolean().optional(),
|
|
78
80
|
workspaceDot: z.boolean().optional(),
|
|
79
81
|
appBadge: z.boolean().optional(),
|
|
80
|
-
clearDelaySec: z.number().int().nonnegative().max(600).optional()
|
|
82
|
+
clearDelaySec: z.number().int().nonnegative().max(600).optional(),
|
|
83
|
+
revealOnOpen: z.boolean().optional()
|
|
81
84
|
})
|
|
82
85
|
|
|
83
86
|
/** 一次水位线同步:浏览器把自己那份交上来,宿主逐会话取 max 后回权威表。 */
|
|
@@ -376,7 +379,7 @@ export const TYPERT = {
|
|
|
376
379
|
{
|
|
377
380
|
kind: 'method',
|
|
378
381
|
name: 'getUnreadRule',
|
|
379
|
-
signature: 'async getUnreadRule(): Promise<{ reasons: Record<string, boolean>; pending: boolean; workspaceDot: boolean; appBadge: boolean; clearDelaySec: number; hostNow: number; seen: Record<string, number> }>',
|
|
382
|
+
signature: 'async getUnreadRule(): Promise<{ reasons: Record<string, boolean>; pending: boolean; workspaceDot: boolean; appBadge: boolean; clearDelaySec: number; revealOnOpen: boolean; hostNow: number; seen: Record<string, number> }>',
|
|
380
383
|
description: "Read the unread-badge rule, the Host clock and the seen watermark.",
|
|
381
384
|
summary: "Read the unread-badge rule.",
|
|
382
385
|
jsDoc: "/** Read the unread-badge rule. */",
|
|
@@ -385,7 +388,7 @@ export const TYPERT = {
|
|
|
385
388
|
{
|
|
386
389
|
kind: 'method',
|
|
387
390
|
name: 'setUnreadRule',
|
|
388
|
-
signature: 'async setUnreadRule(request: { reasons: Record<string, boolean>; pending?: boolean; workspaceDot?: boolean; appBadge?: boolean; clearDelaySec?: number }): Promise<{ reasons: Record<string, boolean>; pending: boolean; workspaceDot: boolean; appBadge: boolean; clearDelaySec: number; hostNow: number; seen: Record<string, number> }>',
|
|
391
|
+
signature: 'async setUnreadRule(request: { reasons: Record<string, boolean>; pending?: boolean; workspaceDot?: boolean; appBadge?: boolean; clearDelaySec?: number; revealOnOpen?: boolean }): Promise<{ reasons: Record<string, boolean>; pending: boolean; workspaceDot: boolean; appBadge: boolean; clearDelaySec: number; revealOnOpen: boolean; hostNow: number; seen: Record<string, number> }>',
|
|
389
392
|
description: "Replace the unread-badge rule: which turn-end reasons light the red dot.",
|
|
390
393
|
summary: "Set the unread-badge rule.",
|
|
391
394
|
jsDoc: "/** Set the unread-badge rule. */",
|
package/lib/unread.js
CHANGED
|
@@ -46,12 +46,25 @@ export const UNREAD_PENDING_DEFAULT = true
|
|
|
46
46
|
/**
|
|
47
47
|
* 默认:在工作区那一行的文件夹图标上画红点。
|
|
48
48
|
*
|
|
49
|
-
*
|
|
49
|
+
* 这是全套功能里**贴着产品 DOM 走**的两处之一(另一处见下面的 `UNREAD_REVEAL_DEFAULT`),
|
|
50
50
|
* 所以它有自己的开关:一旦 DSH 升级改了结构导致它失效或画歪,
|
|
51
51
|
* 关掉它就行,红点数字/清单/跳转那些稳的部分完全不受影响。
|
|
52
52
|
*/
|
|
53
53
|
export const UNREAD_WORKSPACE_DOT_DEFAULT = true
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* 默认:点清单条目时,顺带在侧栏定位到那个会话(展开它所在的工作区、滚到那一行并闪一下)。
|
|
57
|
+
*
|
|
58
|
+
* 这是第二处贴着产品 DOM 走的部分。官方的"展开 + 滚动"只服务于它自己的搜索结果
|
|
59
|
+
* (内部状态,没有对外的 Service),所以插件只能**模拟点击**官方的工作区行与
|
|
60
|
+
* "显示更多"按钮。DSH 升级改结构时它会失效,失效的表现只有一个:
|
|
61
|
+
* **点进去照常打开会话,只是侧栏不动**。不想让它动侧栏就关掉这一项。
|
|
62
|
+
*
|
|
63
|
+
* 还有一件事要说清:展开是**官方自己记住**的状态(存在浏览器里),不会自己收回去 ——
|
|
64
|
+
* 官方搜索跳转也是这个行为。
|
|
65
|
+
*/
|
|
66
|
+
export const UNREAD_REVEAL_DEFAULT = true
|
|
67
|
+
|
|
55
68
|
/**
|
|
56
69
|
* 默认:在系统应用图标上显示角标(安装为应用后生效)。
|
|
57
70
|
*
|
|
@@ -97,8 +110,8 @@ export function normalizeReasons(input) {
|
|
|
97
110
|
|
|
98
111
|
/**
|
|
99
112
|
* 把任意输入夹成完整设置。
|
|
100
|
-
* @param input - `{ reasons?, pending?, workspaceDot?, appBadge?, clearDelaySec? }`,可以是脏的、缺项的、null
|
|
101
|
-
* @returns `{ reasons, pending, workspaceDot, appBadge, clearDelaySec }` —— 每一项都完整
|
|
113
|
+
* @param input - `{ reasons?, pending?, workspaceDot?, appBadge?, clearDelaySec?, revealOnOpen? }`,可以是脏的、缺项的、null
|
|
114
|
+
* @returns `{ reasons, pending, workspaceDot, appBadge, clearDelaySec, revealOnOpen }` —— 每一项都完整
|
|
102
115
|
*/
|
|
103
116
|
export function normalizeUnreadConfig(input) {
|
|
104
117
|
const source = input !== null && typeof input === 'object' ? input : {}
|
|
@@ -107,7 +120,8 @@ export function normalizeUnreadConfig(input) {
|
|
|
107
120
|
pending: typeof source.pending === 'boolean' ? source.pending : UNREAD_PENDING_DEFAULT,
|
|
108
121
|
workspaceDot: typeof source.workspaceDot === 'boolean' ? source.workspaceDot : UNREAD_WORKSPACE_DOT_DEFAULT,
|
|
109
122
|
appBadge: typeof source.appBadge === 'boolean' ? source.appBadge : UNREAD_APP_BADGE_DEFAULT,
|
|
110
|
-
clearDelaySec: normalizeClearDelay(source.clearDelaySec)
|
|
123
|
+
clearDelaySec: normalizeClearDelay(source.clearDelaySec),
|
|
124
|
+
revealOnOpen: typeof source.revealOnOpen === 'boolean' ? source.revealOnOpen : UNREAD_REVEAL_DEFAULT
|
|
111
125
|
}
|
|
112
126
|
}
|
|
113
127
|
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cowwo/dsh-icon-custom",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "自定义浏览器标签页图标:在设置页上传 SVG/PNG/ICO,立即生效并持久保存,自动适配安卓/iOS/平板 PWA 图标,显示未读红点与进行中黄点(
|
|
3
|
+
"version": "0.19.0",
|
|
4
|
+
"description": "自定义浏览器标签页图标:在设置页上传 SVG/PNG/ICO,立即生效并持久保存,自动适配安卓/iOS/平板 PWA 图标,显示未读红点与进行中黄点(工作区数字/会话行/左上角/标签页,数字可点开清单、点条目定位到侧栏),并可把未读数显示为系统应用图标角标(Windows 任务栏 / macOS Dock,含通知权限申请)。· Custom browser tab favicon: upload SVG/PNG/ICO from the settings page, applies instantly and persists, auto-sizes PWA icons for Android/iOS/tablets, shows unread red dots and running yellow marks (workspace number / session row / top-left / tab icon, both counts open a list whose rows locate the session in the sidebar), and projects the unread count onto the system app-icon badge (Windows taskbar / macOS Dock, notifications permission included).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"scripts": {
|
|
9
|
-
"test": "node test/app-badge.mjs && node test/notification-permission.mjs && node test/unread-panel.mjs && node test/unread-clear.mjs && node test/unread-rows.mjs && node test/unread-seen.mjs && node test/unread-host-sync.mjs && node test/running-mark.mjs"
|
|
9
|
+
"test": "node test/app-badge.mjs && node test/notification-permission.mjs && node test/unread-panel.mjs && node test/unread-clear.mjs && node test/unread-rows.mjs && node test/unread-seen.mjs && node test/unread-host-sync.mjs && node test/running-mark.mjs && node test/running-panel.mjs && node test/reveal.mjs"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"jimp": "^1.6.1",
|