@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/README.md +2 -0
- package/README.zh.md +11 -1
- package/client/client.js +715 -186
- package/lib/typert.host.js +6 -3
- package/lib/unread.js +18 -4
- package/package.json +3 -3
package/client/client.js
CHANGED
|
@@ -62,6 +62,11 @@ window.__ModuleLoader__.load({
|
|
|
62
62
|
unreadEnded: "刚结束,还没看",
|
|
63
63
|
unreadPanelOther: "其他",
|
|
64
64
|
unreadMarkAllRead: "全部标记已读",
|
|
65
|
+
/** 进行中清单(点黄字打开):和那个黄数字是同一个事实,不落盘、不推断。 */
|
|
66
|
+
runningPanelTitle: "进行中的会话",
|
|
67
|
+
runningPanelEmpty: "现在没有会话在运行",
|
|
68
|
+
runningPanelTrigger: "{n} 个进行中,点击查看",
|
|
69
|
+
runningPanelRow: "回合正在跑",
|
|
65
70
|
/** 与官方侧栏同一形状("13小时"),方便和列表里的时间戳逐字对上。 */
|
|
66
71
|
ageNow: "刚刚",
|
|
67
72
|
ageMinutes: "{n}分钟",
|
|
@@ -71,6 +76,8 @@ window.__ModuleLoader__.load({
|
|
|
71
76
|
ageYears: "{n}年",
|
|
72
77
|
workspaceDotLabel: "在工作区和会话行上显示红点(实验)",
|
|
73
78
|
workspaceDotHint: "在侧栏工作区那一行的文件夹图标上点一个小红点(收起的工作区里有多少未读,就看它),有未读的会话在其标题右边也跟一个小红点。会话行上的点只在那一行真的显示着时才画:折叠起来的会话由工作区那一行的点和「待处理」清单代表。这一项是贴着页面结构做的,DSH 升级后可能失效;失效时只会变成\"不显示\",不会影响红点数字、清单、跳转这些功能,随时可以关掉它。",
|
|
79
|
+
revealLabel: "点清单里的会话时,在侧栏定位到它(实验)",
|
|
80
|
+
revealHint: "点「待处理」或「进行中」清单里的一条时,除了打开那个会话,还会在侧栏把它所在的工作区展开、滚到那一行、并闪一下。这是贴着页面结构做的:官方只把\"展开 + 滚动\"留给了它自己的搜索框,没有给插件留接口,所以这里只能模拟点击官方的工作区行和「显示更多」。DSH 升级后可能失效,失效的表现只是\"侧栏不动\",打开会话完全不受影响。**展开状态是官方自己记住的**(存在浏览器里),所以它不会自己收回去——不想要就手动收起那个工作区,或者关掉这个开关。",
|
|
74
81
|
appBadgeLabel: "在系统应用图标上显示红点",
|
|
75
82
|
appBadgeHint: "安装为应用后,把同一个数字也放到系统图标上:Windows 是任务栏图标上的角标,macOS 是 Dock 角标,iOS 是主屏图标上的数字。样子由系统决定——Windows 上 Chrome 画深色圆+白字,Edge 走 Windows 系统徽章通道(是否显示数字由 Edge/Windows 决定);插件会周期重设数字,但只在应用窗口开着时更新。Safari 的程序坞网页应用 / 主屏应用还要先允许通知(见下面的「通知权限」),否则角标不会出现。",
|
|
76
83
|
appBadgeFootnote: "角标只在应用窗口开着时由本插件更新;数字超过 99 时由系统显示为 99+。",
|
|
@@ -148,6 +155,11 @@ window.__ModuleLoader__.load({
|
|
|
148
155
|
unreadEnded: "ended, not seen yet",
|
|
149
156
|
unreadPanelOther: "Other",
|
|
150
157
|
unreadMarkAllRead: "Mark all read",
|
|
158
|
+
/** The running list (opened by the yellow number): the very fact that number counts. */
|
|
159
|
+
runningPanelTitle: "Sessions running",
|
|
160
|
+
runningPanelEmpty: "Nothing is running right now",
|
|
161
|
+
runningPanelTrigger: "{n} running — click for the list",
|
|
162
|
+
runningPanelRow: "turn in flight",
|
|
151
163
|
/** Same shape as the shipped sidebar ("13h"), so the two can be read side by side. */
|
|
152
164
|
ageNow: "now",
|
|
153
165
|
ageMinutes: "{n}min",
|
|
@@ -157,6 +169,8 @@ window.__ModuleLoader__.load({
|
|
|
157
169
|
ageYears: "{n}y",
|
|
158
170
|
workspaceDotLabel: "Dots on workspace and session rows (experimental)",
|
|
159
171
|
workspaceDotHint: "Adds a small red dot to the folder icon of each workspace row (a collapsed workspace shows how many of its sessions are unread there), and one beside the title of every unread session row. A session dot is only drawn while that row is really rendered: folded sessions are represented by their workspace row's dot and the pending panel. This one reads the page structure, so a DSH upgrade may break it; when it does it simply stops showing, never affecting the counts, the list, or navigation. Turn it off any time.",
|
|
172
|
+
revealLabel: "Locate the session in the sidebar when a list row is clicked (experimental)",
|
|
173
|
+
revealHint: "Clicking a row in the \"needing you\" or \"running\" list opens that session AND, in the sidebar, expands its workspace, scrolls to the row and flashes it once. This reads the page structure: the official sidebar keeps expand-and-scroll for its own search box and exposes no interface for it, so this drives the shipped workspace row and \"show more\" button the way a click would. A DSH upgrade may break it, and the only symptom is a sidebar that does not move — opening the session is never affected. **The expanded state is remembered by the official sidebar itself** (in the browser), so it will not collapse again on its own: collapse that workspace by hand, or turn this off.",
|
|
160
174
|
appBadgeLabel: "Badge the system app icon",
|
|
161
175
|
appBadgeHint: "Once installed as an app, the same number also goes to the system icon: a taskbar badge on Windows, a Dock badge on macOS, a number on the iOS home-screen icon. The system decides how it looks — on Windows, Chrome draws a dark circle with white text, while Edge goes through the Windows badge channel (whether it shows the number is up to Edge/Windows). The plugin re-asserts the number periodically, but only while the app window is open. Safari's Dock and home-screen web apps additionally need notifications allowed first (see the permission row below), or the badge never appears.",
|
|
162
176
|
appBadgeFootnote: "The badge is only updated by this plugin while the app window is open; above 99 the system shows 99+.",
|
|
@@ -297,7 +311,9 @@ window.__ModuleLoader__.load({
|
|
|
297
311
|
const UNREAD_CLEAR_DELAY_MAX_SEC = 600;
|
|
298
312
|
/** Mirror of the host's default (see lib/unread.js) — badging the app icon is on. */
|
|
299
313
|
const UNREAD_APP_BADGE_FALLBACK = true;
|
|
300
|
-
|
|
314
|
+
/** Mirror of the host's default (see lib/unread.js) — locating a clicked row is on. */
|
|
315
|
+
const UNREAD_REVEAL_FALLBACK = true;
|
|
316
|
+
let unreadConfig = { reasons: { ...UNREAD_FALLBACK_REASONS }, pending: true, workspaceDot: true, appBadge: UNREAD_APP_BADGE_FALLBACK, clearDelaySec: 0, revealOnOpen: UNREAD_REVEAL_FALLBACK };
|
|
301
317
|
const unreadConfigListeners = new Set();
|
|
302
318
|
/** Mirror of the host's normalizer: any input becomes an integer 0..cap seconds. */
|
|
303
319
|
function normalizeClearDelay(input) {
|
|
@@ -319,7 +335,8 @@ window.__ModuleLoader__.load({
|
|
|
319
335
|
pending: typeof source.pending === "boolean" ? source.pending : true,
|
|
320
336
|
workspaceDot: typeof source.workspaceDot === "boolean" ? source.workspaceDot : true,
|
|
321
337
|
appBadge: typeof source.appBadge === "boolean" ? source.appBadge : UNREAD_APP_BADGE_FALLBACK,
|
|
322
|
-
clearDelaySec: normalizeClearDelay(source.clearDelaySec)
|
|
338
|
+
clearDelaySec: normalizeClearDelay(source.clearDelaySec),
|
|
339
|
+
revealOnOpen: typeof source.revealOnOpen === "boolean" ? source.revealOnOpen : UNREAD_REVEAL_FALLBACK
|
|
323
340
|
};
|
|
324
341
|
}
|
|
325
342
|
function emitUnreadConfig(input) {
|
|
@@ -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
|
|
638
|
-
*
|
|
639
|
-
*
|
|
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
|
-
|
|
644
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
676
|
-
*
|
|
677
|
-
*
|
|
678
|
-
*
|
|
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
|
-
*
|
|
682
|
-
*
|
|
683
|
-
*
|
|
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
|
|
686
|
-
* @returns
|
|
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
|
|
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 (
|
|
692
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
1503
|
-
|
|
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
|
-
|
|
1509
|
-
//
|
|
1510
|
-
//
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
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
|
-
|
|
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 =
|
|
1545
|
-
if (
|
|
1546
|
-
if (
|
|
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)
|
|
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
|
-
|
|
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 (
|
|
1610
|
-
composeFavicon(
|
|
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
|
-
*
|
|
1623
|
-
*
|
|
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
|
|
1627
|
-
let
|
|
1628
|
-
const
|
|
1629
|
-
function
|
|
1630
|
-
|
|
1631
|
-
if (anchor !== undefined)
|
|
1632
|
-
|
|
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
|
|
1635
|
-
|
|
1636
|
-
return () => {
|
|
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
|
-
|
|
2332
|
-
|
|
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
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
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,
|
|
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
|
-
//
|
|
2427
|
-
//
|
|
2428
|
-
//
|
|
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:
|
|
2898
|
+
runningSessions: runningRows.map((row) => ({ id: row.id, title: row.title })),
|
|
2449
2899
|
runningWorkspaces: runningWorkspaceDots(runningIds, owners)
|
|
2450
2900
|
});
|
|
2451
|
-
}, [items,
|
|
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,
|
|
2518
|
-
//
|
|
2519
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
|
2761
|
-
* the sidebar's clipping and scroll container.
|
|
2762
|
-
*
|
|
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
|
|
3242
|
+
function HeaderPopup(props) {
|
|
2765
3243
|
const [snapshot, setSnapshot] = React.useState(badgeSnapshot);
|
|
2766
|
-
const [
|
|
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(() =>
|
|
3249
|
+
React.useEffect(() => subscribePanel(() => setMode(panelMode)), []);
|
|
2772
3250
|
React.useEffect(() => {
|
|
2773
|
-
if (
|
|
3251
|
+
if (mode === null) return undefined;
|
|
2774
3252
|
const interval = window.setInterval(() => tick((n) => n + 1), 30000);
|
|
2775
3253
|
return () => window.clearInterval(interval);
|
|
2776
|
-
}, [
|
|
3254
|
+
}, [mode]);
|
|
2777
3255
|
React.useEffect(() => {
|
|
2778
|
-
if (
|
|
2779
|
-
const onKey = (event) => { if (event.key === "Escape")
|
|
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-
|
|
2783
|
-
|
|
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
|
-
}, [
|
|
2792
|
-
if (
|
|
3269
|
+
}, [mode]);
|
|
3270
|
+
if (mode === null) return null;
|
|
2793
3271
|
const t = props.t;
|
|
2794
|
-
|
|
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
|
-
|
|
2810
|
-
|
|
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)
|
|
3308
|
+
if (ackTargets.length === items.length) emitPanel(null);
|
|
2825
3309
|
};
|
|
2826
|
-
return React.createElement("div", { "data-icon-custom-
|
|
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: () => {
|
|
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
|
|
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 = (
|
|
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-
|
|
3577
|
-
() => React.createElement(
|
|
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
|
-
|
|
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
|
-
|
|
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;
|