@cowwo/dsh-icon-custom 0.7.1 → 0.8.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
@@ -33,7 +33,36 @@ window.__ModuleLoader__.load({
33
33
  logoHint: "把网页左上角品牌 logo 里的鲸鱼图标换成这张(不影响 deepseek HARNESS 文字)。",
34
34
  successLogo: "页面 Logo 已更新",
35
35
  fail: "操作失败",
36
- noFile: "请先选择图标文件"
36
+ noFile: "请先选择图标文件",
37
+ badgeTest: "未读红点",
38
+ badgeSourceLabel: "数据来源:",
39
+ badgeSourceReal: "真实未读",
40
+ badgeSourceManual: "手动测试",
41
+ badgeTestUnit: "0 = 不显示",
42
+ badgeSizeLabel: "红点大小:",
43
+ badgeSizeSmall: "小",
44
+ badgeSizeMedium: "中",
45
+ badgeSizeLarge: "大",
46
+ unreadReasonsLabel: "哪些情况算未读(勾选即生效):",
47
+ reasonCompleted: "干完一轮",
48
+ reasonError: "报错",
49
+ reasonBlocked: "被卡住",
50
+ reasonMaxTokens: "输出超限",
51
+ reasonInterrupted: "崩溃中断",
52
+ reasonAbortedUser: "我自己停止",
53
+ reasonAbortedOther: "其他中断",
54
+ reasonPending: "有人在等我(审批/提问/计划)",
55
+ unreadPanelShort: "待处理",
56
+ unreadPanelCount: "{n} 个待处理",
57
+ unreadPanelNone: "暂无待处理",
58
+ unreadPanelTitle: "待处理的会话",
59
+ unreadPanelEmpty: "现在没有需要你处理的事情",
60
+ unreadWaiting: "在等你",
61
+ unreadEnded: "刚结束,还没看",
62
+ unreadPanelOther: "其他",
63
+ workspaceDotLabel: "在工作区和会话行上显示红点(实验)",
64
+ workspaceDotHint: "在侧栏工作区那一行的文件夹图标上点一个小红点。这一项是贴着页面结构做的,DSH 升级后可能失效;失效时只会变成\"不显示\",不会影响红点数字、清单、跳转这些功能,随时可以关掉它。",
65
+ badgeTestHint: "真实未读 = 有会话发生了上面勾选的情况、而且你还没看过它(打开该会话即视为已读)。正在看的会话和子代理不计。手动模式:自己填数字试看效果。刷新页面后回到真实未读。"
37
66
  };
38
67
  const en = {
39
68
  nav: "Favicon",
@@ -59,7 +88,36 @@ window.__ModuleLoader__.load({
59
88
  logoHint: "Replaces the whale mark beside the brand name in the top-left (the deepseek HARNESS text stays).",
60
89
  successLogo: "Page logo updated",
61
90
  fail: "Operation failed",
62
- noFile: "Choose a file first"
91
+ noFile: "Choose a file first",
92
+ badgeTest: "Unread badge",
93
+ badgeSourceLabel: "Number source:",
94
+ badgeSourceReal: "Real unread",
95
+ badgeSourceManual: "Manual",
96
+ badgeTestUnit: "0 = hidden",
97
+ badgeSizeLabel: "Badge size:",
98
+ badgeSizeSmall: "Small",
99
+ badgeSizeMedium: "Medium",
100
+ badgeSizeLarge: "Large",
101
+ unreadReasonsLabel: "Count these as unread:",
102
+ reasonCompleted: "Turn finished",
103
+ reasonError: "Error",
104
+ reasonBlocked: "Blocked",
105
+ reasonMaxTokens: "Hit token cap",
106
+ reasonInterrupted: "Interrupted",
107
+ reasonAbortedUser: "I stopped it",
108
+ reasonAbortedOther: "Other cancel",
109
+ reasonPending: "Someone is waiting",
110
+ unreadPanelShort: "Pending",
111
+ unreadPanelCount: "{n} pending",
112
+ unreadPanelNone: "Nothing pending",
113
+ unreadPanelTitle: "Sessions needing you",
114
+ unreadPanelEmpty: "Nothing needs you right now",
115
+ unreadWaiting: "waiting for you",
116
+ unreadEnded: "ended, not seen yet",
117
+ unreadPanelOther: "Other",
118
+ workspaceDotLabel: "Dots on workspace and session rows (experimental)",
119
+ workspaceDotHint: "Adds a small red dot to the folder icon of each workspace row. 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.",
120
+ badgeTestHint: "Real unread = a session ended for one of the checked reasons and you have not looked at it yet (opening a session marks it read). The session you are viewing and sub-agents never count. Manual = type a number to preview. Resets to Real on reload."
63
121
  };
64
122
  //#endregion
65
123
 
@@ -113,6 +171,8 @@ window.__ModuleLoader__.load({
113
171
  img.src = url;
114
172
  });
115
173
  }
174
+ //#endregion
175
+ //#region logo bridge
116
176
  // In-package brand-mark bridge: the settings section pushes the current
117
177
  // logo state here; the sidebar brand-mark component subscribes to it.
118
178
  // Both live in this same module — no RPC loop, no cross-package events.
@@ -124,16 +184,843 @@ window.__ModuleLoader__.load({
124
184
  logoListeners.add(fn);
125
185
  return () => { logoListeners.delete(fn); };
126
186
  }
127
- function syncLogo(status) {
187
+ function syncStatus(status) {
188
+ const known = status !== null && typeof status === "object";
128
189
  emitLogo({
129
- enabled: status !== null && typeof status === "object" && status.active === true && status.logo === true,
130
- rev: status && status.rev ? status.rev : null,
131
- png: !!(status && typeof status === "object" && status.png === true)
190
+ enabled: known && status.active === true && status.logo === true,
191
+ rev: known && status.rev ? status.rev : null,
192
+ png: known && status.png === true
193
+ });
194
+ // The plugin's own status — not the served HTML — decides which icon the
195
+ // page should show. A cached/proxied page, a stale HTML rewrite, or a
196
+ // browser extension can all leave the wrong link behind; this makes the
197
+ // client authoritative about the icon it composites onto.
198
+ iconState = {
199
+ active: known && status.active === true,
200
+ rev: known && typeof status.rev === "string" && status.rev !== "" ? status.rev : null,
201
+ png: known && status.png === true,
202
+ mime: known && typeof status.mime === "string" ? status.mime : null
203
+ };
204
+ reconcileFavicon();
205
+ }
206
+
207
+ //#endregion
208
+ //#region unread state
209
+ // ---------------------------------------------------------------------
210
+ // Unread badge: which endings count, and what this browser has seen.
211
+ //
212
+ // The host folds every session's last `turn/end` into an official session
213
+ // projection (`lastTurnEnd`); it travels to the browser inside the ordinary
214
+ // session list, so this half never asks the host per session. Two local
215
+ // inputs finish the job:
216
+ // * the rule — which reasons light the badge. Persisted by the host,
217
+ // mirrored here so a toggle applies without a round trip;
218
+ // * the seen watermark — per browser, in localStorage, because "have I
219
+ // already looked at this session" is a fact about this browser.
220
+ // ---------------------------------------------------------------------
221
+ const LAST_TURN_END_KEY = "lastTurnEnd";
222
+ /** Reason key → locale key, for the settings checkboxes. */
223
+ const REASON_LABEL_KEYS = {
224
+ completed: "reasonCompleted",
225
+ error: "reasonError",
226
+ blocked: "reasonBlocked",
227
+ "max-tokens": "reasonMaxTokens",
228
+ interrupted: "reasonInterrupted",
229
+ "aborted:user": "reasonAbortedUser",
230
+ "aborted:other": "reasonAbortedOther"
231
+ };
232
+ /** Mirror of the host defaults; only used before the first status arrives. */
233
+ const UNREAD_FALLBACK_REASONS = { completed: true, error: true, blocked: true, "max-tokens": true, interrupted: true, "aborted:user": false, "aborted:other": true };
234
+ let unreadConfig = { reasons: { ...UNREAD_FALLBACK_REASONS }, pending: true, workspaceDot: true };
235
+ const unreadConfigListeners = new Set();
236
+ function normalizeUnreadConfig(input) {
237
+ const source = input !== null && typeof input === "object" ? input : {};
238
+ const raw = source.reasons !== null && typeof source.reasons === "object" ? source.reasons : {};
239
+ const reasons = {};
240
+ for (const key of Object.keys(UNREAD_FALLBACK_REASONS)) {
241
+ reasons[key] = typeof raw[key] === "boolean" ? raw[key] : UNREAD_FALLBACK_REASONS[key];
242
+ }
243
+ return {
244
+ reasons,
245
+ pending: typeof source.pending === "boolean" ? source.pending : true,
246
+ workspaceDot: typeof source.workspaceDot === "boolean" ? source.workspaceDot : true
247
+ };
248
+ }
249
+ function emitUnreadConfig(input) {
250
+ unreadConfig = normalizeUnreadConfig(input);
251
+ unreadConfigListeners.forEach((fn) => { try { fn(unreadConfig); } catch {} });
252
+ }
253
+ function subscribeUnreadConfig(fn) {
254
+ unreadConfigListeners.add(fn);
255
+ return () => { unreadConfigListeners.delete(fn); };
256
+ }
257
+
258
+ const SEEN_STORE_KEY = "dsh-icon-custom.unread-seen.v1";
259
+ const SEEN_MAX = 400;
260
+ function loadSeenState() {
261
+ try {
262
+ const raw = JSON.parse(window.localStorage.getItem(SEEN_STORE_KEY) || "null");
263
+ if (raw === null || typeof raw !== "object") return { lastActiveAt: 0, seen: {} };
264
+ const seen = raw.seen !== null && typeof raw.seen === "object" ? raw.seen : {};
265
+ return { lastActiveAt: typeof raw.lastActiveAt === "number" ? raw.lastActiveAt : 0, seen };
266
+ } catch { return { lastActiveAt: 0, seen: {} }; }
267
+ }
268
+ const seenState = loadSeenState();
269
+ /** When this browser last had the app open before this page load. */
270
+ const seenBootAt = seenState.lastActiveAt > 0 ? seenState.lastActiveAt : 0;
271
+ function saveSeenState() {
272
+ try { window.localStorage.setItem(SEEN_STORE_KEY, JSON.stringify(seenState)); } catch {}
273
+ }
274
+ function pruneSeenState() {
275
+ const ids = Object.keys(seenState.seen);
276
+ if (ids.length <= SEEN_MAX) return;
277
+ ids.sort((a, b) => seenState.seen[b] - seenState.seen[a]);
278
+ for (const id of ids.slice(SEEN_MAX)) delete seenState.seen[id];
279
+ }
280
+ /** Record that this browser is looking at a session right now. */
281
+ function noteSeen(sessionId) {
282
+ if (typeof sessionId !== "string" || sessionId === "") return false;
283
+ seenState.seen[sessionId] = Date.now();
284
+ return true;
285
+ }
286
+ /**
287
+ * First sight of a session is watermarked at the last time this browser was
288
+ * here — so an ending that happened while the app was closed still counts as
289
+ * unread, while endings from before the plugin was installed do not.
290
+ * @returns whether anything was learned (callers persist only then).
291
+ */
292
+ function ensureSeenBaseline(ids) {
293
+ const fallback = seenBootAt > 0 ? seenBootAt : Date.now();
294
+ let changed = false;
295
+ for (const id of ids) {
296
+ if (typeof id !== "string" || id === "") continue;
297
+ if (typeof seenState.seen[id] === "number") continue;
298
+ seenState.seen[id] = fallback;
299
+ changed = true;
300
+ }
301
+ return changed;
302
+ }
303
+ /**
304
+ * How many sessions deserve the badge right now: endings whose reason is
305
+ * enabled and that this browser has not seen since, plus sessions waiting
306
+ * for you (when that source is enabled). Sub-agent sessions and the session
307
+ * you are looking at never count.
308
+ * @param list - the session list snapshot (may be absent).
309
+ * @param pending - the pending-interaction map (may be absent).
310
+ * @param config - the unread rule to apply.
311
+ * @returns the badge number.
312
+ */
313
+ function collectUnread(list, pending, config, seen) {
314
+ const reasons = config !== null && typeof config === "object" && config.reasons !== null && typeof config.reasons === "object" ? config.reasons : {};
315
+ // The watermark is a parameter, not module state: this stays a pure
316
+ // decision function that a test can drive with any marks it likes.
317
+ const marks = seen !== null && typeof seen === "object" ? seen : {};
318
+ /** { id, title, waiting, kind, at } — newest first, "in wait" ahead of "ended". */
319
+ const hits = [];
320
+ const ids = new Set();
321
+ const current = list !== null && typeof list === "object" ? list.current : undefined;
322
+ const byId = list !== null && typeof list === "object" && list.byId !== null && typeof list.byId === "object" ? list.byId : {};
323
+ const titleOf = (id, entry) => (entry !== null && typeof entry === "object" && typeof entry.displayTitle === "string" && entry.displayTitle !== "" ? entry.displayTitle : id);
324
+ // Which workspace the session lives in. The sidebar's own workspace rows are
325
+ // labelled with this cwd basename, so the list points at the same place the
326
+ // reader sees — the sidebar group cannot be expanded for us (that state is
327
+ // private to the shipped browser), so naming it is the honest substitute.
328
+ const whereOf = (entry) => {
329
+ const cwd = entry !== null && typeof entry === "object" && typeof entry.cwd === "string" ? entry.cwd : "";
330
+ if (cwd === "") return "";
331
+ const parts = cwd.replace(/[\\/]+$/, "").split(/[\\/]/);
332
+ return parts[parts.length - 1] || cwd;
333
+ };
334
+ if (list !== null && typeof list === "object") {
335
+ const order = Array.isArray(list.ids) ? list.ids : Object.keys(byId);
336
+ for (const id of order) {
337
+ const entry = byId[id];
338
+ if (entry === null || typeof entry !== "object") continue;
339
+ if (entry.origin === "subagent") continue;
340
+ if (id === current) continue;
341
+ const values = entry.projectionValues;
342
+ const value = values !== null && typeof values === "object" ? values[LAST_TURN_END_KEY] : undefined;
343
+ if (value === null || typeof value !== "object") continue;
344
+ const endAt = typeof value.endAt === "number" ? value.endAt : 0;
345
+ const seenAt = marks[id];
346
+ if (endAt <= 0 || typeof seenAt !== "number" || endAt <= seenAt) continue;
347
+ // Unknown reasons (a newer DSH adds one) stay hidden until the user
348
+ // turns them on, so a vocabulary growth can never surprise the badge.
349
+ if (reasons[value.reason] !== true) continue;
350
+ ids.add(id);
351
+ hits.push({ id, title: titleOf(id, entry), where: whereOf(entry), waiting: false, kind: value.reason, at: endAt });
352
+ }
353
+ }
354
+ if (config === null || typeof config !== "object" || config.pending !== false) {
355
+ if (pending !== null && pending !== undefined && typeof pending.forEach === "function") {
356
+ pending.forEach((value, id) => {
357
+ if (id === current) return;
358
+ const kind = value !== null && typeof value === "object" && typeof value.kind === "string" ? value.kind : "";
359
+ if (ids.has(id)) {
360
+ // Already unread by its last turn: it is waiting too, which wins.
361
+ const hit = hits.find((candidate) => candidate.id === id);
362
+ if (hit !== undefined) { hit.waiting = true; hit.kind = kind; }
363
+ return;
364
+ }
365
+ ids.add(id);
366
+ hits.push({ id, title: titleOf(id, byId[id]), where: whereOf(byId[id]), waiting: true, kind, at: Date.now() });
367
+ });
368
+ }
369
+ }
370
+ hits.sort((a, b) => (b.waiting === true ? 1 : 0) - (a.waiting === true ? 1 : 0) || b.at - a.at);
371
+ return hits;
372
+ }
373
+
374
+ //#endregion
375
+ //#region badge bridge
376
+ // In-package badge bridge. Two number sources:
377
+ // "real" — DSH's own pending-interaction registry (the same source the
378
+ // sidebar session-row marker uses): how many sessions are
379
+ // waiting for you right now. This is the default.
380
+ // "manual" — the settings test input, for eyeballing sizes and numbers.
381
+ // `md` is the default size; `sm` keeps the original 1.0 multiplier.
382
+ const BADGE_SCALES = { sm: 1, md: 1.15, lg: 1.3 };
383
+ let badgeSource = "real";
384
+ let realCount = 0;
385
+ /** The sessions behind `realCount`, for the sidebar list (newest first). */
386
+ let realItems = [];
387
+ let manualCount = 0;
388
+ let badgeSize = "md";
389
+ const badgeListeners = new Set();
390
+ function normalizeCount(value) {
391
+ const n = Number(value);
392
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
393
+ }
394
+ function effectiveCount() {
395
+ return badgeSource === "manual" ? manualCount : realCount;
396
+ }
397
+ function badgeSnapshot() {
398
+ return { count: effectiveCount(), size: badgeSize, source: badgeSource, items: badgeSource === "manual" ? [] : realItems };
399
+ }
400
+ function notifyBadge() {
401
+ const snapshot = badgeSnapshot();
402
+ badgeListeners.forEach((fn) => { try { fn(snapshot); } catch {} });
403
+ }
404
+ /** Manual test input. */
405
+ function emitBadge(value) {
406
+ manualCount = normalizeCount(value);
407
+ notifyBadge();
408
+ }
409
+ /** The real source: the sessions needing you, and which they are. */
410
+ function emitRealBadge(value, items) {
411
+ realCount = normalizeCount(value);
412
+ realItems = Array.isArray(items) ? items : [];
413
+ notifyBadge();
414
+ }
415
+ function emitBadgeSize(value) {
416
+ badgeSize = Object.prototype.hasOwnProperty.call(BADGE_SCALES, value) ? value : "md";
417
+ notifyBadge();
418
+ }
419
+ function emitBadgeSource(value) {
420
+ badgeSource = value === "manual" ? "manual" : "real";
421
+ notifyBadge();
422
+ }
423
+ function subscribeBadge(fn) {
424
+ badgeListeners.add(fn);
425
+ return () => { badgeListeners.delete(fn); };
426
+ }
427
+ function badgeScale(size) {
428
+ return BADGE_SCALES[size] || BADGE_SCALES.md;
429
+ }
430
+ function badgeLabel(count) {
431
+ return count > 99 ? "99+" : String(count);
432
+ }
433
+
434
+ //#endregion
435
+ //#region favicon compositing
436
+ // The browser accepts exactly ONE image per <link rel="icon">, so a tab
437
+ // badge cannot be layered: base icon and badge are redrawn together into a
438
+ // canvas and the merged bitmap is handed back to the browser. Count 0 (and
439
+ // teardown) restores the pristine platform/custom href.
440
+ const FAVICON_SIZE = 64;
441
+ let faviconBase = null;
442
+ let composeSeq = 0;
443
+ let faviconComposing = false;
444
+ /** Last compose failure, so a repeated failure warns once instead of spamming. */
445
+ let lastComposeError = null;
446
+ /** Which icon the plugin considers current (filled from the status RPC). */
447
+ let iconState = { active: false, rev: null, png: false, mime: null };
448
+ /** The icon URL this page SHOULD be showing, per the plugin's own status. */
449
+ function desiredIconHref() {
450
+ if (iconState.active === true && typeof iconState.rev === "string" && iconState.rev !== "") {
451
+ return iconState.png === true ? `/icon-custom-192.png?v=${iconState.rev}` : `/icon-custom.svg?v=${iconState.rev}`;
452
+ }
453
+ return "/favicon.svg";
454
+ }
455
+ function desiredIconType() {
456
+ if (iconState.active !== true) return "image/svg+xml";
457
+ return iconState.png === true ? "image/png" : (iconState.mime || "image/svg+xml");
458
+ }
459
+ function iconLink() {
460
+ let link = document.querySelector('link[rel="icon"]');
461
+ if (!link) {
462
+ link = document.createElement("link");
463
+ link.rel = "icon";
464
+ link.type = "image/svg+xml";
465
+ link.href = "/favicon.svg";
466
+ document.head.appendChild(link);
467
+ }
468
+ return link;
469
+ }
470
+ function roundRect(c2d, x, y, w, h) {
471
+ const r = Math.min(h / 2, w / 2);
472
+ c2d.beginPath();
473
+ c2d.moveTo(x + r, y);
474
+ c2d.arcTo(x + w, y, x + w, y + h, r);
475
+ c2d.arcTo(x + w, y + h, x, y + h, r);
476
+ c2d.arcTo(x, y + h, x, y, r);
477
+ c2d.arcTo(x, y, x + w, y, r);
478
+ c2d.closePath();
479
+ }
480
+ function drawFaviconBadge(c2d, size, count, scale) {
481
+ const text = badgeLabel(count);
482
+ const r = size * 0.28 * (scale || 1);
483
+ const wide = text.length > 1;
484
+ const w = wide ? r * 2 + size * 0.16 * (text.length - 1) : r * 2;
485
+ const h = r * 2;
486
+ const cx = size - w / 2 - size * 0.02;
487
+ const cy = h / 2 + size * 0.02;
488
+ c2d.save();
489
+ // White ring so the badge stays readable on any base artwork.
490
+ c2d.fillStyle = "#ffffff";
491
+ roundRect(c2d, cx - w / 2 - size * 0.03, cy - h / 2 - size * 0.03, w + size * 0.06, h + size * 0.06);
492
+ c2d.fill();
493
+ c2d.fillStyle = "#e5484d";
494
+ roundRect(c2d, cx - w / 2, cy - h / 2, w, h);
495
+ c2d.fill();
496
+ c2d.fillStyle = "#ffffff";
497
+ c2d.font = `700 ${Math.round(h * (wide ? 0.56 : 0.68))}px system-ui, -apple-system, sans-serif`;
498
+ c2d.textAlign = "center";
499
+ c2d.textBaseline = "middle";
500
+ c2d.fillText(text, cx, cy + size * 0.01);
501
+ c2d.restore();
502
+ }
503
+ function loadIconImage(href) {
504
+ return new Promise((resolve, reject) => {
505
+ const img = new Image();
506
+ img.onload = () => resolve(img);
507
+ img.onerror = () => reject(new Error("icon-load"));
508
+ img.src = href;
132
509
  });
133
510
  }
511
+ async function applyBadgeToFavicon(count, size) {
512
+ const link = iconLink();
513
+ const base = desiredIconHref();
514
+ if (count <= 0) {
515
+ // Nothing to show: make sure the page carries the plugin's own icon
516
+ // (this also repairs a stale/proxied link that still points elsewhere).
517
+ if (link.getAttribute("href") !== base) {
518
+ link.setAttribute("href", base);
519
+ link.setAttribute("type", desiredIconType());
520
+ }
521
+ faviconBase = null;
522
+ return;
523
+ }
524
+ // The base is resolved from the plugin's status, never from whatever the
525
+ // page happens to carry, and never from our own previous bitmap.
526
+ faviconBase = { href: base, type: desiredIconType() };
527
+ const seq = ++composeSeq;
528
+ faviconComposing = true;
529
+ try {
530
+ const img = await loadIconImage(new URL(faviconBase.href, document.baseURI).href);
531
+ if (seq !== composeSeq) return;
532
+ const canvas = document.createElement("canvas");
533
+ canvas.width = FAVICON_SIZE;
534
+ canvas.height = FAVICON_SIZE;
535
+ const c2d = canvas.getContext("2d");
536
+ const iw = img.naturalWidth || img.width || FAVICON_SIZE;
537
+ const ih = img.naturalHeight || img.height || FAVICON_SIZE;
538
+ const scale = Math.min(FAVICON_SIZE / iw, FAVICON_SIZE / ih);
539
+ const dw = iw * scale;
540
+ const dh = ih * scale;
541
+ c2d.drawImage(img, (FAVICON_SIZE - dw) / 2, (FAVICON_SIZE - dh) / 2, dw, dh);
542
+ drawFaviconBadge(c2d, FAVICON_SIZE, count, badgeScale(size));
543
+ link.setAttribute("href", canvas.toDataURL("image/png"));
544
+ link.setAttribute("type", "image/png");
545
+ } catch (error) {
546
+ // Never silent: a failure here is invisible in the UI (the tab simply
547
+ // keeps its previous icon), which once cost a whole debugging session.
548
+ // Warn once per distinct reason so the 5s watchdog cannot spam.
549
+ const why = String((error && error.message) || error);
550
+ if (why !== lastComposeError) {
551
+ lastComposeError = why;
552
+ try { console.warn("dsh-icon-custom: 标签页红点合成失败 / favicon badge compose failed:", why); } catch {}
553
+ }
554
+ } finally {
555
+ if (seq === composeSeq) faviconComposing = false;
556
+ }
557
+ }
558
+ /**
559
+ * Re-assert the tab badge after anything outside this plugin disturbed the
560
+ * icon link — a cached page, a browser extension, a tab move, or a plugin
561
+ * reload. Our own output is a `data:` URL, so any other href means the
562
+ * composite was replaced and has to be rebuilt from the new base.
563
+ */
564
+ function reconcileFavicon() {
565
+ const link = document.querySelector('link[rel="icon"]');
566
+ const href = link === null ? null : link.getAttribute("href");
567
+ const ours = typeof href === "string" && href.slice(0, 5) === "data:";
568
+ const wanted = desiredIconHref();
569
+ if (effectiveCount() <= 0) {
570
+ // No badge to draw, but the page must still carry the plugin's icon.
571
+ if (ours || href !== wanted) applyBadgeToFavicon(0);
572
+ return;
573
+ }
574
+ if (ours || faviconComposing) return; // already showing, or a build is running
575
+ applyBadgeToFavicon(effectiveCount(), badgeSize);
576
+ }
577
+ // A freshly applied base icon replaces whatever the browser holds, so the
578
+ // composite must be rebuilt from the NEW base instead of the stale one.
579
+ function refreshFaviconBase() {
580
+ faviconBase = null;
581
+ applyBadgeToFavicon(effectiveCount(), badgeSize);
582
+ }
583
+ //#endregion
584
+
585
+ //#region unread panel
586
+ /**
587
+ * Whether the "which sessions?" list is open, and where its button sits.
588
+ * The foot entry and the floating list live in two different slots, so they
589
+ * cannot share React state — this tiny store is what they agree through.
590
+ */
591
+ let unreadPanelOpen = false;
592
+ let unreadPanelAnchor = null;
593
+ const unreadPanelListeners = new Set();
594
+ function emitUnreadPanel(open, anchor) {
595
+ unreadPanelOpen = open === true;
596
+ if (anchor !== undefined) unreadPanelAnchor = anchor;
597
+ unreadPanelListeners.forEach((fn) => { try { fn(); } catch {} });
598
+ }
599
+ function subscribeUnreadPanel(fn) {
600
+ unreadPanelListeners.add(fn);
601
+ return () => { unreadPanelListeners.delete(fn); };
602
+ }
603
+ function toggleUnreadPanel(anchor) {
604
+ emitUnreadPanel(!unreadPanelOpen, anchor === undefined ? null : anchor);
605
+ }
606
+ /**
607
+ * Roll the pending sessions up to one dot per workspace.
608
+ *
609
+ * Pure: `titles` maps sessionId → the workspace title the sidebar shows
610
+ * (assembled from the official workspace snapshot); a session missing from it
611
+ * falls back to the cwd basename `collectUnread` already carries.
612
+ * @returns `[{ name, count }]`, busiest workspace first.
613
+ */
614
+ function dotsFromItems(items, titles) {
615
+ const counts = new Map();
616
+ for (const item of Array.isArray(items) ? items : []) {
617
+ if (item === null || typeof item !== "object") continue;
618
+ const mapped = titles !== null && typeof titles === "object" ? titles[item.id] : undefined;
619
+ const name = typeof mapped === "string" && mapped !== "" ? mapped : (typeof item.where === "string" ? item.where : "");
620
+ if (name === "") continue;
621
+ counts.set(name, (counts.get(name) || 0) + 1);
622
+ }
623
+ return [...counts.entries()]
624
+ .map(([name, count]) => ({ name, count }))
625
+ .sort((a, b) => b.count - a.count || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
626
+ }
627
+
628
+ /**
629
+ * Group the pending rows by workspace for the sidebar list.
630
+ *
631
+ * Pure and order-preserving: rows arrive already sorted by urgency, so the
632
+ * group holding the most urgent row naturally comes first, and rows that
633
+ * carry no workspace label fall into their own unlabelled bucket.
634
+ * @param items - rows from `collectUnread`.
635
+ * @returns `[{ where, items }]`.
636
+ */
637
+ function groupUnreadByWorkspace(items) {
638
+ const groups = [];
639
+ const index = new Map();
640
+ for (const item of Array.isArray(items) ? items : []) {
641
+ const where = item !== null && typeof item === "object" && typeof item.where === "string" ? item.where : "";
642
+ let group = index.get(where);
643
+ if (group === undefined) {
644
+ group = { where, items: [] };
645
+ index.set(where, group);
646
+ groups.push(group);
647
+ }
648
+ group.items.push(item);
649
+ }
650
+ return groups;
651
+ }
652
+ //#endregion
653
+
654
+ //#region workspace dots
655
+ /**
656
+ * One dot on each workspace row's folder icon.
657
+ *
658
+ * This is the only part of the plugin that reaches into the shipped DOM: the
659
+ * browsing region is a single opaque seat (`sidebar.workspaces`, kind
660
+ * `single`) with no per-row slots, so a marker can only be injected. It is
661
+ * therefore built to fail quietly — see `noteWorkspaceDotFailure`, the
662
+ * give-up switch, and the `workspaceDot` setting that removes it entirely.
663
+ */
664
+ const WS_DOT_ATTR = "data-icon-custom-wsdot";
665
+ const ROW_DOT_ATTR = "data-icon-custom-rowdot";
666
+ const WS_ROW_MIN_H = 18;
667
+ const WS_ROW_MAX_H = 56;
668
+ const WS_ROW_MIN_W = 80;
669
+ const WS_MAX_FAILURES = 3;
670
+ let workspaceDots = [];
671
+ /** One entry per unread session: `{ id, title }`, matched against the row title. */
672
+ let sessionDots = [];
673
+ let workspaceDotFailures = 0;
674
+ let workspaceDotGivenUp = false;
675
+ let workspaceDotPainting = false;
676
+ /** When the last paint ran, so the observer can tell our churn from everyone else's. */
677
+ let workspaceDotPaintedAt = 0;
678
+ /** Pending "did our markers survive?" check, one shot per creation burst. */
679
+ let workspaceDotVerify = 0;
680
+ /** Rows whose inline `position` we changed, and what it was before. */
681
+ let workspaceDotRestores = [];
682
+ const workspaceDotListeners = new Set();
683
+ function emitSidebarMarks(marks) {
684
+ const next = marks !== null && typeof marks === "object" ? marks : {};
685
+ workspaceDots = Array.isArray(next.workspaces) ? next.workspaces : [];
686
+ sessionDots = Array.isArray(next.sessions) ? next.sessions : [];
687
+ workspaceDotListeners.forEach((fn) => { try { fn(); } catch {} });
688
+ }
689
+ function subscribeSidebarMarks(fn) {
690
+ workspaceDotListeners.add(fn);
691
+ return () => { workspaceDotListeners.delete(fn); };
692
+ }
693
+ /** The shipped browsing region, by the official slot marker (never by class name). */
694
+ function workspaceDotContainer() {
695
+ return document.querySelector('[data-slot="sidebar.workspaces"]')
696
+ || document.querySelector('[data-slot="sidebar"]')
697
+ || null;
698
+ }
699
+ function clearWorkspaceDots() {
700
+ try { document.querySelectorAll("[" + WS_DOT_ATTR + "],[" + ROW_DOT_ATTR + "]").forEach((node) => node.remove()); } catch {}
701
+ for (const entry of workspaceDotRestores) { try { entry.el.style.position = entry.position; } catch {} }
702
+ workspaceDotRestores = [];
703
+ }
704
+ function noteWorkspaceDotFailure(why) {
705
+ workspaceDotFailures++;
706
+ if (workspaceDotFailures === 1) {
707
+ try { console.warn("dsh-icon-custom: 工作区红点定位失败,已降级(不影响其他功能):", why); } catch {}
708
+ return;
709
+ }
710
+ if (workspaceDotFailures < WS_MAX_FAILURES || workspaceDotGivenUp) return;
711
+ workspaceDotGivenUp = true;
712
+ try { console.warn("dsh-icon-custom: 工作区红点连续失败,本次会话内已自动关闭,可在设置里关掉这一项。"); } catch {}
713
+ }
714
+ /** The marker already sitting in this host for that key, or null. */
715
+ function existingMarker(host, attribute, key) {
716
+ for (const child of host.children) {
717
+ if (child.getAttribute(attribute) === key) return child;
718
+ }
719
+ return null;
720
+ }
721
+ /** The one element whose trimmed text is exactly this name, or null. */
722
+ function labelElementFor(container, name) {
723
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null);
724
+ let hit = null;
725
+ let hits = 0;
726
+ let node = walker.nextNode();
727
+ while (node !== null) {
728
+ if (node.nodeValue !== null && node.nodeValue.trim() === name) { hits++; hit = node.parentElement; }
729
+ node = walker.nextNode();
730
+ }
731
+ return hits === 1 ? hit : null;
732
+ }
733
+ /**
734
+ * The row box + folder icon a marker should hang off.
735
+ *
736
+ * Walks up from the label until it finds a row-sized box that ALSO contains an
737
+ * icon sitting left of the text — the only shape that means "this is the
738
+ * workspace row". Requiring that icon is load-bearing: without it the label
739
+ * itself (or any wider wrapper) qualifies as the row, and the marker lands at
740
+ * the end of the text instead of on the icon.
741
+ * @returns `{ row, icon }`, or null when this row cannot be trusted.
742
+ */
743
+ function markerTargetFor(label) {
744
+ const labelRect = label.getBoundingClientRect();
745
+ let element = label;
746
+ for (let step = 0; step < 6 && element !== null && element !== undefined; step++) {
747
+ const icon = element.querySelector("svg");
748
+ if (icon !== null) {
749
+ const rect = element.getBoundingClientRect();
750
+ const iconRect = icon.getBoundingClientRect();
751
+ if (rect.height >= WS_ROW_MIN_H && rect.height <= WS_ROW_MAX_H && rect.width >= WS_ROW_MIN_W && iconRect.left < labelRect.left) {
752
+ return { row: element, icon };
753
+ }
754
+ }
755
+ element = element.parentElement;
756
+ }
757
+ return null;
758
+ }
759
+ /**
760
+ * The title element a session marker hangs off.
761
+ *
762
+ * A session row has no icon to anchor on, so its marker is inserted inline right
763
+ * after the title. That only works when `label` is the TIGHT holder of the title
764
+ * text: a wrapper that merely contains it would push the marker past the
765
+ * timestamp, and a row holding the text directly has no inline slot at all.
766
+ * @returns `{ title, row }`, or null when this row cannot be trusted.
767
+ */
768
+ function titleTargetFor(label, title) {
769
+ if (label === null || label === undefined) return null;
770
+ if (typeof label.textContent !== "string" || label.textContent.trim() !== title) return null;
771
+ const row = label.parentElement;
772
+ if (row === null || row === undefined || row === label) return null;
773
+ const rect = row.getBoundingClientRect();
774
+ if (rect.height < WS_ROW_MIN_H || rect.height > WS_ROW_MAX_H || rect.width < WS_ROW_MIN_W) return null;
775
+ return { title: label, row };
776
+ }
777
+ function paintWorkspaceDots() {
778
+ if (workspaceDotGivenUp) return;
779
+ const enabled = unreadConfig.workspaceDot !== false;
780
+ const wanted = enabled ? workspaceDots : [];
781
+ const wantedRows = enabled ? sessionDots : [];
782
+ const container = workspaceDotContainer();
783
+ workspaceDotPainting = true;
784
+ workspaceDotPaintedAt = Date.now();
785
+ let placed = 0;
786
+ let created = 0;
787
+ let missed = 0;
788
+ try {
789
+ // Reconcile, never wipe-and-redraw: a marker that is already correct is
790
+ // left untouched, so one unlucky lookup (a re-render in flight, a name
791
+ // matched twice for a moment) can no longer blink an existing dot away.
792
+ const wantedNames = new Set(wanted.map((entry) => entry.name));
793
+ const wantedIds = new Set(wantedRows.map((entry) => entry.id));
794
+ document.querySelectorAll("[" + WS_DOT_ATTR + "]").forEach((node) => {
795
+ if (!node.isConnected || !wantedNames.has(node.getAttribute(WS_DOT_ATTR))) node.remove();
796
+ });
797
+ document.querySelectorAll("[" + ROW_DOT_ATTR + "]").forEach((node) => {
798
+ if (!node.isConnected || !wantedIds.has(node.getAttribute(ROW_DOT_ATTR))) node.remove();
799
+ });
800
+ if (container === null) { workspaceDotPainting = false; return; } // start-up race
801
+ for (const entry of wanted) {
802
+ const label = labelElementFor(container, entry.name);
803
+ const target = label === null ? null : markerTargetFor(label);
804
+ if (target === null) { missed++; continue; }
805
+ const row = target.row;
806
+ if (existingMarker(row, WS_DOT_ATTR, entry.name) !== null) { placed++; continue; }
807
+ const icon = target.icon;
808
+ const rowRect = row.getBoundingClientRect();
809
+ const anchorRect = icon.getBoundingClientRect();
810
+ if (window.getComputedStyle(row).position === "static") {
811
+ workspaceDotRestores.push({ el: row, position: row.style.position });
812
+ row.style.position = "relative";
813
+ }
814
+ const dot = document.createElement("span");
815
+ dot.setAttribute(WS_DOT_ATTR, entry.name);
816
+ dot.setAttribute("aria-hidden", "true");
817
+ dot.title = entry.name + " · " + entry.count;
818
+ dot.style.cssText = "position:absolute;pointer-events:none;width:8px;height:8px;border-radius:50%;"
819
+ + "background:#e5484d;box-shadow:0 0 0 1.5px var(--dsw-alias-bg-layer-1,#fff);"
820
+ + "left:" + Math.round(anchorRect.right - rowRect.left - 4) + "px;"
821
+ + "top:" + Math.round(anchorRect.top - rowRect.top - 3) + "px;";
822
+ row.appendChild(dot);
823
+ placed++;
824
+ created++;
825
+ }
826
+ // Session rows: the marker rides inline right after the title, so it stays
827
+ // beside the name whatever the timestamp happens to say.
828
+ for (const entry of wantedRows) {
829
+ const label = labelElementFor(container, entry.title);
830
+ const target = titleTargetFor(label, entry.title);
831
+ if (target === null) { missed++; continue; }
832
+ if (existingMarker(target.row, ROW_DOT_ATTR, entry.id) !== null) { placed++; continue; }
833
+ const dot = document.createElement("span");
834
+ dot.setAttribute(ROW_DOT_ATTR, entry.id);
835
+ dot.setAttribute("aria-hidden", "true");
836
+ dot.title = entry.title;
837
+ // Tight to the title on the left, and kept off the timestamp on the
838
+ // right: with no right margin the dot reads as part of "24 分钟".
839
+ dot.style.cssText = "pointer-events:none;flex:none;width:8px;height:8px;border-radius:50%;"
840
+ + "background:#e5484d;margin:0 4px 0 6px;";
841
+ target.title.insertAdjacentElement("afterend", dot);
842
+ placed++;
843
+ created++;
844
+ }
845
+ } catch (error) {
846
+ missed++;
847
+ noteWorkspaceDotFailure(String((error && error.message) || error));
848
+ } finally {
849
+ workspaceDotPainting = false;
850
+ }
851
+ // The breaker watches the MECHANISM, not one unlucky row: only a run that
852
+ // placed nothing at all counts as a failure.
853
+ if (placed > 0) workspaceDotFailures = 0;
854
+ else if (missed > 0) noteWorkspaceDotFailure("这一轮没有任何工作区行能定位:" + missed + " 个");
855
+ // If the shipped UI committed a re-render inside our quiet window it could
856
+ // have taken a fresh marker with it, and we would have ignored that
857
+ // mutation. One settle check shortly after closes that hole.
858
+ if (created > 0) {
859
+ if (workspaceDotVerify !== 0) window.clearTimeout(workspaceDotVerify);
860
+ workspaceDotVerify = window.setTimeout(() => { workspaceDotVerify = 0; paintWorkspaceDots(); }, 700);
861
+ }
862
+ }
134
863
  //#endregion
135
864
 
136
865
  //#region components
866
+ /**
867
+ * Headless occupant of the frame-wide `shell.overlay` slot. It reads DSH's
868
+ * own pending-interaction registry — the very same source that paints the
869
+ * sidebar session-row marker — and feeds "how many sessions are waiting for
870
+ * you" into the badge bridge. Renders nothing.
871
+ *
872
+ * This is the entire integration: an official standard prop goes in, a
873
+ * number comes out. The icon plugin never learns which plugin published an
874
+ * interaction, so swapping or removing any of them cannot break it — the
875
+ * count simply drops back to 0.
876
+ */
877
+ function BadgeSource(props) {
878
+ // Both hooks are official root-scope standard props: the session list
879
+ // (which carries every session's `lastTurnEnd` projection value) and the
880
+ // pending-interaction registry the sidebar row marker already uses.
881
+ const list = typeof props.useSessions === "function" ? props.useSessions((state) => state) : undefined;
882
+ const pending = typeof props.useSessionPendingInteraction === "function" ? props.useSessionPendingInteraction((map) => map) : undefined;
883
+ const workspaces = typeof props.useWorkspaces === "function" ? props.useWorkspaces((state) => state) : undefined;
884
+ const [config, setConfig] = React.useState(unreadConfig);
885
+ React.useEffect(() => subscribeUnreadConfig(setConfig), []);
886
+ // Baseline new sessions; persists only when it actually learns something.
887
+ React.useEffect(() => {
888
+ const ids = list !== null && typeof list === "object" && Array.isArray(list.ids) ? list.ids : [];
889
+ if (!ensureSeenBaseline(ids)) return;
890
+ pruneSeenState();
891
+ saveSeenState();
892
+ }, [list]);
893
+ // Looking at a session marks it seen; the count also excludes it outright.
894
+ const currentId = list !== null && typeof list === "object" ? list.current : undefined;
895
+ React.useEffect(() => {
896
+ if (noteSeen(currentId)) saveSeenState();
897
+ }, [currentId]);
898
+ const items = collectUnread(list, pending, config, seenState.seen);
899
+ const count = items.length;
900
+ React.useEffect(() => { emitRealBadge(count, items); }, [count, items]);
901
+ // The workspace rows are labelled with the workspace TITLE, which is not
902
+ // always the cwd basename, so map session → title from the official snapshot.
903
+ React.useEffect(() => {
904
+ const titles = {};
905
+ const list = workspaces !== null && typeof workspaces === "object" && Array.isArray(workspaces.items) ? workspaces.items : [];
906
+ for (const workspace of list) {
907
+ if (workspace === null || typeof workspace !== "object") continue;
908
+ const title = typeof workspace.title === "string" ? workspace.title : "";
909
+ if (title === "" || !Array.isArray(workspace.sessionIds)) continue;
910
+ for (const id of workspace.sessionIds) titles[id] = title;
911
+ }
912
+ emitSidebarMarks({
913
+ workspaces: dotsFromItems(items, titles),
914
+ sessions: items.map((item) => ({ id: item.id, title: item.title }))
915
+ });
916
+ }, [items, workspaces]);
917
+ return null;
918
+ }
919
+
920
+ /**
921
+ * The sidebar-foot entry: a bell carrying the unread count, beside the
922
+ * shipped Settings and Cordis buttons. Both that seat and the overlay list
923
+ * are additive (`replaceRisk: none`) — a fresh id is added beside the others,
924
+ * nothing shipped is shadowed.
925
+ */
926
+ function UnreadFooterButton(props) {
927
+ const [snapshot, setSnapshot] = React.useState(badgeSnapshot);
928
+ const [open, setOpen] = React.useState(unreadPanelOpen);
929
+ const ref = React.useRef(null);
930
+ React.useEffect(() => subscribeBadge(setSnapshot), []);
931
+ React.useEffect(() => subscribeUnreadPanel(() => setOpen(unreadPanelOpen)), []);
932
+ const t = props.t;
933
+ const count = snapshot.count;
934
+ const wide = props.wide !== false;
935
+ const label = count > 0 ? t("unreadPanelCount").replace("{n}", String(count)) : t("unreadPanelNone");
936
+ const onClick = () => {
937
+ const node = ref.current;
938
+ const rect = node !== null && typeof node.getBoundingClientRect === "function" ? node.getBoundingClientRect() : null;
939
+ toggleUnreadPanel(rect === null ? null : { left: rect.left, top: rect.top });
940
+ };
941
+ return React.createElement("button", {
942
+ ref, type: "button", onClick, title: label, "aria-label": label, "aria-expanded": open,
943
+ style: {
944
+ position: "relative", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "6px",
945
+ height: "32px", width: wide ? "auto" : "32px", padding: wide ? "0 10px" : "0",
946
+ border: "1px solid var(--dsw-alias-border-l2)", borderRadius: "8px", background: "transparent",
947
+ color: "var(--dsw-alias-label-primary)", cursor: "pointer", font: "inherit", fontSize: "12px",
948
+ opacity: count > 0 ? 1 : 0.55
949
+ }
950
+ },
951
+ React.createElement("svg", { width: 16, height: 16, viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", style: { flex: "none" } },
952
+ React.createElement("path", { d: "M8 2.2a3.6 3.6 0 0 0-3.6 3.6v2.4L3.2 11h9.6l-1.2-2.8V5.8A3.6 3.6 0 0 0 8 2.2Z", stroke: "currentColor", strokeWidth: 1.3, strokeLinejoin: "round" }),
953
+ React.createElement("path", { d: "M6.4 12.8a1.6 1.6 0 0 0 3.2 0", stroke: "currentColor", strokeWidth: 1.3, strokeLinecap: "round" })
954
+ ),
955
+ wide ? React.createElement("span", null, t("unreadPanelShort")) : null,
956
+ count > 0 ? React.createElement("span", {
957
+ "aria-hidden": "true",
958
+ style: { position: "absolute", top: "-6px", right: "-6px", minWidth: "16px", height: "16px", padding: "0 4px", boxSizing: "border-box", borderRadius: "999px", background: "#e5484d", color: "#fff", fontSize: "10px", fontWeight: 700, lineHeight: 1, display: "flex", alignItems: "center", justifyContent: "center", fontVariantNumeric: "tabular-nums", pointerEvents: "none" }
959
+ }, badgeLabel(count)) : null
960
+ );
961
+ }
962
+
963
+ /**
964
+ * The list behind that number, in the frame-wide overlay layer so it escapes
965
+ * the sidebar's clipping and scroll container. Clicking a row opens that
966
+ * session — which is also what marks it read.
967
+ */
968
+ function UnreadPopup(props) {
969
+ const [snapshot, setSnapshot] = React.useState(badgeSnapshot);
970
+ const [open, setOpen] = React.useState(unreadPanelOpen);
971
+ React.useEffect(() => subscribeBadge(setSnapshot), []);
972
+ React.useEffect(() => subscribeUnreadPanel(() => setOpen(unreadPanelOpen)), []);
973
+ React.useEffect(() => {
974
+ if (!open) return undefined;
975
+ const onKey = (event) => { if (event.key === "Escape") emitUnreadPanel(false); };
976
+ const onDown = (event) => {
977
+ const node = event.target;
978
+ if (node !== null && typeof node.closest === "function" && node.closest('[data-icon-custom-unread="1"]') !== null) return;
979
+ emitUnreadPanel(false);
980
+ };
981
+ window.addEventListener("keydown", onKey);
982
+ document.addEventListener("mousedown", onDown, true);
983
+ return () => {
984
+ window.removeEventListener("keydown", onKey);
985
+ document.removeEventListener("mousedown", onDown, true);
986
+ };
987
+ }, [open]);
988
+ if (!open) return null;
989
+ const t = props.t;
990
+ const anchor = unreadPanelAnchor;
991
+ const style = {
992
+ position: "fixed", left: "12px", bottom: "52px", zIndex: 60, pointerEvents: "auto",
993
+ minWidth: "240px", maxWidth: "340px", padding: "8px", borderRadius: "12px",
994
+ border: "1px solid var(--dsw-alias-border-l2)", background: "var(--dsw-alias-bg-layer-1)",
995
+ boxShadow: "0 10px 30px rgba(0,0,0,.18)", color: "var(--dsw-alias-label-primary)", fontSize: "12px"
996
+ };
997
+ if (anchor !== null && typeof anchor === "object") {
998
+ style.left = Math.max(8, Math.round(anchor.left)) + "px";
999
+ style.bottom = Math.max(8, Math.round(window.innerHeight - anchor.top + 6)) + "px";
1000
+ }
1001
+ const items = Array.isArray(snapshot.items) ? snapshot.items : [];
1002
+ return React.createElement("div", { "data-icon-custom-unread": "1", style },
1003
+ React.createElement("div", { style: { fontWeight: 600, padding: "2px 6px 8px" } }, t("unreadPanelTitle")),
1004
+ items.length === 0
1005
+ ? React.createElement("div", { style: { padding: "2px 6px 8px", opacity: 0.7 } }, t("unreadPanelEmpty"))
1006
+ : React.createElement("div", { style: { display: "flex", flexDirection: "column", maxHeight: "320px", overflowY: "auto" } },
1007
+ groupUnreadByWorkspace(items).map((group) => React.createElement("div", { key: group.where === "" ? "\u0000none" : group.where },
1008
+ React.createElement("div", { style: { padding: "7px 8px 3px", fontSize: "11px", fontWeight: 600, opacity: 0.55, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } },
1009
+ group.where === "" ? t("unreadPanelOther") : group.where
1010
+ ),
1011
+ group.items.map((item) => React.createElement("button", {
1012
+ key: item.id, type: "button",
1013
+ onClick: () => { emitUnreadPanel(false); props.onOpen(item.id); },
1014
+ style: { display: "block", width: "100%", textAlign: "left", padding: "6px 8px", border: "0", borderRadius: "8px", background: "transparent", color: "inherit", font: "inherit", cursor: "pointer" }
1015
+ },
1016
+ React.createElement("span", { style: { display: "block", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, item.title),
1017
+ React.createElement("span", { style: { display: "block", opacity: 0.6, fontSize: "11px" } }, item.waiting === true ? t("unreadWaiting") : t("unreadEnded"))
1018
+ ))
1019
+ ))
1020
+ )
1021
+ );
1022
+ }
1023
+
137
1024
  /**
138
1025
  * Sidebar brand-mark occupant (registered on the official
139
1026
  * `sidebar.brand.mark` single seat). Shows the custom icon when the
@@ -143,26 +1030,62 @@ window.__ModuleLoader__.load({
143
1030
  */
144
1031
  function BrandMark(props) {
145
1032
  const [state, setState] = React.useState(null);
1033
+ const [badge, setBadge] = React.useState(badgeSnapshot);
146
1034
  React.useEffect(() => {
147
1035
  const unsubscribe = subscribeLogo(setState);
148
1036
  // Pull the current state on mount too, so the mark is correct
149
1037
  // even when the settings panel was never opened this session.
150
1038
  props.rpc.call("/api", "iconCustom/getStatus", { args: {} })
151
- .then((resp) => { if (resp && resp.ok === true) syncLogo(resp.value); })
1039
+ .then((resp) => { if (resp && resp.ok === true) syncStatus(resp.value); })
152
1040
  .catch(() => {});
153
1041
  return unsubscribe;
154
1042
  }, [props.rpc]);
1043
+ React.useEffect(() => subscribeBadge(setBadge), []);
155
1044
  const size = (props && props.size) || 24;
156
1045
  const custom = state && state.enabled === true && state.rev;
157
1046
  const style = { width: size, height: size, objectFit: "contain", display: "block" };
158
1047
  const src = custom
159
1048
  ? (state.png === true ? `/icon-custom-192.png?v=${state.rev}` : `/icon-custom.svg?v=${state.rev}`)
160
1049
  : "/favicon.svg";
161
- return React.createElement("img", {
1050
+ const img = React.createElement("img", {
162
1051
  src,
163
1052
  alt: "",
164
1053
  style
165
1054
  });
1055
+ // No badge: keep the exact original element (no wrapper, no layout risk).
1056
+ if (badge.count <= 0) return img;
1057
+ const edge = Math.max(12, Math.round(size * 0.5 * badgeScale(badge.size)));
1058
+ const label = `${badgeLabel(badge.count)} pending (test)`;
1059
+ return React.createElement("span", {
1060
+ style: { position: "relative", display: "inline-flex", width: size, height: size, flex: "none" }
1061
+ },
1062
+ img,
1063
+ React.createElement("span", {
1064
+ title: label,
1065
+ "aria-label": label,
1066
+ style: {
1067
+ position: "absolute",
1068
+ top: 0,
1069
+ right: 0,
1070
+ minWidth: edge,
1071
+ height: edge,
1072
+ padding: "0 2px",
1073
+ boxSizing: "border-box",
1074
+ borderRadius: 999,
1075
+ background: "#e5484d",
1076
+ color: "#fff",
1077
+ border: "1.5px solid var(--dsw-alias-bg-layer-1, #fff)",
1078
+ fontSize: Math.max(8, Math.round(edge * 0.62)),
1079
+ fontWeight: 700,
1080
+ lineHeight: 1,
1081
+ display: "flex",
1082
+ alignItems: "center",
1083
+ justifyContent: "center",
1084
+ fontVariantNumeric: "tabular-nums",
1085
+ pointerEvents: "none"
1086
+ }
1087
+ }, badgeLabel(badge.count))
1088
+ );
166
1089
  }
167
1090
 
168
1091
  function FaviconSection(props) {
@@ -174,6 +1097,11 @@ window.__ModuleLoader__.load({
174
1097
  const [busy, setBusy] = React.useState(false);
175
1098
  const [error, setError] = React.useState("");
176
1099
  const [notice, setNotice] = React.useState("");
1100
+ const [badgeInput, setBadgeInput] = React.useState(() => (manualCount > 0 ? String(manualCount) : ""));
1101
+ const [badgeSizeValue, setBadgeSizeValue] = React.useState(badgeSize);
1102
+ const [badgeSourceValue, setBadgeSourceValue] = React.useState(badgeSource);
1103
+ const [unread, setUnread] = React.useState(unreadConfig);
1104
+ React.useEffect(() => subscribeUnreadConfig(setUnread), []);
177
1105
  const inputRef = React.useRef(null);
178
1106
 
179
1107
  const load = React.useCallback(async () => {
@@ -183,7 +1111,7 @@ window.__ModuleLoader__.load({
183
1111
  setStatus(resp.value);
184
1112
  setPwaEnabled(resp.value.pwa === true);
185
1113
  setLogoEnabled(resp.value.logo === true);
186
- syncLogo(resp.value);
1114
+ syncStatus(resp.value);
187
1115
  } else setError(reason(resp, t("fail")));
188
1116
  } catch (e) {
189
1117
  setError(String((e && e.message) || e || t("fail")));
@@ -211,6 +1139,8 @@ window.__ModuleLoader__.load({
211
1139
  el.href = href;
212
1140
  document.head.appendChild(el);
213
1141
  }
1142
+ // The base icon just changed: rebuild the composite from it.
1143
+ refreshFaviconBase();
214
1144
  }, []);
215
1145
 
216
1146
  // When cleared, point the icon back at the platform default.
@@ -220,10 +1150,62 @@ window.__ModuleLoader__.load({
220
1150
  link.setAttribute("href", "/favicon.svg");
221
1151
  link.setAttribute("type", "image/svg+xml");
222
1152
  }
1153
+ refreshFaviconBase();
223
1154
  }, []);
224
1155
 
225
1156
  const pick = React.useCallback(() => inputRef.current && inputRef.current.click(), []);
226
1157
 
1158
+ // Test-only: type a number to push it into the badge bridge. Empty or 0
1159
+ // clears the badge. Not persisted — a reload starts from 0 again.
1160
+ const onBadgeInput = React.useCallback((event) => {
1161
+ const raw = String(event.target.value || "").replace(/[^0-9]/g, "");
1162
+ setBadgeInput(raw);
1163
+ emitBadge(raw === "" ? 0 : Math.min(999, parseInt(raw, 10) || 0));
1164
+ }, []);
1165
+ const onBadgeSize = React.useCallback((value) => {
1166
+ setBadgeSizeValue(value);
1167
+ emitBadgeSize(value);
1168
+ }, []);
1169
+ // "real" = DSH's pending-interaction count; "manual" = the input below.
1170
+ const onBadgeSource = React.useCallback((value) => {
1171
+ setBadgeSourceValue(value);
1172
+ emitBadgeSource(value);
1173
+ }, []);
1174
+ // Toggle one unread reason. Applies locally at once; the host keeps the
1175
+ // durable copy so the rule survives a restart and other browsers.
1176
+ const onUnreadToggle = React.useCallback((key, checked) => {
1177
+ const current = unreadConfig;
1178
+ const reasons = Object.assign({}, current.reasons);
1179
+ let pending = current.pending;
1180
+ if (key === "pending") pending = checked;
1181
+ else reasons[key] = checked;
1182
+ const next = normalizeUnreadConfig({ reasons, pending, workspaceDot: current.workspaceDot });
1183
+ emitUnreadConfig(next);
1184
+ rpc.call("/api", "iconCustom/setUnreadRule", {
1185
+ args: { request: { reasons: next.reasons, pending: next.pending, workspaceDot: next.workspaceDot } }
1186
+ }).catch(() => {});
1187
+ }, [rpc]);
1188
+ // The fuse for the one fragile piece (see the workspace-dots region).
1189
+ const onWorkspaceDotToggle = React.useCallback((checked) => {
1190
+ const next = normalizeUnreadConfig({ reasons: unreadConfig.reasons, pending: unreadConfig.pending, workspaceDot: checked });
1191
+ emitUnreadConfig(next);
1192
+ rpc.call("/api", "iconCustom/setUnreadRule", {
1193
+ args: { request: { reasons: next.reasons, pending: next.pending, workspaceDot: next.workspaceDot } }
1194
+ }).catch(() => {});
1195
+ }, [rpc]);
1196
+ /** One unread checkbox; `key === "pending"` is the "someone is waiting" row. */
1197
+ const renderReasonCheckbox = (key, labelKey) => React.createElement("label", {
1198
+ key,
1199
+ style: { display: "inline-flex", alignItems: "center", gap: "6px", marginRight: "14px", marginTop: "6px", fontSize: 12, color: "var(--dsw-alias-label-primary)", cursor: "pointer" }
1200
+ },
1201
+ React.createElement("input", {
1202
+ type: "checkbox",
1203
+ checked: key === "pending" ? unread.pending === true : unread.reasons[key] === true,
1204
+ onChange: (event) => onUnreadToggle(key, event.target.checked === true)
1205
+ }),
1206
+ React.createElement("span", null, t(labelKey))
1207
+ );
1208
+
227
1209
  const onFileChange = React.useCallback(async (event) => {
228
1210
  const file = event.target.files && event.target.files[0];
229
1211
  event.target.value = "";
@@ -264,7 +1246,7 @@ window.__ModuleLoader__.load({
264
1246
  setLogoEnabled(resp.value.logo === true);
265
1247
  setNotice(t("successUpload"));
266
1248
  applyLive(resp.value);
267
- syncLogo(resp.value);
1249
+ syncStatus(resp.value);
268
1250
  } else {
269
1251
  setError(reason(resp, t("fail")));
270
1252
  }
@@ -290,7 +1272,7 @@ window.__ModuleLoader__.load({
290
1272
  setLogoEnabled(false);
291
1273
  setNotice(t("successReset"));
292
1274
  applyDefault();
293
- syncLogo(resp.value);
1275
+ syncStatus(resp.value);
294
1276
  } else {
295
1277
  setError(reason(resp, t("fail")));
296
1278
  }
@@ -340,7 +1322,7 @@ window.__ModuleLoader__.load({
340
1322
  setStatus(resp.value);
341
1323
  setLogoEnabled(resp.value.logo === true);
342
1324
  setNotice(t("successLogo"));
343
- syncLogo(resp.value);
1325
+ syncStatus(resp.value);
344
1326
  } else {
345
1327
  setLogoEnabled(!enabled);
346
1328
  setError(reason(resp, t("fail")));
@@ -404,17 +1386,95 @@ window.__ModuleLoader__.load({
404
1386
  React.createElement("div", { style: style.hint }, t("logoHint")),
405
1387
  React.createElement("div", { style: style.hint }, t("uploadHint")),
406
1388
  React.createElement("div", { style: style.hint }, t("resetHint")),
1389
+ React.createElement("div", { style: { borderTop: "1px solid var(--dsw-alias-border-l2)", marginTop: "12px", paddingTop: "12px" } },
1390
+ React.createElement("div", { style: style.label }, t("badgeTest")),
1391
+ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "8px", marginTop: "6px" } },
1392
+ React.createElement("span", { style: style.desc }, t("badgeSourceLabel")),
1393
+ ["real", "manual"].map((key) => React.createElement("button", {
1394
+ key,
1395
+ type: "button",
1396
+ onClick: () => onBadgeSource(key),
1397
+ "aria-pressed": badgeSourceValue === key,
1398
+ style: { ...(badgeSourceValue === key ? style.btnPrimary : style.btn), padding: "4px 10px" }
1399
+ }, t(key === "real" ? "badgeSourceReal" : "badgeSourceManual")))
1400
+ ),
1401
+ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "8px", marginTop: "10px" } },
1402
+ React.createElement("input", {
1403
+ type: "number",
1404
+ min: 0,
1405
+ max: 999,
1406
+ value: badgeInput,
1407
+ onChange: onBadgeInput,
1408
+ disabled: badgeSourceValue !== "manual",
1409
+ "aria-label": t("badgeTest"),
1410
+ style: { width: 88, padding: "5px 8px", fontSize: 12, borderRadius: 8, border: "1px solid var(--dsw-alias-border-l2)", background: "var(--dsw-alias-bg-layer-2)", color: "var(--dsw-alias-label-primary)", opacity: badgeSourceValue === "manual" ? 1 : 0.45, cursor: badgeSourceValue === "manual" ? "text" : "not-allowed" }
1411
+ }),
1412
+ React.createElement("span", { style: style.desc }, t("badgeTestUnit"))
1413
+ ),
1414
+ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "8px", marginTop: "10px" } },
1415
+ React.createElement("span", { style: style.desc }, t("badgeSizeLabel")),
1416
+ ["sm", "md", "lg"].map((key) => React.createElement("button", {
1417
+ key,
1418
+ type: "button",
1419
+ onClick: () => onBadgeSize(key),
1420
+ "aria-pressed": badgeSizeValue === key,
1421
+ style: { ...(badgeSizeValue === key ? style.btnPrimary : style.btn), padding: "4px 10px" }
1422
+ }, t(key === "sm" ? "badgeSizeSmall" : key === "md" ? "badgeSizeMedium" : "badgeSizeLarge")))
1423
+ ),
1424
+ React.createElement("div", { style: { marginTop: "12px" } },
1425
+ React.createElement("div", { style: style.desc }, t("unreadReasonsLabel")),
1426
+ React.createElement("div", { style: { display: "flex", flexWrap: "wrap", alignItems: "center" } },
1427
+ ["completed", "error", "blocked", "max-tokens", "interrupted", "aborted:user", "aborted:other"].map((key) => renderReasonCheckbox(key, REASON_LABEL_KEYS[key])),
1428
+ renderReasonCheckbox("pending", "reasonPending")
1429
+ )
1430
+ ),
1431
+ React.createElement("div", { style: { marginTop: "10px" } },
1432
+ React.createElement("label", { style: { display: "inline-flex", alignItems: "center", gap: "6px", fontSize: 12, color: "var(--dsw-alias-label-primary)", cursor: "pointer" } },
1433
+ React.createElement("input", {
1434
+ type: "checkbox",
1435
+ checked: unread.workspaceDot !== false,
1436
+ onChange: (event) => onWorkspaceDotToggle(event.target.checked === true)
1437
+ }),
1438
+ React.createElement("span", null, t("workspaceDotLabel"))
1439
+ ),
1440
+ React.createElement("div", { style: style.hint }, t("workspaceDotHint"))
1441
+ ),
1442
+ React.createElement("div", { style: style.hint }, t("badgeTestHint"))
1443
+ ),
407
1444
  notice ? React.createElement("div", { style: style.notice }, notice) : null,
408
1445
  error ? React.createElement("div", { style: style.error }, error) : null
409
1446
  );
410
1447
  }
411
1448
  //#endregion
412
1449
 
413
- const inject = ["slots", "locale", "connection"];
1450
+ const inject = ["slots", "locale", "connection", "sessions"];
414
1451
  function apply(ctx) {
415
1452
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-icon-custom: dictionaries");
416
1453
  const t = ctx.locale.bind(NS);
417
1454
  const rpc = ctx.connection.rpc;
1455
+ // Pull the host state once, so the brand mark and the badge are correct
1456
+ // before the settings section is ever opened. The unread rule rides its
1457
+ // own endpoint: the icon RPCs keep their pre-badge response shape.
1458
+ rpc.call("/api", "iconCustom/getStatus", { args: {} })
1459
+ .then((resp) => { if (resp && resp.ok === true) syncStatus(resp.value); })
1460
+ .catch(() => {});
1461
+ rpc.call("/api", "iconCustom/getUnreadRule", { args: {} })
1462
+ .then((resp) => { if (resp && resp.ok === true) emitUnreadConfig(resp.value); })
1463
+ .catch(() => {});
1464
+ // Keep "the last moment this browser was here" fresh, so an ending that
1465
+ // happens while the app is closed still counts as unread when you return.
1466
+ ctx.effect(() => {
1467
+ const touch = () => { seenState.lastActiveAt = Date.now(); saveSeenState(); };
1468
+ const interval = window.setInterval(touch, 20000);
1469
+ window.addEventListener("visibilitychange", touch);
1470
+ window.addEventListener("beforeunload", touch);
1471
+ return () => {
1472
+ window.clearInterval(interval);
1473
+ window.removeEventListener("visibilitychange", touch);
1474
+ window.removeEventListener("beforeunload", touch);
1475
+ touch();
1476
+ };
1477
+ }, "dsh-icon-custom: unread watermark");
418
1478
  // Brand-mark seat: replaces only the whale mark (verified: a
419
1479
  // third-party registration wins over the official occupant and the
420
1480
  // official package's mark steps aside). The brand NAME text is a
@@ -431,6 +1491,111 @@ window.__ModuleLoader__.load({
431
1491
  locale: NS,
432
1492
  inject: () => ({ t })
433
1493
  }, (props) => React.createElement(FaviconSection, { ...props, t, rpc })));
1494
+
1495
+ // Additive seats for "where exactly?" — a foot entry beside the shipped
1496
+ // Settings/Cordis buttons, and the frame-wide overlay for its list. A fresh
1497
+ // id is added beside the others; nothing shipped is shadowed.
1498
+ const openSession = (id) => {
1499
+ try { ctx.sessions.open(id); } catch { /* navigation is best effort */ }
1500
+ };
1501
+ ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register(
1502
+ { name: "sidebar.footer.action", id: "icon-custom-unread", order: 20, label: () => t("unreadPanelShort") },
1503
+ (props) => React.createElement(UnreadFooterButton, { ...props, t, onOpen: openSession })
1504
+ ));
1505
+ ctx.slots.inject("shell.overlay", () => ctx.slots.register(
1506
+ { name: "shell.overlay", id: "icon-custom-unread-popup", order: 30 },
1507
+ () => React.createElement(UnreadPopup, { t, onOpen: openSession })
1508
+ ));
1509
+
1510
+ // Real number source: a headless occupant of the frame-wide overlay
1511
+ // layer reads DSH's official pending-interaction registry and pushes it
1512
+ // into the badge bridge. No plugin is addressed by name.
1513
+ ctx.slots.inject("shell.overlay", () => ctx.slots.register(
1514
+ { name: "shell.overlay", id: "icon-custom-badge-source" },
1515
+ (props) => React.createElement(BadgeSource, props)
1516
+ ));
1517
+
1518
+ // Test-only badge wiring: the settings input drives the in-package
1519
+ // bridge, the brand mark reads it through its own subscription, and
1520
+ // this effect keeps the tab icon in sync. Teardown restores the
1521
+ // pristine platform/custom icon.
1522
+ ctx.effect(() => {
1523
+ const unsubscribe = subscribeBadge((snapshot) => { applyBadgeToFavicon(snapshot.count, snapshot.size); });
1524
+ // Level-triggered on purpose: (re)applying this half must adopt the
1525
+ // value that is already on the bridge. The brand mark reads it at
1526
+ // mount; the tab icon has to be told, or a plugin hot-reload would
1527
+ // leave it restored (the old effect's cleanup) until the next change.
1528
+ applyBadgeToFavicon(effectiveCount(), badgeSize);
1529
+ return () => {
1530
+ unsubscribe();
1531
+ applyBadgeToFavicon(0);
1532
+ };
1533
+ }, "dsh-icon-custom: favicon badge");
1534
+ // Watchdog. The tab icon is the one surface other parties can disturb
1535
+ // (cached HTML, browser extensions, tab moves, plugin reloads), so keep
1536
+ // re-asserting the composite instead of trusting one-shot application.
1537
+ ctx.effect(() => {
1538
+ let queued = false;
1539
+ const schedule = () => {
1540
+ if (queued) return;
1541
+ queued = true;
1542
+ window.setTimeout(() => { queued = false; reconcileFavicon(); }, 150);
1543
+ };
1544
+ const observer = new MutationObserver(schedule);
1545
+ observer.observe(document.head, { childList: true, subtree: true, attributes: true, attributeFilter: ["href", "rel"] });
1546
+ window.addEventListener("visibilitychange", schedule);
1547
+ window.addEventListener("focus", schedule);
1548
+ const interval = window.setInterval(reconcileFavicon, 5000);
1549
+ return () => {
1550
+ observer.disconnect();
1551
+ window.removeEventListener("visibilitychange", schedule);
1552
+ window.removeEventListener("focus", schedule);
1553
+ window.clearInterval(interval);
1554
+ };
1555
+ }, "dsh-icon-custom: favicon watchdog");
1556
+ // Workspace dots: the one fragile piece. It waits for the sidebar, keeps a
1557
+ // marker on each workspace row whose folder icon it can find, and gives up
1558
+ // on its own after repeated failures. It lives entirely outside React, so a
1559
+ // throw here can never reach anyone's render tree.
1560
+ ctx.effect(() => {
1561
+ let observer = null;
1562
+ let retry = 0;
1563
+ let queued = false;
1564
+ const schedule = () => {
1565
+ if (queued) return;
1566
+ queued = true;
1567
+ window.setTimeout(() => { queued = false; paintWorkspaceDots(); }, 200);
1568
+ };
1569
+ const attach = () => {
1570
+ const container = workspaceDotContainer();
1571
+ if (container === null) return false;
1572
+ observer = new MutationObserver(() => {
1573
+ if (workspaceDotPainting) return;
1574
+ // Ignore only the settle window right after our own paint. Anything
1575
+ // later — including the shipped UI re-rendering a row and taking our
1576
+ // marker with it — must trigger a reconcile, or the dot stays gone.
1577
+ if (Date.now() - workspaceDotPaintedAt < 250) return;
1578
+ schedule();
1579
+ });
1580
+ observer.observe(container, { childList: true, subtree: true });
1581
+ schedule();
1582
+ return true;
1583
+ };
1584
+ if (!attach()) retry = window.setInterval(() => { if (attach()) window.clearInterval(retry); }, 1000);
1585
+ const offDots = subscribeSidebarMarks(schedule);
1586
+ const offConfig = subscribeUnreadConfig(schedule);
1587
+ return () => {
1588
+ if (retry !== 0) window.clearInterval(retry);
1589
+ if (workspaceDotVerify !== 0) { window.clearTimeout(workspaceDotVerify); workspaceDotVerify = 0; }
1590
+ if (observer !== null) observer.disconnect();
1591
+ offDots();
1592
+ offConfig();
1593
+ clearWorkspaceDots();
1594
+ };
1595
+ }, "dsh-icon-custom: workspace dots");
1596
+ // The platform favicon.svg repaints itself for dark mode through
1597
+ // prefers-color-scheme; a composited bitmap cannot, so rebuild it.
1598
+ ctx.on("theme/change", () => { applyBadgeToFavicon(effectiveCount(), badgeSize); });
434
1599
  }
435
1600
  exports.apply = apply;
436
1601
  exports.inject = inject;