@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 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
- let unreadConfig = { reasons: { ...UNREAD_FALLBACK_REASONS }, pending: true, workspaceDot: true, appBadge: UNREAD_APP_BADGE_FALLBACK, clearDelaySec: 0 };
314
+ /** Mirror of the host's default (see lib/unread.js) — locating a clicked row is on. */
315
+ const UNREAD_REVEAL_FALLBACK = true;
316
+ let unreadConfig = { reasons: { ...UNREAD_FALLBACK_REASONS }, pending: true, workspaceDot: true, appBadge: UNREAD_APP_BADGE_FALLBACK, clearDelaySec: 0, revealOnOpen: UNREAD_REVEAL_FALLBACK };
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: whereOf(entry), waiting: false, kind: value.reason, at: endAt });
793
+ hits.push({ id, title: titleOf(id, entry), where: sessionWhereOf(entry), waiting: false, kind: value.reason, at: endAt });
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: whereOf(byId[id]), waiting: true, kind, at: Date.now() });
807
+ hits.push({ id, title: titleOf(id, byId[id]), where: sessionWhereOf(byId[id]), waiting: true, kind, at: Date.now() });
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
- if (next === runningTotal) return;
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
- * Whether the "which sessions?" list is open, and where its button sits.
1623
- * The foot entry and the floating list live in two different slots, so they
1672
+ * Which list is open, and where the button that opened it sits.
1673
+ *
1674
+ * One store for both lists, and one open slot: the two numbers sit in the same
1675
+ * header row, so two panels open at once would stack on top of each other
1676
+ * under the same anchor. `mode` is therefore "unread" | "running" | null, and
1677
+ * opening either closes the other by construction.
1678
+ *
1679
+ * The buttons and the floating list live in two different slots, so they
1624
1680
  * cannot share React state — this tiny store is what they agree through.
1625
1681
  */
1626
- let unreadPanelOpen = false;
1627
- let unreadPanelAnchor = null;
1628
- const unreadPanelListeners = new Set();
1629
- function emitUnreadPanel(open, anchor) {
1630
- unreadPanelOpen = open === true;
1631
- if (anchor !== undefined) unreadPanelAnchor = anchor;
1632
- unreadPanelListeners.forEach((fn) => { try { fn(); } catch {} });
1682
+ let panelMode = null;
1683
+ let panelAnchor = null;
1684
+ const panelListeners = new Set();
1685
+ function emitPanel(mode, anchor) {
1686
+ panelMode = mode === "unread" || mode === "running" ? mode : null;
1687
+ if (anchor !== undefined) panelAnchor = anchor;
1688
+ panelListeners.forEach((fn) => { try { fn(); } catch {} });
1633
1689
  }
1634
- function subscribeUnreadPanel(fn) {
1635
- unreadPanelListeners.add(fn);
1636
- return () => { unreadPanelListeners.delete(fn); };
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, items); }, [count, items]);
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
- // Map session → its Workspace from the official snapshot: the ID anchors the
2427
- // folder dot to the keyed row, the title is what the row displays (not always
2428
- // the cwd basename, and not always equal to the stored title either).
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: runningIds.map((id) => ({ id, title: titleOf(id, byId[id]) })),
2720
+ runningSessions: runningRows.map((row) => ({ id: row.id, title: row.title })),
2449
2721
  runningWorkspaces: runningWorkspaceDots(runningIds, owners)
2450
2722
  });
2451
- }, [items, workspaces, runningSignature, running]);
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, not interactive. It has no
2518
- // zero state — a yellow 0 would be pure noise — so it is shown or hidden.
2519
- `[${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;pointer-events:none;z-index:3;box-shadow:0 0 0 1.5px var(--dsw-specific-sidebar-fill,#fff)}`
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
- toggleUnreadPanel({ left: rect.left, top: rect.top, bottom: rect.bottom });
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("span");
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
- const spoken = badgeT("runningMarkCount").replace("{n}", String(running));
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 that number, in the frame-wide overlay layer so it escapes
2761
- * the sidebar's clipping and scroll container. Clicking a row opens that
2762
- * session — which is also what marks it read.
3057
+ * The list behind those numbers, in the frame-wide overlay layer so it escapes
3058
+ * the sidebar's clipping and scroll container. ONE panel with two modes: the
3059
+ * red count opens "who needs you", the yellow count opens "who is running".
3060
+ * Clicking a row opens that session — which is also what marks an unread one
3061
+ * read. The running mode has no "mark all read": running is a live fact, not a
3062
+ * queue, so there is nothing there to acknowledge.
2763
3063
  */
