@cowwo/dsh-icon-custom 0.18.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client/client.js CHANGED
@@ -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) {
@@ -629,69 +646,102 @@ window.__ModuleLoader__.load({
629
646
  function documentVisible() {
630
647
  try { return document.visibilityState !== "hidden"; } catch { return true; }
631
648
  }
649
+ /**
650
+ * The official "Session on screen" answer, when this build exposes it.
651
+ *
652
+ * `ctx.uiSession.adapter.current` is the very binding the renderer derives
653
+ * `[data-conversation-session]` from, so it answers with the same value without
654
+ * the DOM's first-match ambiguity (see `conversationOnScreen`) — and it keeps
655
+ * answering while a global panel has unmounted the conversation, where the DOM
656
+ * has nothing to say. Filled in by `apply`, where a client context exists;
657
+ * until then it answers nothing, which is exactly what the other fallbacks are
658
+ * for.
659
+ * @returns the session id, or undefined when this build does not say.
660
+ */
661
+ let officialSessionId = () => undefined;
662
+ /**
663
+ * What the conversation on screen is rendering, in two parts.
664
+ *
665
+ * `rendered` says whether the main conversation is on screen at all, and `id` is
666
+ * the session it renders (`undefined` while none is selected — the landing view).
667
+ * The distinction matters: "the conversation is showing no session" is an answer
668
+ * ("you are not in a session"), while "there is no conversation element" is not
669
+ * an answer at all (a global panel replaced it, the page is still booting).
670
+ *
671
+ * TWO occurrences can carry this attribute: the main conversation, and the
672
+ * embedded one the right sidebar keeps MOUNTED while collapsed (hidden by CSS and
673
+ * `inert`). A plain `querySelector` returns the first in document order, so the
674
+ * moment the main occurrence carries no attribute — no session selected, the
675
+ * archive/close path — the hidden one becomes the answer: "you are looking at
676
+ * that subagent conversation". The stay clock reads that as "you left this
677
+ * session", drops the entered-at snapshot, and the ending that arrived meanwhile
678
+ * is acknowledged the moment you come back: a dot that goes out without you ever
679
+ * leaving. Only an occurrence the user can actually see is an answer.
680
+ * @returns `{ rendered, id }`.
681
+ */
682
+ function conversationOnScreen() {
683
+ try {
684
+ const nodes = document.querySelectorAll("[data-conversation-session]");
685
+ let rendered = false;
686
+ for (const node of nodes) {
687
+ if (typeof node.closest === "function" && (node.closest("[data-sidebar-right-panel]") !== null || node.closest("[inert]") !== null)) continue;
688
+ rendered = true;
689
+ const id = node.getAttribute("data-conversation-session");
690
+ if (typeof id === "string" && id !== "") return { rendered: true, id };
691
+ }
692
+ return { rendered, id: undefined };
693
+ } catch { return { rendered: false, id: undefined }; }
694
+ }
632
695
  /**
633
696
  * The session whose conversation the page is rendering right now, from the DOM.
634
697
  *
635
698
  * This is the only signal that names the session on screen, and it is the one
636
699
  * that keeps a stale retention from clearing a dot: `retainedBy.mainView` means
637
- * "recently opened, not yet released", not "displayed". `undefined` means this
638
- * build carries no such marker (or the probe failed) and callers must fall back
639
- * to the retention answer rather than treat it as "nothing is displayed".
700
+ * "recently opened, not yet released", not "displayed". `undefined` means the page
701
+ * does not name one (see `conversationOnScreen`) and callers must fall back to the
702
+ * other answers rather than treat it as "nothing is displayed".
640
703
  * @returns the session id, or undefined when the page cannot say.
641
704
  */
642
705
  function displayedSessionId() {
643
- try {
644
- const node = document.querySelector("[data-conversation-session]");
645
- if (node === null) return undefined;
646
- const id = node.getAttribute("data-conversation-session");
647
- return typeof id === "string" && id !== "" ? id : undefined;
648
- } catch { return undefined; }
649
- }
650
- /**
651
- * Every session the main view is showing.
652
- *
653
- * `view` carries what the page itself says about the screen:
654
- * * `panelActive` — a global panel (settings, plugins, …) replaced the
655
- * conversation. DSH's own sidebar treats that as "no current session"
656
- * (`usePanelInfo((info) => info.activePanelId !== null)`), and following it
657
- * is what fixes the "cleared a dot I never opened" report: the conversation
658
- * is simply not on screen.
659
- * * `visible` — whether this page is on screen at all.
660
- * * `displayed` — the session the conversation is rendering, when the DOM can
661
- * name it. That is the precise answer and it wins over retention.
662
- *
663
- * Retention (`retainedBy.mainView`) stays as the fallback: it is what shipped
664
- * before, it keeps working on a build without the newer signals, and its known
665
- * weakness (a release that lags behind the pane) only costs a false "read" when
666
- * nothing better is available.
667
- * @param list - the session list snapshot (may be absent).
668
- * @param view - the screen report described above (may be absent).
669
- * @returns the ids being viewed, in list order; `[]` when there are none.
670
- */
706
+ return conversationOnScreen().id;
707
+ }
671
708
  /**
672
- * Which session this page is ON — the set that defines "entered" and "left".
709
+ * The session this page is ON — the set that defines "entered" and "left" — plus
710
+ * whether the answer is actually known.
673
711
  *
674
- * Only a change here counts as leaving a session, so this must NOT be emptied
675
- * by pausing: a background tab, or a global panel (settings, plugins, …) that
676
- * covers the conversation, keeps you on the same session. Emptying it would
677
- * drop the entered-at snapshot, and taking a fresh one on return is exactly
678
- * what acknowledges an ending you never left the session for — the bug behind
679
- * "切一下标签页红点就没了".
712
+ * Only a change here counts as leaving a session, so this must NOT be emptied by
713
+ * pausing: a background tab, or a global panel (settings, plugins, …) that covers
714
+ * the conversation, keeps you on the same session. Emptying it would drop the
715
+ * entered-at snapshot, and taking a fresh one on return is exactly what
716
+ * acknowledges an ending you never left the session for.
680
717
  *
681
- * `view.displayed` (read from the DOM) is the precise answer when available;
682
- * `list.current` and then retention are the fallbacks, which is also what keeps
683
- * the answer meaningful while a panel covers the conversation.
718
+ * The answers, in order:
719
+ * * the visible conversation's own session (the precise one, and the only one
720
+ * that may overrule retention);
721
+ * * the official "Session on screen" binding (`view.official`, from
722
+ * `ctx.uiSession`) — the very value the attribute above is rendered from, so
723
+ * it still answers while a global panel has unmounted the conversation;
724
+ * * `list.current`, when a build carries it;
725
+ * * retention (`retainedBy.mainView > 0`), the pre-0.12 answer.
726
+ *
727
+ * `known: false` is the important one: it means the page could not name ANY
728
+ * session, which is NOT the same as "no session is being viewed". Callers must
729
+ * keep the snapshots they already have instead of reading it as a leave — a
730
+ * single such frame used to be enough to acknowledge an ending you were sitting
731
+ * in front of.
684
732
  * @param list - the session list snapshot (may be absent).
685
- * @param view - `{ displayed }`: the session the conversation is rendering.
686
- * @returns the ids being viewed, in list order; `[]` when there are none.
733
+ * @param view - `{ displayed, official }`: what the page says about the screen.
734
+ * @returns `{ ids, known }`; `ids` in list order, `[]` when there are none.
687
735
  */
688
- function currentSessionIds(list, view) {
736
+ function viewedSessions(list, view) {
689
737
  const screen = view !== null && typeof view === "object" ? view : {};
690
- if (typeof screen.displayed === "string" && screen.displayed !== "") return [screen.displayed];
691
- if (list === null || typeof list !== "object") return [];
692
- if (typeof list.current === "string" && list.current !== "") return [list.current];
738
+ if (typeof screen.displayed === "string" && screen.displayed !== "") return { ids: [screen.displayed], known: true };
739
+ if (typeof screen.official === "string" && screen.official !== "") return { ids: [screen.official], known: true };
740
+ if (screen.idle === true) return { ids: [], known: true }; // the conversation is up, showing no session
741
+ if (list === null || typeof list !== "object") return { ids: [], known: false };
742
+ if (typeof list.current === "string" && list.current !== "") return { ids: [list.current], known: true };
693
743
  const byId = list.byId;
694
- if (byId === null || typeof byId !== "object") return [];
744
+ if (byId === null || typeof byId !== "object") return { ids: [], known: false };
695
745
  const ids = [];
696
746
  for (const session of Object.values(byId)) {
697
747
  if (session === null || typeof session !== "object") continue;
@@ -699,7 +749,11 @@ window.__ModuleLoader__.load({
699
749
  const count = retainedBy !== null && typeof retainedBy === "object" ? retainedBy.mainView : undefined;
700
750
  if (typeof count === "number" && count > 0 && typeof session.id === "string" && session.id !== "") ids.push(session.id);
701
751
  }
702
- return ids;
752
+ // Retention with nothing in it, and the conversation not on screen either, is
753
+ // exactly the frame this must not read as a leave: it is what a global panel
754
+ // over a released session, a boot, or a DOM gap looks like. The landing view
755
+ // (`idle`) is the positive "you are on no session" answer, not this.
756
+ return { ids, known: ids.length > 0 };
703
757
  }
704
758
  /**
705
759
  * One session's last `turn/end` timestamp (host domain), or 0 when it has
@@ -713,6 +767,23 @@ window.__ModuleLoader__.load({
713
767
  const value = values !== null && typeof values === "object" ? values[LAST_TURN_END_KEY] : undefined;
714
768
  return value !== null && typeof value === "object" && typeof value.endAt === "number" ? value.endAt : 0;
715
769
  }
770
+ /**
771
+ * Which workspace a session is shown under: the basename of its cwd.
772
+ *
773
+ * The sidebar's own workspace rows are labelled with this basename, so a list
774
+ * points at the same place the reader sees — the sidebar group cannot be
775
+ * expanded for us (that state is private to the shipped browser), so naming
776
+ * it is the honest substitute. Shared by the pending list and the running
777
+ * list: two lists of the same sessions must not disagree about where they are.
778
+ * @param entry - the session-list row (may be absent).
779
+ * @returns the basename, or "" when the row carries no cwd.
780
+ */
781
+ function sessionWhereOf(entry) {
782
+ const cwd = entry !== null && typeof entry === "object" && typeof entry.cwd === "string" ? entry.cwd : "";
783
+ if (cwd === "") return "";
784
+ const parts = cwd.replace(/[\\/]+$/, "").split(/[\\/]/);
785
+ return parts[parts.length - 1] || cwd;
786
+ }
716
787
  /**
717
788
  * How many sessions deserve the badge right now: endings whose reason is
718
789
  * enabled and that this browser has not seen since, plus sessions waiting
@@ -728,28 +799,21 @@ window.__ModuleLoader__.load({
728
799
  * @param list - the session list snapshot (may be absent).
729
800
  * @param pending - the pending-interaction map (may be absent).
730
801
  * @param config - the unread rule to apply.
802
+ * @param lit - the ids currently showing a dot, so a change of ENDING KIND cannot
803
+ * put an already lit dot out (see below). Optional.
731
804
  * @returns the badge number.
732
805
  */
733
- function collectUnread(list, pending, config, seen) {
806
+ function collectUnread(list, pending, config, seen, lit) {
734
807
  const reasons = config !== null && typeof config === "object" && config.reasons !== null && typeof config.reasons === "object" ? config.reasons : {};
735
808
  // The watermark is a parameter, not module state: this stays a pure
736
809
  // decision function that a test can drive with any marks it likes.
737
810
  const marks = seen !== null && typeof seen === "object" ? seen : {};
811
+ const litIds = lit !== null && lit !== undefined && typeof lit.has === "function" ? lit : null;
738
812
  /** { id, title, waiting, kind, at } — newest first, "in wait" ahead of "ended". */
739
813
  const hits = [];
740
814
  const ids = new Set();
741
815
  const byId = list !== null && typeof list === "object" && list.byId !== null && typeof list.byId === "object" ? list.byId : {};
742
816
  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
817
  if (list !== null && typeof list === "object") {
754
818
  const order = Array.isArray(list.ids) ? list.ids : Object.keys(byId);
755
819
  for (const id of order) {
@@ -764,9 +828,14 @@ window.__ModuleLoader__.load({
764
828
  if (endAt <= 0 || typeof seenAt !== "number" || endAt <= seenAt) continue;
765
829
  // Unknown reasons (a newer DSH adds one) stay hidden until the user
766
830
  // turns them on, so a vocabulary growth can never surprise the badge.
767
- if (reasons[value.reason] !== true) continue;
831
+ // A reason the user turned OFF behaves the same way — with ONE
832
+ // exception: it must never put out a dot that is already lit. That dot
833
+ // stands for an ending nobody has read yet, and "the newest turn ended
834
+ // in a way I do not want to be told about" is not "I read it". Only the
835
+ // watermark clears a lit dot.
836
+ if (reasons[value.reason] !== true && !(litIds !== null && litIds.has(id))) continue;
768
837
  ids.add(id);
769
- hits.push({ id, title: titleOf(id, entry), where: whereOf(entry), waiting: false, kind: value.reason, at: endAt });
838
+ hits.push({ id, title: titleOf(id, entry), where: sessionWhereOf(entry), waiting: false, kind: value.reason, at: endAt });
770
839
  }
771
840
  }
772
841
  if (config === null || typeof config !== "object" || config.pending !== false) {
@@ -780,7 +849,7 @@ window.__ModuleLoader__.load({
780
849
  return;
781
850
  }
782
851
  ids.add(id);
783
- hits.push({ id, title: titleOf(id, byId[id]), where: whereOf(byId[id]), waiting: true, kind, at: Date.now() });
852
+ hits.push({ id, title: titleOf(id, byId[id]), where: sessionWhereOf(byId[id]), waiting: true, kind, at: Date.now() });
784
853
  });
785
854
  }
786
855
  }
@@ -879,6 +948,16 @@ window.__ModuleLoader__.load({
879
948
  let realCount = 0;
880
949
  /** The sessions behind `realCount`, for the sidebar list (newest first). */
881
950
  let realItems = [];
951
+ /**
952
+ * Which sessions are showing an unread dot right now.
953
+ *
954
+ * Read back by `collectUnread` so a change of ending KIND cannot put a lit dot
955
+ * out (see the reason filter there). Kept here rather than derived on the fly
956
+ * because the projection only carries the LATEST ending: once a newer one lands
957
+ * with a reason the user turned off, the one that lit the dot is no longer
958
+ * visible anywhere.
959
+ */
960
+ let litUnreadIds = new Set();
882
961
  /**
883
962
  * How many sessions are running right now — the official `running` fact,
884
963
  * already folded by `runningSessionIds`. It rides the SAME bridge as the
@@ -887,6 +966,8 @@ window.__ModuleLoader__.load({
887
966
  * being shown.
888
967
  */
889
968
  let runningTotal = 0;
969
+ /** The sessions behind `runningTotal` (`{ id, title, where }`), for the running list. */
970
+ let runningItems = [];
890
971
  let manualCount = 0;
891
972
  let badgeSize = "md";
892
973
  const badgeListeners = new Set();
@@ -894,11 +975,30 @@ window.__ModuleLoader__.load({
894
975
  const n = Number(value);
895
976
  return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
896
977
  }
978
+ /**
979
+ * A stable identity for one running list.
980
+ *
981
+ * The number alone is not enough: with one session running, handing over to a
982
+ * DIFFERENT session keeps the count at 1, so a guard that only compares counts
983
+ * leaves the open list naming the session that already stopped — the mark and
984
+ * the tab icon stay right while the list lies. Same reason the sidebar marks
985
+ * key off `runningSignature`: compare the list by value.
986
+ * @param items - `{ id, title, where }` rows (anything else is ignored).
987
+ * @returns a string that changes whenever the list does.
988
+ */
989
+ function runningItemsSignature(items) {
990
+ const out = [];
991
+ for (const item of Array.isArray(items) ? items : []) {
992
+ if (item === null || typeof item !== "object") continue;
993
+ out.push(`${item.id}\u0000${item.title}\u0000${item.where}`);
994
+ }
995
+ return out.join("\u0001");
996
+ }
897
997
  function effectiveCount() {
898
998
  return badgeSource === "manual" ? manualCount : realCount;
899
999
  }
900
1000
  function badgeSnapshot() {
901
- return { count: effectiveCount(), size: badgeSize, source: badgeSource, items: badgeSource === "manual" ? [] : realItems, running: runningTotal };
1001
+ return { count: effectiveCount(), size: badgeSize, source: badgeSource, items: badgeSource === "manual" ? [] : realItems, running: runningTotal, runningItems };
902
1002
  }
903
1003
  function notifyBadge() {
904
1004
  const snapshot = badgeSnapshot();
@@ -918,10 +1018,19 @@ window.__ModuleLoader__.load({
918
1018
  function emitRealBadge(value, items) {
919
1019
  realCount = normalizeCount(value);
920
1020
  realItems = Array.isArray(items) ? items : [];
1021
+ // Remember what is lit BEFORE the next round of folding can hide it: the
1022
+ // projection only carries the latest ending, so an ending whose reason is
1023
+ // turned off would otherwise erase the memory of the one that lit the dot.
1024
+ const lit = new Set();
1025
+ for (const item of realItems) {
1026
+ if (item !== null && typeof item === "object" && typeof item.id === "string" && item.id !== "") lit.add(item.id);
1027
+ }
1028
+ litUnreadIds = lit;
921
1029
  notifyBadge();
922
1030
  }
923
1031
  /**
924
- * The running sessions, as one number on the same bridge.
1032
+ * The running sessions, as one number on the same bridge — plus the list
1033
+ * behind it, so the yellow count can be opened like the red one.
925
1034
  *
926
1035
  * Emitted from every render of the headless badge source, so the "did it
927
1036
  * actually change" guard is what keeps a re-render from repainting the tab
@@ -929,11 +1038,14 @@ window.__ModuleLoader__.load({
929
1038
  * `emitRealBadge`: the unread number and the running number are independent
930
1039
  * facts, and a manual test number must not disturb the other.
931
1040
  * @param value - how many sessions are running; anything non-positive is 0.
1041
+ * @param items - `{ id, title, where }`, one per running session, in list order.
932
1042
  */
933
- function emitRunningState(value) {
1043
+ function emitRunningState(value, items) {
934
1044
  const next = normalizeCount(value);
935
- if (next === runningTotal) return;
1045
+ const nextItems = Array.isArray(items) ? items : [];
1046
+ if (next === runningTotal && runningItemsSignature(nextItems) === runningItemsSignature(runningItems)) return;
936
1047
  runningTotal = next;
1048
+ runningItems = nextItems;
937
1049
  notifyBadge();
938
1050
  }
939
1051
  function emitBadgeSize(value) {
@@ -1332,11 +1444,34 @@ window.__ModuleLoader__.load({
1332
1444
  // canvas and the merged bitmap is handed back to the browser. Count 0 (and
1333
1445
  // teardown) restores the pristine platform/custom href.
1334
1446
  const FAVICON_SIZE = 64;
1447
+ /**
1448
+ * How long one base-image fetch may take before the compose gives up on it.
1449
+ *
1450
+ * A fetch that neither loads nor errors (a stalled request, a proxy that never
1451
+ * answers) used to leave the compose pending forever — and silently, because no
1452
+ * error is ever raised — so the tab icon simply stopped following the numbers
1453
+ * with nothing in the console. This timeout turns that into a reported failure
1454
+ * the watchdog can retry.
1455
+ */
1456
+ const FAVICON_LOAD_TIMEOUT_MS = 8000;
1335
1457
  let faviconBase = null;
1336
1458
  let composeSeq = 0;
1337
1459
  let faviconComposing = false;
1338
1460
  /** Last compose failure, so a repeated failure warns once instead of spamming. */
1339
1461
  let lastComposeError = null;
1462
+ /**
1463
+ * The picture the tab is carrying right now: `未读|进行中|基图`.
1464
+ *
1465
+ * A `data:` href alone says nothing — an OLD bitmap and a bitmap whose compose
1466
+ * FAILED look exactly the same from the DOM. Remembering which picture was
1467
+ * actually written is what lets the watchdog tell "already right" from "stale
1468
+ * but still ours", which is the hole that used to freeze the tab icon.
1469
+ */
1470
+ let faviconDrawnSignature = "";
1471
+ /** The picture a compose is fetching right now, so a repeat cannot start a second fetch. */
1472
+ let faviconPendingSignature = "";
1473
+ /** Whether the last compose failed: the watchdog must retry instead of trusting the DOM. */
1474
+ let faviconFailed = false;
1340
1475
  /** Which icon the plugin considers current (filled from the status RPC). */
1341
1476
  let iconState = { active: false, rev: null, png: false, mime: null };
1342
1477
  /** The icon URL this page SHOULD be showing, per the plugin's own status. */
@@ -1350,6 +1485,24 @@ window.__ModuleLoader__.load({
1350
1485
  if (iconState.active !== true) return "image/svg+xml";
1351
1486
  return iconState.png === true ? "image/png" : (iconState.mime || "image/svg+xml");
1352
1487
  }
1488
+ /**
1489
+ * One string that names one picture: which numbers, on which base.
1490
+ *
1491
+ * It is the identity used both to remember what was drawn and to decide whether
1492
+ * a compose still has anything to do. The numbers are part of it on purpose —
1493
+ * "1 unread" and "2 unread" are different pictures even though both are `data:`.
1494
+ * @param count - the unread number this picture carries.
1495
+ * @param runningCount - the running number this picture carries.
1496
+ * @param base - the base icon href the numbers ride on.
1497
+ * @returns the signature string.
1498
+ */
1499
+ function faviconSignature(count, runningCount, base) {
1500
+ return normalizeCount(count) + "|" + normalizeCount(runningCount) + "|" + base;
1501
+ }
1502
+ /** The picture this page SHOULD be carrying right now, from live state. */
1503
+ function wantedFaviconSignature() {
1504
+ return faviconSignature(effectiveCount(), runningTotal, desiredIconHref());
1505
+ }
1353
1506
  /**
1354
1507
  * Every `<link rel="icon">` the document carries, creating one if the page
1355
1508
  * has none.
@@ -1496,36 +1649,64 @@ window.__ModuleLoader__.load({
1496
1649
  c2d.fillText(text, cx, cy + size * 0.01);
1497
1650
  c2d.restore();
1498
1651
  }
1652
+ /**
1653
+ * Load one image for the composite, with a deadline.
1654
+ *
1655
+ * Settles exactly once, whatever happens: a late `onload` after the timeout must
1656
+ * not draw on top of the picture that replaced it.
1657
+ * @param href - absolute URL of the base icon.
1658
+ * @returns the loaded image, or a rejection whose message says why.
1659
+ */
1499
1660
  function loadIconImage(href) {
1500
1661
  return new Promise((resolve, reject) => {
1501
1662
  const img = new Image();
1502
- img.onload = () => resolve(img);
1503
- img.onerror = () => reject(new Error("icon-load"));
1663
+ let done = false;
1664
+ const settle = (fn, value) => {
1665
+ if (done) return;
1666
+ done = true;
1667
+ window.clearTimeout(timer);
1668
+ fn(value);
1669
+ };
1670
+ const timer = window.setTimeout(() => { settle(reject, new Error("icon-load-timeout")); }, FAVICON_LOAD_TIMEOUT_MS);
1671
+ img.onload = () => { settle(resolve, img); };
1672
+ img.onerror = () => { settle(reject, new Error("icon-load")); };
1504
1673
  img.src = href;
1505
1674
  });
1506
1675
  }
1507
1676
  async function applyBadgeToFavicon(count, size) {
1508
- const base = desiredIconHref();
1509
- // Two independent marks ride one composite, so "nothing to draw" means
1510
- // neither of them is wanted — not merely "no unread".
1511
- const running = runningTotal > 0;
1512
- if (count <= 0 && !running) {
1513
- // No badge to draw. With a custom icon active the page must carry it on
1514
- // EVERY link (a stale scheme-specific sibling outranks the one we set);
1515
- // with none, the shipped dark/light pair goes back untouched — the
1516
- // plugin promises zero side effects while it has no icon of its own.
1517
- if (iconState.active === true) setIconEverywhere(base, desiredIconType());
1518
- else restoreShippedIcons();
1519
- faviconBase = null;
1520
- return;
1521
- }
1522
- // The base is resolved from the plugin's status, never from whatever the
1523
- // page happens to carry, and never from our own previous bitmap.
1524
- faviconBase = { href: base, type: desiredIconType() };
1677
+ // ONE snapshot decides this whole compose: which marks to draw AND with which
1678
+ // numbers. `runningTotal` used to be read again after the await, so a run that
1679
+ // stopped mid-fetch was painted as a yellow "0", and the two numbers could
1680
+ // come from two different moments.
1681
+ const wantedCount = normalizeCount(count);
1682
+ const runningCount = normalizeCount(runningTotal);
1683
+ const signature = faviconSignature(wantedCount, runningCount, desiredIconHref());
1684
+ // The sequence is taken BEFORE the "nothing to draw" branch: restoring the
1685
+ // plain icon has to invalidate any compose still in flight, or that older
1686
+ // bitmap lands afterwards and paints the number we just cleared back onto
1687
+ // the tab. (That is exactly the "stale dot that will not go away" report.)
1525
1688
  const seq = ++composeSeq;
1526
1689
  faviconComposing = true;
1690
+ faviconPendingSignature = signature;
1691
+ faviconFailed = false;
1527
1692
  try {
1528
- const img = await loadIconImage(new URL(faviconBase.href, document.baseURI).href);
1693
+ if (wantedCount <= 0 && runningCount <= 0) {
1694
+ // No badge to draw. With a custom icon active the page must carry it on
1695
+ // EVERY link (a stale scheme-specific sibling outranks the one we set);
1696
+ // with none, the shipped dark/light pair goes back untouched — the
1697
+ // plugin promises zero side effects while it has no icon of its own.
1698
+ if (seq !== composeSeq) return;
1699
+ if (iconState.active === true) setIconEverywhere(desiredIconHref(), desiredIconType());
1700
+ else restoreShippedIcons();
1701
+ faviconBase = null;
1702
+ faviconDrawnSignature = signature;
1703
+ return;
1704
+ }
1705
+ // The base is resolved from the plugin's status, never from whatever the
1706
+ // page happens to carry, and never from our own previous bitmap.
1707
+ const base = desiredIconHref();
1708
+ faviconBase = { href: base, type: desiredIconType() };
1709
+ const img = await loadIconImage(new URL(base, document.baseURI).href);
1529
1710
  if (seq !== composeSeq) return;
1530
1711
  const canvas = document.createElement("canvas");
1531
1712
  canvas.width = FAVICON_SIZE;
@@ -1541,16 +1722,22 @@ window.__ModuleLoader__.load({
1541
1722
  // together to the largest pair that fits, so neither reads as a bug and
1542
1723
  // the two can never overlap.
1543
1724
  const badgeUnit = badgeScale(size);
1544
- const badgeK = count > 0 && running ? badgeUnit * faviconBadgeFitScale(badgeUnit) : badgeUnit;
1545
- if (count > 0) drawFaviconBadge(c2d, FAVICON_SIZE, count, badgeK);
1546
- if (running) drawFaviconRunningBadge(c2d, FAVICON_SIZE, runningTotal, badgeK); setIconEverywhere(canvas.toDataURL("image/png"), "image/png");
1725
+ const badgeK = wantedCount > 0 && runningCount > 0 ? badgeUnit * faviconBadgeFitScale(badgeUnit) : badgeUnit;
1726
+ if (wantedCount > 0) drawFaviconBadge(c2d, FAVICON_SIZE, wantedCount, badgeK);
1727
+ if (runningCount > 0) drawFaviconRunningBadge(c2d, FAVICON_SIZE, runningCount, badgeK);
1728
+ setIconEverywhere(canvas.toDataURL("image/png"), "image/png");
1729
+ faviconDrawnSignature = signature;
1547
1730
  } catch (error) {
1548
1731
  // Never silent: a failure here is invisible in the UI (the tab simply
1549
1732
  // keeps its previous icon), which once cost a whole debugging session.
1550
1733
  // Warn once per distinct reason so the 5s watchdog cannot spam.
1734
+ faviconFailed = true;
1551
1735
  warnFaviconCompose(error);
1552
1736
  } finally {
1553
- if (seq === composeSeq) faviconComposing = false;
1737
+ if (seq === composeSeq) {
1738
+ faviconComposing = false;
1739
+ faviconPendingSignature = "";
1740
+ }
1554
1741
  }
1555
1742
  }
1556
1743
  /**
@@ -1574,10 +1761,22 @@ window.__ModuleLoader__.load({
1574
1761
  * rejection, and the tab icon would simply never change with nothing in the
1575
1762
  * console — the exact "no warning, no badge" state. Every call site goes through
1576
1763
  * here instead.
1764
+ *
1765
+ * It is also the ONE place that decides whether there is anything to do: this is
1766
+ * called on every badge notification, and a badge notification happens on every
1767
+ * render. Without the signature guard each of those started its own base-image
1768
+ * fetch, and on a page whose base image is slow (a reverse proxy, a cold cache,
1769
+ * a laptop waking up) every fetch was superseded before it landed — the tab icon
1770
+ * fell further behind the numbers with every repaint instead of catching up.
1577
1771
  * @param count - sessions needing you; 0 restores the plain icon.
1578
1772
  * @param size - badge size key.
1773
+ * @param force - recompose even when this exact picture was already drawn (the
1774
+ * page was disturbed, the base icon changed, the last compose failed).
1579
1775
  */
1580
- function composeFavicon(count, size) {
1776
+ function composeFavicon(count, size, force) {
1777
+ const signature = faviconSignature(count, runningTotal, desiredIconHref());
1778
+ if (signature === faviconPendingSignature) return; // this very picture is being fetched
1779
+ if (force !== true && signature === faviconDrawnSignature && faviconFailed !== true) return; // already on the tab
1581
1780
  try {
1582
1781
  Promise.resolve(applyBadgeToFavicon(count, size)).catch(warnFaviconCompose);
1583
1782
  } catch (error) {
@@ -1592,6 +1791,12 @@ window.__ModuleLoader__.load({
1592
1791
  * icon link — a cached page, a browser extension, a tab move, or a plugin
1593
1792
  * reload. Our own output is a `data:` URL, so any other href means the
1594
1793
  * composite was replaced and has to be rebuilt from the new base.
1794
+ *
1795
+ * "Still ours" is not the same as "still right": a `data:` href can carry an
1796
+ * older number, or one whose compose died halfway. Comparing the picture that
1797
+ * was actually written against the one that is wanted now is what lets this
1798
+ * watchdog repair those — and what makes a failed compose a retry instead of a
1799
+ * tab icon that is frozen for the rest of the page's life.
1595
1800
  */
1596
1801
  function reconcileFavicon() {
1597
1802
  // Inspect EVERY icon link: 0.2.x ships two (dark/light), and a sibling
@@ -1599,41 +1804,60 @@ window.__ModuleLoader__.load({
1599
1804
  // stale sibling is exactly what used to outrank the icon we set.
1600
1805
  const hrefs = iconLinks().map((candidate) => candidate.getAttribute("href"));
1601
1806
  const ours = hrefs.length > 0 && hrefs.every((value) => typeof value === "string" && value.slice(0, 5) === "data:");
1602
- if (effectiveCount() <= 0 && runningTotal <= 0) {
1807
+ const wanted = wantedFaviconSignature();
1808
+ if (ours && faviconDrawnSignature === wanted && faviconFailed !== true) return; // exactly right, right now
1809
+ const count = effectiveCount();
1810
+ if (count <= 0 && normalizeCount(runningTotal) <= 0) {
1603
1811
  // Carries the custom icon, or puts the shipped pair back. Both writers
1604
1812
  // only touch a link that actually differs, so this stays a no-op once
1605
1813
  // the page is correct and cannot re-arm the head observer.
1606
- composeFavicon(0);
1814
+ composeFavicon(0, badgeSize, ours !== true);
1607
1815
  return;
1608
1816
  }
1609
- if (ours || faviconComposing) return; // already showing, or a build is running
1610
- composeFavicon(effectiveCount(), badgeSize);
1817
+ if (faviconComposing) return; // a build is already running
1818
+ composeFavicon(count, badgeSize, ours !== true);
1611
1819
  }
1612
1820
  // A freshly applied base icon replaces whatever the browser holds, so the
1613
1821
  // composite must be rebuilt from the NEW base instead of the stale one.
1614
1822
  function refreshFaviconBase() {
1615
1823
  faviconBase = null;
1616
- composeFavicon(effectiveCount(), badgeSize);
1824
+ composeFavicon(effectiveCount(), badgeSize, true);
1617
1825
  }
1618
1826
  //#endregion
1619
1827
 
1620
1828
  //#region unread panel
1829
+ //#region header panel
1621
1830
  /**
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
1831
+ * Which list is open, and where the button that opened it sits.
1832
+ *
1833
+ * One store for both lists, and one open slot: the two numbers sit in the same
1834
+ * header row, so two panels open at once would stack on top of each other
1835
+ * under the same anchor. `mode` is therefore "unread" | "running" | null, and
1836
+ * opening either closes the other by construction.
1837
+ *
1838
+ * The buttons and the floating list live in two different slots, so they
1624
1839
  * cannot share React state — this tiny store is what they agree through.
1625
1840
  */
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 {} });
1841
+ let panelMode = null;
1842
+ let panelAnchor = null;
1843
+ const panelListeners = new Set();
1844
+ function emitPanel(mode, anchor) {
1845
+ panelMode = mode === "unread" || mode === "running" ? mode : null;
1846
+ if (anchor !== undefined) panelAnchor = anchor;
1847
+ panelListeners.forEach((fn) => { try { fn(); } catch {} });
1633
1848
  }
1634
- function subscribeUnreadPanel(fn) {
1635
- unreadPanelListeners.add(fn);
1636
- return () => { unreadPanelListeners.delete(fn); };
1849
+ function subscribePanel(fn) {
1850
+ panelListeners.add(fn);
1851
+ return () => { panelListeners.delete(fn); };
1852
+ }
1853
+ /**
1854
+ * One marker's button: open its list, or close it when it is already the open
1855
+ * one. Clicking the OTHER marker switches lists instead of stacking them.
1856
+ * @param mode - "unread" (the red count) or "running" (the yellow count).
1857
+ * @param anchor - the clicked node's viewport rect, or undefined to keep the last one.
1858
+ */
1859
+ function togglePanel(mode, anchor) {
1860
+ emitPanel(panelMode === mode ? null : mode, anchor === undefined ? null : anchor);
1637
1861
  }
1638
1862
  /**
1639
1863
  * Tells the badge source that the marks moved somewhere it did not touch —
@@ -1648,9 +1872,6 @@ window.__ModuleLoader__.load({
1648
1872
  unreadPokeListeners.add(fn);
1649
1873
  return () => { unreadPokeListeners.delete(fn); };
1650
1874
  }
1651
- function toggleUnreadPanel(anchor) {
1652
- emitUnreadPanel(!unreadPanelOpen, anchor === undefined ? null : anchor);
1653
- }
1654
1875
  /**
1655
1876
  * Roll the pending sessions up to one dot per workspace.
1656
1877
  *
@@ -1778,6 +1999,22 @@ window.__ModuleLoader__.load({
1778
1999
  if (short !== "") parts.push(short);
1779
2000
  return parts.join(" · ");
1780
2001
  }
2002
+ /**
2003
+ * The dim second line of one running row: that it is running, and which
2004
+ * session it is. Deliberately no age — the official session list carries no
2005
+ * "started at" fact (only `running: boolean`), and this plugin would rather
2006
+ * print less than invent a time. Shape matches `unreadRowSubtitle` so the two
2007
+ * lists read as one panel with two modes.
2008
+ * @param item - a row from the badge bridge's `runningItems`.
2009
+ * @param t - locale binder.
2010
+ * @returns the line, already joined.
2011
+ */
2012
+ function runningRowSubtitle(item, t) {
2013
+ const parts = [t("runningPanelRow")];
2014
+ const short = shortSessionId(item.id);
2015
+ if (short !== "") parts.push(short);
2016
+ return parts.join(" · ");
2017
+ }
1781
2018
  /**
1782
2019
  * What "mark all read" may actually acknowledge: the turn-end rows.
1783
2020
  *
@@ -1798,6 +2035,175 @@ window.__ModuleLoader__.load({
1798
2035
  }
1799
2036
  return targets;
1800
2037
  }
2038
+ //#region reveal in sidebar
2039
+ /**
2040
+ * Locating a clicked session in the sidebar: expand its workspace, scroll to the
2041
+ * row, light it up for a moment.
2042
+ *
2043
+ * The official sidebar does exactly this by itself — but ONLY for its own search
2044
+ * results. The state behind that (`revealSessionId`) is private to that component
2045
+ * and no Service exposes it, while `uiWorkspace.openSession` switches the main
2046
+ * view and nothing else. So this drives the shipped rows the way a user's click
2047
+ * would: the workspace row toggles its group, the "show more" button raises the
2048
+ * collapsed-session limit.
2049
+ *
2050
+ * It therefore reads the page structure, and it is DECORATIVE: by the time it runs
2051
+ * the session is already open. Moved markup (a DSH upgrade), a rail sidebar or an
2052
+ * open search box all degrade to "the sidebar did not move" — never to an error,
2053
+ * and never to a blocked click.
2054
+ */
2055
+ const REVEAL_ROW_KEY_ATTR = "data-row-key";
2056
+ const REVEAL_FLASH_ATTR = "data-icon-custom-flash";
2057
+ const REVEAL_FLASH_STYLE_ID = "dsh-icon-custom-flash-css";
2058
+ /** How long the located row stays lit. */
2059
+ const REVEAL_FLASH_MS = 1200;
2060
+ /**
2061
+ * How many DOM steps one reveal may take.
2062
+ *
2063
+ * The official flow does this in one shot by writing its own state; we only have
2064
+ * clicks, and every click needs a commit before the row can exist. The realistic
2065
+ * worst case is a collapsed workspace whose session sits past "show more", which
2066
+ * releases five rows per click — so this covers a workspace of roughly twenty
2067
+ * sessions and then stops, rather than driving a shipped control in a loop.
2068
+ */
2069
+ const REVEAL_MAX_STEPS = 6;
2070
+ /**
2071
+ * The flash rules: a short outline pulse, not a lasting class. The point is to say
2072
+ * "here it is", not to leave a mark on a shipped row.
2073
+ */
2074
+ const REVEAL_FLASH_CSS = [
2075
+ `[${REVEAL_FLASH_ATTR}]{animation:dsh-icon-custom-flash ${REVEAL_FLASH_MS}ms ease-out}`,
2076
+ `@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}}`,
2077
+ `@media (prefers-reduced-motion:reduce){[${REVEAL_FLASH_ATTR}]{animation:none;outline:2px solid var(--dsw-alias-state-business-primary,#4d6bfe);outline-offset:-2px}}`
2078
+ ].join("");
2079
+ /** The row currently lit, and the timer that will un-light it. */
2080
+ let revealFlashRow = null;
2081
+ let revealFlashTimer = 0;
2082
+
2083
+ /**
2084
+ * What to do next to make one session's row visible.
2085
+ *
2086
+ * Pure, so the order — and the giving up — is testable without a browser: a row
2087
+ * that is already there gets scrolled to; otherwise its workspace is expanded;
2088
+ * otherwise "show more" is clicked; otherwise nothing is touched at all.
2089
+ * @param state - `{ hasRow, hasWorkspace, expanded, hasOverflow, overflowExpanded, steps }`.
2090
+ * @returns `"scroll" | "expand" | "overflow" | "give-up"`.
2091
+ */
2092
+ function revealAction(state) {
2093
+ if (state.hasRow === true) return "scroll";
2094
+ if (state.steps >= REVEAL_MAX_STEPS) return "give-up";
2095
+ if (state.hasWorkspace === true && state.expanded !== true) return "expand";
2096
+ // `overflowExpanded` must be false: that button toggles, and this feature has
2097
+ // no business COLLAPSING a workspace the reader deliberately opened up.
2098
+ if (state.hasOverflow === true && state.overflowExpanded !== true) return "overflow";
2099
+ return "give-up";
2100
+ }
2101
+
2102
+ /**
2103
+ * The shipped row carrying one `data-row-key`, matched by VALUE.
2104
+ *
2105
+ * Not by a selector built from the key: session ids go into the key verbatim, so a
2106
+ * selector would need escaping rules that a plain comparison simply does not have.
2107
+ * @param container - the browsing region root.
2108
+ * @param key - e.g. `session:<id>` / `workspace:<id>` / `overflow:<id>`.
2109
+ * @returns the element, or null.
2110
+ */
2111
+ function findRowByKey(container, key) {
2112
+ try {
2113
+ for (const node of container.querySelectorAll(`[${REVEAL_ROW_KEY_ATTR}]`)) {
2114
+ if (node.getAttribute(REVEAL_ROW_KEY_ATTR) === key) return node;
2115
+ }
2116
+ } catch { /* a broken subtree must not become a thrown click */ }
2117
+ return null;
2118
+ }
2119
+
2120
+ /** One paint's worth of waiting, so a click can commit before the row is looked for. */
2121
+ function nextPaint() {
2122
+ return new Promise((resolve) => {
2123
+ try {
2124
+ if (typeof window.requestAnimationFrame === "function") window.requestAnimationFrame(() => resolve());
2125
+ else window.setTimeout(resolve, 16);
2126
+ } catch { resolve(); }
2127
+ });
2128
+ }
2129
+
2130
+ /** Install the flash rules once per page. */
2131
+ function ensureRevealFlashStyle() {
2132
+ try {
2133
+ if (document.getElementById(REVEAL_FLASH_STYLE_ID) !== null) return;
2134
+ const style = document.createElement("style");
2135
+ style.id = REVEAL_FLASH_STYLE_ID;
2136
+ style.textContent = REVEAL_FLASH_CSS;
2137
+ document.head.appendChild(style);
2138
+ } catch { /* styling is cosmetic; never let it reach a click */ }
2139
+ }
2140
+
2141
+ /** Take the flash off, whoever put it there. Also the unload path. */
2142
+ function clearRevealFlash() {
2143
+ if (revealFlashTimer !== 0) {
2144
+ try { window.clearTimeout(revealFlashTimer); } catch { /* the page is going away */ }
2145
+ revealFlashTimer = 0;
2146
+ }
2147
+ if (revealFlashRow !== null) {
2148
+ try { revealFlashRow.removeAttribute(REVEAL_FLASH_ATTR); } catch { /* already detached */ }
2149
+ revealFlashRow = null;
2150
+ }
2151
+ }
2152
+
2153
+ /**
2154
+ * Scroll one shipped row into view and light it for a moment.
2155
+ *
2156
+ * `block:"nearest"` is the official call and the reason a reveal does not yank the
2157
+ * whole list: it moves the list only as far as the row needs.
2158
+ * @param row - the session row element.
2159
+ */
2160
+ function flashRow(row) {
2161
+ try {
2162
+ clearRevealFlash();
2163
+ ensureRevealFlashStyle();
2164
+ row.setAttribute(REVEAL_FLASH_ATTR, "1");
2165
+ revealFlashRow = row;
2166
+ revealFlashTimer = window.setTimeout(() => { clearRevealFlash(); }, REVEAL_FLASH_MS);
2167
+ } catch { /* cosmetic */ }
2168
+ try { row.scrollIntoView({ block: "nearest" }); } catch { /* older engines take no options */ }
2169
+ }
2170
+
2171
+ /**
2172
+ * Put one session's row in front of the reader.
2173
+ *
2174
+ * Best effort from the first line to the last: the session is ALREADY open when
2175
+ * this runs, so every exit here — no container, no workspace id, a sidebar that
2176
+ * never renders the row — costs the reader nothing but the scroll.
2177
+ * @param sessionId - the session to locate.
2178
+ * @param workspaceId - its workspace, when the official snapshot knows one.
2179
+ */
2180
+ async function revealSessionRow(sessionId, workspaceId) {
2181
+ if (typeof sessionId !== "string" || sessionId === "") return;
2182
+ if (unreadConfig === null || typeof unreadConfig !== "object" || unreadConfig.revealOnOpen === false) return;
2183
+ const container = workspaceDotContainer();
2184
+ if (container === null) return;
2185
+ const wsKey = typeof workspaceId === "string" && workspaceId !== "" ? `workspace:${workspaceId}` : null;
2186
+ const moreKey = typeof workspaceId === "string" && workspaceId !== "" ? `overflow:${workspaceId}` : null;
2187
+ for (let steps = 0; steps < REVEAL_MAX_STEPS; steps++) {
2188
+ const row = findRowByKey(container, `session:${sessionId}`);
2189
+ const wsRow = wsKey === null ? null : findRowByKey(container, wsKey);
2190
+ const more = moreKey === null ? null : findRowByKey(container, moreKey);
2191
+ const action = revealAction({
2192
+ hasRow: row !== null,
2193
+ hasWorkspace: wsRow !== null,
2194
+ expanded: wsRow !== null && wsRow.getAttribute("aria-expanded") === "true",
2195
+ hasOverflow: more !== null,
2196
+ overflowExpanded: more !== null && more.getAttribute("aria-expanded") === "true",
2197
+ steps
2198
+ });
2199
+ if (action === "scroll") { flashRow(row); return; }
2200
+ if (action === "give-up") return;
2201
+ const target = action === "expand" ? wsRow : more;
2202
+ if (target === null) return;
2203
+ try { target.click(); } catch { return; }
2204
+ await nextPaint();
2205
+ }
2206
+ }
1801
2207
  //#endregion
1802
2208
 
1803
2209
  //#region workspace dots
@@ -2328,8 +2734,49 @@ window.__ModuleLoader__.load({
2328
2734
  }, []);
2329
2735
  /** Looking at the conversation right now: on screen, and this tab in front. */
2330
2736
  const watching = visible && panelActive !== true;
2331
- const currentIds = currentSessionIds(list, { displayed: displayedSessionId() });
2332
- const items = collectUnread(list, pending, config, seenState.seen);
2737
+ // Read the screen once per render: which session the visible conversation
2738
+ // renders (`displayed`), whether it is up but showing none (`idle` — the
2739
+ // landing view, a real "you are not in a session"), and whether it is not up
2740
+ // at all (neither ⇒ `known: false`, and the entered-at snapshots stay).
2741
+ const onScreen = conversationOnScreen();
2742
+ const viewed = viewedSessions(list, {
2743
+ displayed: onScreen.id,
2744
+ official: officialSessionId(),
2745
+ idle: onScreen.rendered === true && onScreen.id === undefined
2746
+ });
2747
+ const currentIds = viewed.ids;
2748
+ const stayKnown = viewed.known;
2749
+ const items = collectUnread(list, pending, config, seenState.seen, litUnreadIds);
2750
+ /**
2751
+ * Map session → its Workspace from the official snapshot: the ID anchors the
2752
+ * folder dot to the keyed row, the title is what the row displays (not always
2753
+ * the cwd basename, and not always equal to the stored title either).
2754
+ *
2755
+ * The same map answers a second question: which workspace row to expand when a
2756
+ * list row is clicked. The pending list only carries the cwd basename, which
2757
+ * cannot be turned back into a workspace id.
2758
+ */
2759
+ const owners = React.useMemo(() => {
2760
+ const out = {};
2761
+ const workspaceItems = workspaces !== null && typeof workspaces === "object" && Array.isArray(workspaces.items) ? workspaces.items : [];
2762
+ for (const workspace of workspaceItems) {
2763
+ if (workspace === null || typeof workspace !== "object") continue;
2764
+ if (!Array.isArray(workspace.sessionIds)) continue;
2765
+ const id = typeof workspace.workspaceId === "string" ? workspace.workspaceId : "";
2766
+ const title = typeof workspace.title === "string" ? workspace.title : "";
2767
+ for (const sessionId of workspace.sessionIds) out[sessionId] = { id, title };
2768
+ }
2769
+ return out;
2770
+ }, [workspaces]);
2771
+ /**
2772
+ * The pending rows plus the workspace they belong to. A session the official
2773
+ * snapshot places nowhere keeps its row untouched: `revealSessionRow` then has
2774
+ * nothing to expand, which is the honest answer, not a guess.
2775
+ */
2776
+ const itemsWithOwner = React.useMemo(() => items.map((item) => {
2777
+ const owner = owners[item.id];
2778
+ return owner === undefined || owner.id === "" ? item : { ...item, workspaceId: owner.id };
2779
+ }), [items, owners]);
2333
2780
  const count = items.length;
2334
2781
  // Being in a session is what marks it read — and the clock that does it is
2335
2782
  // armed by ENTERING the session, never by a new ending.
@@ -2373,9 +2820,17 @@ window.__ModuleLoader__.load({
2373
2820
  const stayArmed = React.useRef(new Map());
2374
2821
  React.useEffect(() => {
2375
2822
  const snapshot = stayLive.current.list !== null && typeof stayLive.current.list === "object" && stayLive.current.list.byId !== null && typeof stayLive.current.list.byId === "object" ? stayLive.current.list.byId : {};
2376
- const live = new Set(stayIds);
2377
- for (const id of Array.from(stayArmed.current.keys())) {
2378
- if (!live.has(id)) stayArmed.current.delete(id);
2823
+ // ONLY A KNOWN ANSWER MAY DROP A SNAPSHOT. A frame in which the page could
2824
+ // not name any session (a panel unmounting the conversation while the
2825
+ // official binding is unavailable, a DOM gap, a build without either
2826
+ // signal) is not a leave: forgetting the snapshot there is what made the
2827
+ // clock re-enter the session behind the reader's back and acknowledge an
2828
+ // ending that arrived while they were sitting in front of it.
2829
+ if (stayKnown !== false) {
2830
+ const live = new Set(stayIds);
2831
+ for (const id of Array.from(stayArmed.current.keys())) {
2832
+ if (!live.has(id)) stayArmed.current.delete(id);
2833
+ }
2379
2834
  }
2380
2835
  for (const id of stayIds) {
2381
2836
  if (!stayArmed.current.has(id)) stayArmed.current.set(id, lastTurnEndAt(snapshot[id]));
@@ -2404,16 +2859,8 @@ window.__ModuleLoader__.load({
2404
2859
  cancelled = true;
2405
2860
  if (timer !== 0) window.clearTimeout(timer);
2406
2861
  };
2407
- }, [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]);
2862
+ }, [staySignature, delayMs, watching, stayKnown]);
2863
+ React.useEffect(() => { emitRealBadge(count, itemsWithOwner); }, [count, itemsWithOwner]);
2417
2864
  /**
2418
2865
  * One session's displayed title, the way the sidebar shows it.
2419
2866
  *
@@ -2423,32 +2870,35 @@ window.__ModuleLoader__.load({
2423
2870
  * title has not landed yet would get no yellow dot at all.
2424
2871
  */
2425
2872
  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).
2873
+ // The running fact, straight off the same two official props and with no
2874
+ // host round trip at all. `runningSignature` is what the marks effect below
2875
+ // depends on, so a list re-render with the same set of running sessions
2876
+ // does not repaint the sidebar.
2877
+ const runningIds = running.enabled === false ? [] : runningSessionIds(list, status);
2878
+ const byId = list !== null && typeof list === "object" && list.byId !== null && typeof list.byId === "object" ? list.byId : {};
2879
+ // The rows behind the yellow count, for the list it opens (`titleOf` +
2880
+ // the same workspace naming the pending list uses, so the two modes of the
2881
+ // panel cannot disagree about what a session is called or where it lives).
2882
+ const runningRows = runningIds.map((id) => ({ id, title: titleOf(id, byId[id]), where: sessionWhereOf(byId[id]), workspaceId: owners[id] === undefined ? "" : owners[id].id }));
2883
+ // Two keys for two consumers: the sidebar repaints on the SET OF IDS, while
2884
+ // the panel must also refresh when a title or a workspace changes.
2885
+ const runningSignature = runningIds.join("|");
2886
+ const runningRowSignature = runningItemsSignature(runningRows);
2887
+ const runningCount = runningIds.length;
2888
+ React.useEffect(() => { emitRunningState(runningCount, runningRows); }, [runningRowSignature]);
2889
+ // The session rows, for naming the running ones the way the sidebar shows
2890
+ // them (`displayTitle`, the same fallback the red dots use).
2429
2891
  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
2892
  emitSidebarMarks({
2443
2893
  workspaces: dotsFromItems(items, owners),
2444
2894
  sessions: items.map((item) => ({ id: item.id, title: item.title })),
2445
2895
  // The same rows, the other fact. Both arrays travel together, so the
2446
2896
  // paint pass can put the yellow dot in front of the red one
2447
2897
  // deterministically instead of depending on which feature changed last.
2448
- runningSessions: runningIds.map((id) => ({ id, title: titleOf(id, byId[id]) })),
2898
+ runningSessions: runningRows.map((row) => ({ id: row.id, title: row.title })),
2449
2899
  runningWorkspaces: runningWorkspaceDots(runningIds, owners)
2450
2900
  });
2451
- }, [items, workspaces, runningSignature, running]);
2901
+ }, [items, owners, runningSignature, running]);
2452
2902
  return null;
2453
2903
  }
2454
2904
 
@@ -2472,13 +2922,16 @@ window.__ModuleLoader__.load({
2472
2922
  *
2473
2923
  * The count is shown for EVERY value including zero: a grey 0 keeps the
2474
2924
  * position from jumping, and doubles as the only way back to the panel once
2475
- * the old sidebar-foot button is gone.
2925
+ * the old sidebar-foot button is gone. The YELLOW count, by contrast, has no
2926
+ * zero state and is a button: with nothing running there is no list to open.
2476
2927
  */
2477
2928
  const HEAD_BADGE_ATTR = "data-icon-custom-unreadbadge";
2478
2929
  /** The running count's own node in the same header row — bottom-right of the label. */
2479
2930
  const HEAD_RUN_ATTR = "data-icon-custom-runbadge";
2480
2931
  const HEAD_BADGE_STYLE_ID = "dsh-icon-custom-unreadbadge-css";
2481
2932
  const HEAD_BADGE_WIRED = "data-icon-custom-unreadbadge-wired";
2933
+ /** The yellow pill's own wiring flag — it is a button now, exactly like the red one. */
2934
+ const HEAD_RUN_WIRED = "data-icon-custom-runbadge-wired";
2482
2935
  const HEAD_BADGE_MAX_FAILURES = 40;
2483
2936
  /** How many ancestors to walk up looking for the header row. */
2484
2937
  const HEAD_BADGE_MAX_STEPS = 6;
@@ -2514,9 +2967,13 @@ window.__ModuleLoader__.load({
2514
2967
  `[${HEAD_BADGE_ATTR}]::after{content:"";position:absolute;inset:0 -5px -5px -5px;border-radius:999px}`,
2515
2968
  `[${HEAD_BADGE_ATTR}]:hover{filter:brightness(1.12)}`,
2516
2969
  `[${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)}`
2970
+ // The running count: same pill, other corner, and now a button like the red
2971
+ // one — it opens the list of the very sessions it counts. It has no zero
2972
+ // state (a yellow 0 would be pure noise), so with nothing running there is
2973
+ // nothing to open either: the pill is simply not there.
2974
+ `[${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)}`,
2975
+ `[${HEAD_RUN_ATTR}]::after{content:"";position:absolute;inset:0 -5px -5px -5px;border-radius:999px}`,
2976
+ `[${HEAD_RUN_ATTR}]:hover{filter:brightness(1.12)}`
2520
2977
  ].join("");
2521
2978
 
2522
2979
  /** Install the badge rules once per page. */
@@ -2625,7 +3082,7 @@ window.__ModuleLoader__.load({
2625
3082
  event.stopPropagation();
2626
3083
  const rect = node.getBoundingClientRect();
2627
3084
  // `bottom` is what the panel drops below; see unreadPanelPlacement.
2628
- toggleUnreadPanel({ left: rect.left, top: rect.top, bottom: rect.bottom });
3085
+ togglePanel("unread", { left: rect.left, top: rect.top, bottom: rect.bottom });
2629
3086
  });
2630
3087
  }
2631
3088
  const count = effectiveCount();
@@ -2641,17 +3098,35 @@ window.__ModuleLoader__.load({
2641
3098
  }
2642
3099
  if (node.textContent !== text) node.textContent = text;
2643
3100
  // 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.
3101
+ // number (where it has always been), bottom-right is this one. A button, not
3102
+ // a label — it opens the list of the sessions it is counting.
2645
3103
  let runNode = row.querySelector(`[${HEAD_RUN_ATTR}]`);
2646
3104
  if (runNode === null) {
2647
- runNode = document.createElement("span");
3105
+ runNode = document.createElement("button");
3106
+ runNode.type = "button";
2648
3107
  runNode.setAttribute(HEAD_RUN_ATTR, "1");
3108
+ // Same as the red one: appended AFTER the shipped controls, so it can
3109
+ // never be mistaken for the header's first button.
2649
3110
  row.appendChild(runNode);
2650
3111
  }
3112
+ if (runNode.getAttribute(HEAD_RUN_WIRED) !== "1") {
3113
+ runNode.setAttribute(HEAD_RUN_WIRED, "1");
3114
+ runNode.addEventListener("click", (event) => {
3115
+ // The row belongs to the sidebar; keep the click to ourselves.
3116
+ event.preventDefault();
3117
+ event.stopPropagation();
3118
+ const rect = runNode.getBoundingClientRect();
3119
+ // The same anchor shape the red badge hands over; see
3120
+ // `unreadPanelPlacement` for what `bottom` is used for.
3121
+ togglePanel("running", { left: rect.left, top: rect.top, bottom: rect.bottom });
3122
+ });
3123
+ }
2651
3124
  const running = normalizeCount(runningTotal);
2652
3125
  const runText = badgeLabel(running);
2653
3126
  if (typeof badgeT === "function") {
2654
- const spoken = badgeT("runningMarkCount").replace("{n}", String(running));
3127
+ // Says what the number is AND that it opens: it is a control now, and a
3128
+ // bare "2 running" would leave a screen reader with no idea it is one.
3129
+ const spoken = badgeT("runningPanelTrigger").replace("{n}", String(running));
2655
3130
  if (runNode.getAttribute("aria-label") !== spoken) {
2656
3131
  runNode.setAttribute("aria-label", spoken);
2657
3132
  runNode.setAttribute("title", spoken);
@@ -2757,30 +3232,33 @@ window.__ModuleLoader__.load({
2757
3232
  }
2758
3233
 
2759
3234
  /**
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.
3235
+ * The list behind those numbers, in the frame-wide overlay layer so it escapes
3236
+ * the sidebar's clipping and scroll container. ONE panel with two modes: the
3237
+ * red count opens "who needs you", the yellow count opens "who is running".
3238
+ * Clicking a row opens that session — which is also what marks an unread one
3239
+ * read. The running mode has no "mark all read": running is a live fact, not a
3240
+ * queue, so there is nothing there to acknowledge.
2763
3241
  */
2764
- function UnreadPopup(props) {
3242
+ function HeaderPopup(props) {
2765
3243
  const [snapshot, setSnapshot] = React.useState(badgeSnapshot);
2766
- const [open, setOpen] = React.useState(unreadPanelOpen);
3244
+ const [mode, setMode] = React.useState(panelMode);
2767
3245
  // Ages are computed against the HOST's clock (the endings are host
2768
3246
  // timestamps); re-tick while open so "刚刚" does not stay stale.
2769
3247
  const [, tick] = React.useState(0);
2770
3248
  React.useEffect(() => subscribeBadge(setSnapshot), []);
2771
- React.useEffect(() => subscribeUnreadPanel(() => setOpen(unreadPanelOpen)), []);
3249
+ React.useEffect(() => subscribePanel(() => setMode(panelMode)), []);
2772
3250
  React.useEffect(() => {
2773
- if (!open) return undefined;
3251
+ if (mode === null) return undefined;
2774
3252
  const interval = window.setInterval(() => tick((n) => n + 1), 30000);
2775
3253
  return () => window.clearInterval(interval);
2776
- }, [open]);
3254
+ }, [mode]);
2777
3255
  React.useEffect(() => {
2778
- if (!open) return undefined;
2779
- const onKey = (event) => { if (event.key === "Escape") emitUnreadPanel(false); };
3256
+ if (mode === null) return undefined;
3257
+ const onKey = (event) => { if (event.key === "Escape") emitPanel(null); };
2780
3258
  const onDown = (event) => {
2781
3259
  const node = event.target;
2782
- if (node !== null && typeof node.closest === "function" && node.closest('[data-icon-custom-unread="1"]') !== null) return;
2783
- emitUnreadPanel(false);
3260
+ if (node !== null && typeof node.closest === "function" && node.closest('[data-icon-custom-panel="1"]') !== null) return;
3261
+ emitPanel(null);
2784
3262
  };
2785
3263
  window.addEventListener("keydown", onKey);
2786
3264
  document.addEventListener("mousedown", onDown, true);
@@ -2788,10 +3266,12 @@ window.__ModuleLoader__.load({
2788
3266
  window.removeEventListener("keydown", onKey);
2789
3267
  document.removeEventListener("mousedown", onDown, true);
2790
3268
  };
2791
- }, [open]);
2792
- if (!open) return null;
3269
+ }, [mode]);
3270
+ if (mode === null) return null;
2793
3271
  const t = props.t;
2794
- const anchor = unreadPanelAnchor;
3272
+ /** Which of the two lists this render is: same shell, different rows. */
3273
+ const running = mode === "running";
3274
+ const anchor = panelAnchor;
2795
3275
  // Measured, not assumed: the panel has to clear whatever width the sidebar
2796
3276
  // currently has (it is resizable and collapsible).
2797
3277
  let sidebarRight = null;
@@ -2806,8 +3286,12 @@ window.__ModuleLoader__.load({
2806
3286
  border: "1px solid var(--dsw-alias-border-l2)", background: "var(--dsw-alias-bg-layer-1)",
2807
3287
  boxShadow: "0 10px 30px rgba(0,0,0,.18)", color: "var(--dsw-alias-label-primary)", fontSize: "12px"
2808
3288
  };
2809
- const items = Array.isArray(snapshot.items) ? snapshot.items : [];
2810
- const ackTargets = unreadAckTargets(items);
3289
+ // The rows of whichever list is open. Both carry `{ id, title, where }`;
3290
+ // only the unread ones carry an ending to acknowledge.
3291
+ const items = running
3292
+ ? (Array.isArray(snapshot.runningItems) ? snapshot.runningItems : [])
3293
+ : (Array.isArray(snapshot.items) ? snapshot.items : []);
3294
+ const ackTargets = running ? [] : unreadAckTargets(items);
2811
3295
  /**
2812
3296
  * Acknowledge every turn-end row at once.
2813
3297
  *
@@ -2821,12 +3305,12 @@ window.__ModuleLoader__.load({
2821
3305
  if (!changed) return;
2822
3306
  saveSeenState();
2823
3307
  emitUnreadPoke();
2824
- if (ackTargets.length === items.length) emitUnreadPanel(false);
3308
+ if (ackTargets.length === items.length) emitPanel(null);
2825
3309
  };
2826
- return React.createElement("div", { "data-icon-custom-unread": "1", style },
2827
- React.createElement("div", { style: { fontWeight: 600, padding: "2px 6px 8px" } }, t("unreadPanelTitle")),
3310
+ return React.createElement("div", { "data-icon-custom-panel": "1", style },
3311
+ React.createElement("div", { style: { fontWeight: 600, padding: "2px 6px 8px" } }, t(running ? "runningPanelTitle" : "unreadPanelTitle")),
2828
3312
  items.length === 0
2829
- ? React.createElement("div", { style: { padding: "2px 6px 8px", opacity: 0.7 } }, t("unreadPanelEmpty"))
3313
+ ? React.createElement("div", { style: { padding: "2px 6px 8px", opacity: 0.7 } }, t(running ? "runningPanelEmpty" : "unreadPanelEmpty"))
2830
3314
  : React.createElement("div", { style: { display: "flex", flexDirection: "column", maxHeight: place.listMaxHeight + "px", overflowY: "auto" } },
2831
3315
  groupUnreadByWorkspace(items).map((group) => React.createElement("div", { key: group.where === "" ? "\u0000none" : group.where },
2832
3316
  React.createElement("div", { style: { padding: "7px 8px 3px", fontSize: "11px", fontWeight: 600, opacity: 0.55, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } },
@@ -2834,11 +3318,11 @@ window.__ModuleLoader__.load({
2834
3318
  ),
2835
3319
  group.items.map((item) => React.createElement("button", {
2836
3320
  key: item.id, type: "button",
2837
- onClick: () => { emitUnreadPanel(false); props.onOpen(item.id); },
3321
+ onClick: () => { emitPanel(null); props.onOpen(item); },
2838
3322
  style: { display: "block", width: "100%", textAlign: "left", padding: "6px 8px", border: "0", borderRadius: "8px", background: "transparent", color: "inherit", font: "inherit", cursor: "pointer" }
2839
3323
  },
2840
3324
  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))
3325
+ React.createElement("span", { style: { display: "block", opacity: 0.6, fontSize: "11px" } }, running ? runningRowSubtitle(item, t) : unreadRowSubtitle(item, hostNow(), t))
2842
3326
  ))
2843
3327
  ))
2844
3328
  ),
@@ -3091,8 +3575,11 @@ window.__ModuleLoader__.load({
3091
3575
  const saveUnreadRule = React.useCallback((patch) => {
3092
3576
  const next = normalizeUnreadConfig({ ...unreadConfig, ...patch });
3093
3577
  emitUnreadConfig(next);
3578
+ // Every field is sent explicitly, and the host replaces its copy with what
3579
+ // arrives — so a field missing HERE is a field that silently reverts to its
3580
+ // default on the next page load, however well the local toggle worked.
3094
3581
  rpc.call("/api", "iconCustom/setUnreadRule", {
3095
- args: { request: { reasons: next.reasons, pending: next.pending, workspaceDot: next.workspaceDot, appBadge: next.appBadge, clearDelaySec: next.clearDelaySec } }
3582
+ args: { request: { reasons: next.reasons, pending: next.pending, workspaceDot: next.workspaceDot, appBadge: next.appBadge, clearDelaySec: next.clearDelaySec, revealOnOpen: next.revealOnOpen } }
3096
3583
  }).catch(() => {});
3097
3584
  }, [rpc]);
3098
3585
  // Toggle one unread reason. Applies locally at once; the host keeps the
@@ -3107,6 +3594,11 @@ window.__ModuleLoader__.load({
3107
3594
  const onWorkspaceDotToggle = React.useCallback((checked) => {
3108
3595
  saveUnreadRule({ workspaceDot: checked });
3109
3596
  }, [saveUnreadRule]);
3597
+ // The second fragile piece's own fuse (see the reveal region): locating a
3598
+ // clicked session drives the shipped sidebar rows.
3599
+ const onRevealToggle = React.useCallback((checked) => {
3600
+ saveUnreadRule({ revealOnOpen: checked });
3601
+ }, [saveUnreadRule]);
3110
3602
  // The system-icon projection of the same number. Saved through the same
3111
3603
  // one save path, so this field can never be dropped by a caller.
3112
3604
  const onAppBadgeToggle = React.useCallback((checked) => {
@@ -3394,6 +3886,17 @@ window.__ModuleLoader__.load({
3394
3886
  ),
3395
3887
  React.createElement("div", { style: style.hint }, t("workspaceDotHint"))
3396
3888
  ),
3889
+ React.createElement("div", { style: { marginTop: "10px" } },
3890
+ React.createElement("label", { style: { display: "inline-flex", alignItems: "center", gap: "6px", fontSize: 12, color: "var(--dsw-alias-label-primary)", cursor: "pointer" } },
3891
+ React.createElement("input", {
3892
+ type: "checkbox",
3893
+ checked: unread.revealOnOpen !== false,
3894
+ onChange: (event) => onRevealToggle(event.target.checked === true)
3895
+ }),
3896
+ React.createElement("span", null, t("revealLabel"))
3897
+ ),
3898
+ React.createElement("div", { style: style.hint }, t("revealHint"))
3899
+ ),
3397
3900
  React.createElement("div", { style: { marginTop: "10px" } },
3398
3901
  React.createElement("label", { style: { display: "inline-flex", alignItems: "center", gap: "6px", fontSize: 12, color: "var(--dsw-alias-label-primary)", cursor: "pointer" } },
3399
3902
  React.createElement("input", {
@@ -3458,6 +3961,18 @@ window.__ModuleLoader__.load({
3458
3961
  // Setting it here (not at module scope) is what keeps the store usable in a
3459
3962
  // test harness with no connection: pushes stay no-ops, the badge still works.
3460
3963
  seenRpc = rpc;
3964
+ // The official "Session on screen" binding, looked up lazily so a build
3965
+ // without it (or a plugin loaded before ui-session) simply answers nothing
3966
+ // and the DOM/retention fallbacks keep the behaviour this file shipped with.
3967
+ officialSessionId = () => {
3968
+ try {
3969
+ const service = ctx.get("uiSession");
3970
+ const current = service !== null && service !== undefined ? service.adapter?.current : undefined;
3971
+ const value = current !== null && current !== undefined && typeof current.getSnapshot === "function" ? current.getSnapshot() : undefined;
3972
+ const key = value !== null && typeof value === "object" ? value.key : undefined;
3973
+ return typeof key === "string" && key !== "" ? key : undefined;
3974
+ } catch { return undefined; }
3975
+ };
3461
3976
  // Pull the host state once, so the brand mark and the badge are correct
3462
3977
  // before the settings section is ever opened. The unread rule rides its
3463
3978
  // own endpoint: the icon RPCs keep their pre-badge response shape.
@@ -3557,7 +4072,7 @@ window.__ModuleLoader__.load({
3557
4072
  // The badge itself is NOT a slot occupant: it is injected into the
3558
4073
  // sidebar's section header by `paintHeaderBadge()` (see above), because
3559
4074
  // `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
4075
+ // the lists they open still ride a slot — the frame-wide overlay, which
3561
4076
  // escapes the sidebar's clipping.
3562
4077
  //
3563
4078
  // 0.2.x has no `ctx.sessions.open(id)` (the ClientSessions service exposes
@@ -3566,15 +4081,23 @@ window.__ModuleLoader__.load({
3566
4081
  // `ctx.uiWorkspace.openSession(id)` (ui-chat does exactly this), so the entry
3567
4082
  // is looked up optionally: navigation is best effort and must never make the
3568
4083
  // favicon half depend on the workspace UI being mounted.
3569
- const openSession = (id) => {
4084
+ const openSession = (row) => {
4085
+ // A list row (`{ id, workspaceId, … }`) since 0.19.0: the workspace is what
4086
+ // the sidebar reveal needs, and the pending list cannot supply it later. A
4087
+ // bare id still opens the session — it just cannot expand anything.
4088
+ const id = row !== null && typeof row === "object" ? row.id : row;
4089
+ const workspaceId = row !== null && typeof row === "object" && typeof row.workspaceId === "string" ? row.workspaceId : "";
3570
4090
  try { ctx.get("uiWorkspace")?.openSession(id); } catch { /* navigation is best effort */ }
4091
+ // Deliberately not awaited, and never allowed to matter: the session is open
4092
+ // either way (see the reveal region for why this is all best effort).
4093
+ void revealSessionRow(id, workspaceId);
3571
4094
  };
3572
4095
  // The injected node labels itself for screen readers, so it needs the
3573
4096
  // locale binder that only exists inside `apply()`.
3574
4097
  badgeT = t;
3575
4098
  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 })
4099
+ { name: "shell.overlay", id: "icon-custom-header-popup", order: 30 },
4100
+ () => React.createElement(HeaderPopup, { t, onOpen: openSession })
3578
4101
  ));
3579
4102
 
3580
4103
  // Real number source: a headless occupant of the frame-wide overlay
@@ -3595,10 +4118,12 @@ window.__ModuleLoader__.load({
3595
4118
  // value that is already on the bridge. The brand mark reads it at
3596
4119
  // mount; the tab icon has to be told, or a plugin hot-reload would
3597
4120
  // leave it restored (the old effect's cleanup) until the next change.
3598
- composeFavicon(effectiveCount(), badgeSize);
4121
+ composeFavicon(effectiveCount(), badgeSize, true);
3599
4122
  return () => {
3600
4123
  unsubscribe();
3601
- composeFavicon(0);
4124
+ // Forced: the promise is that unloading this half leaves the page's
4125
+ // own icon behind, whatever this page load had drawn before.
4126
+ composeFavicon(0, badgeSize, true);
3602
4127
  };
3603
4128
  }, "dsh-icon-custom: favicon badge");
3604
4129
  // Watchdog. The tab icon is the one surface other parties can disturb
@@ -3676,11 +4201,15 @@ window.__ModuleLoader__.load({
3676
4201
  offRunning();
3677
4202
  clearWorkspaceDots();
3678
4203
  clearHeaderBadge();
4204
+ // The one highlight this plugin ever puts on a SHIPPED row: it must not
4205
+ // outlive the plugin, even if the unload lands inside its flash window.
4206
+ clearRevealFlash();
3679
4207
  };
3680
4208
  }, "dsh-icon-custom: workspace dots");
3681
4209
  // The platform favicon.svg repaints itself for dark mode through
3682
- // prefers-color-scheme; a composited bitmap cannot, so rebuild it.
3683
- ctx.on("theme/change", () => { composeFavicon(effectiveCount(), badgeSize); });
4210
+ // prefers-color-scheme; a composited bitmap cannot, so rebuild it —
4211
+ // forced, because the base it was built from is exactly what changed.
4212
+ ctx.on("theme/change", () => { composeFavicon(effectiveCount(), badgeSize, true); });
3684
4213
  }
3685
4214
  exports.apply = apply;
3686
4215
  exports.inject = inject;