2764
- function UnreadPopup(props) {
3064
+ function HeaderPopup(props) {
2765
3065
  const [snapshot, setSnapshot] = React.useState(badgeSnapshot);
2766
- const [open, setOpen] = React.useState(unreadPanelOpen);
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(() => subscribeUnreadPanel(() => setOpen(unreadPanelOpen)), []);
3071
+ React.useEffect(() => subscribePanel(() => setMode(panelMode)), []);
2772
3072
  React.useEffect(() => {
2773
- if (!open) return undefined;
3073
+ if (mode === null) return undefined;
2774
3074
  const interval = window.setInterval(() => tick((n) => n + 1), 30000);
2775
3075
  return () => window.clearInterval(interval);
2776
- }, [open]);
3076
+ }, [mode]);
2777
3077
  React.useEffect(() => {
2778
- if (!open) return undefined;
2779
- const onKey = (event) => { if (event.key === "Escape") emitUnreadPanel(false); };
3078
+ if (mode === null) return undefined;
3079
+ const onKey = (event) => { if (event.key === "Escape") emitPanel(null); };
2780
3080
  const onDown = (event) => {
2781
3081
  const node = event.target;
2782
- if (node !== null && typeof node.closest === "function" && node.closest('[data-icon-custom-unread="1"]') !== null) return;
2783
- emitUnreadPanel(false);
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
- }, [open]);
2792
- if (!open) return null;
3091
+ }, [mode]);
3092
+ if (mode === null) return null;
2793
3093
  const t = props.t;
2794
- const anchor = unreadPanelAnchor;
3094
+ /** Which of the two lists this render is: same shell, different rows. */
3095
+ const running = mode === "running";
3096
+ const anchor = panelAnchor;
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
- const items = Array.isArray(snapshot.items) ? snapshot.items : [];
2810
- const ackTargets = unreadAckTargets(items);
3111
+ // The rows of whichever list is open. Both carry `{ id, title, where }`;
3112
+ // only the unread ones carry an ending to acknowledge.
3113
+ const items = running
3114
+ ? (Array.isArray(snapshot.runningItems) ? snapshot.runningItems : [])
3115
+ : (Array.isArray(snapshot.items) ? snapshot.items : []);
3116
+ const ackTargets = running ? [] : unreadAckTargets(items);
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) emitUnreadPanel(false);
3130
+ if (ackTargets.length === items.length) emitPanel(null);
2825
3131
  };
2826
- return React.createElement("div", { "data-icon-custom-unread": "1", style },
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: () => { emitUnreadPanel(false); props.onOpen(item.id); },
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 list it opens still rides a slot — the frame-wide overlay, which
3885
+ // the lists they open still ride a slot — the frame-wide overlay, which
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 = (id) => {
3894
+ const openSession = (row) => {
3895
+ // A list row (`{ id, workspaceId, … }`) since 0.19.0: the workspace is what
3896
+ // the sidebar reveal needs, and the pending list cannot supply it later. A
3897
+ // bare id still opens the session — it just cannot expand anything.
3898
+ const id = row !== null && typeof row === "object" ? row.id : row;
3899
+ const workspaceId = row !== null && typeof row === "object" && typeof row.workspaceId === "string" ? row.workspaceId : "";
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-unread-popup", order: 30 },
3577
- () => React.createElement(UnreadPopup, { t, onOpen: openSession })
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
@@ -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
- * 这是全套功能里**唯一贴着产品 DOM 走**的部分(侧栏那片没有插槽),
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.18.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), and projects the unread count onto the system app-icon badge (Windows taskbar / macOS Dock, notifications permission included).",
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",