@cowwo/dsh-icon-custom 0.10.2 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client/client.js CHANGED
@@ -69,11 +69,18 @@ window.__ModuleLoader__.load({
69
69
  ageMonths: "{n}个月",
70
70
  ageYears: "{n}年",
71
71
  workspaceDotLabel: "在工作区和会话行上显示红点(实验)",
72
- workspaceDotHint: "在侧栏工作区那一行的文件夹图标上点一个小红点。这一项是贴着页面结构做的,DSH 升级后可能失效;失效时只会变成\"不显示\",不会影响红点数字、清单、跳转这些功能,随时可以关掉它。",
72
+ workspaceDotHint: "在侧栏工作区那一行的文件夹图标上点一个小红点(收起的工作区里有多少未读,就看它),有未读的会话在其标题右边也跟一个小红点。会话行上的点只在那一行真的显示着时才画:折叠起来的会话由工作区那一行的点和「待处理」清单代表。这一项是贴着页面结构做的,DSH 升级后可能失效;失效时只会变成\"不显示\",不会影响红点数字、清单、跳转这些功能,随时可以关掉它。",
73
+ appBadgeLabel: "在系统应用图标上显示红点",
74
+ appBadgeHint: "安装为应用后,把同一个数字也放到系统图标上:Windows 是任务栏图标上的角标,macOS 是 Dock 角标,iOS 是主屏图标上的数字。样子由系统决定——Windows 上 Chrome 画深色圆+白字,Edge 走 Windows 系统徽章通道(是否显示数字由 Edge/Windows 决定);插件会周期重设数字,但只在应用窗口开着时更新。",
75
+ appBadgeFootnote: "角标只在应用窗口开着时由本插件更新;数字超过 99 时由系统显示为 99+。",
76
+ appBadgeProbeInstalled: "当前环境:已安装为应用,本机可以设置角标。是否真正显示由系统决定。",
77
+ appBadgeProbeTab: "当前环境:浏览器标签页(不是安装的应用)——安装为应用后才会显示角标。",
78
+ appBadgeProbeUnsupported: "当前环境:本机没有这个能力(当前浏览器不支持应用角标)。",
79
+ appBadgeProbeInsecure: "当前环境:不是安全上下文(请用 localhost 或 https 访问),系统角标不可用。",
73
80
  badgeTestHint: "真实未读 = 有会话发生了上面勾选的情况、而且你还没看过它(打开该会话即视为已读)。你正开着的会话也算——窗口可能被最小化、页面可能切到后台,这里无从判断你在不在看,所以不做这个区分。只有子代理不计。手动模式:自己填数字试看效果。刷新页面后回到真实未读。",
74
81
  clearDelayLabel: "进入会话后多久算已读:",
75
82
  clearDelayUnit: "秒",
76
- clearDelayHint: "填 0 = 进去就算已读(红点立即消失)。大于 0 时,你要在那个会话里连续待满这么多秒它才算看过:中途切走、或它又跑完一轮,都会清零重数;刷新页面也重新数。上限 600 秒。"
83
+ clearDelayHint: "填 0 = 进去就算已读(红点立即消失)。大于 0 时,你要在那个会话里连续待满这么多秒它才算看过:中途切走会清零重数,刷新页面也重新数。**计时只认\"你进入会话时已经存在的结束\"**——你人已经在里面的时候又跑完一轮,那一轮不算数:它会一直亮着,等你切走再进来才会清。上限 600 秒。已读记在宿主($DSH_HOME/custom-favicon/unread-seen.json):任何一台设备上看过,所有设备都不再提醒;浏览器那份只是缓存,清站点数据不会把已读丢回去。"
77
84
  };
78
85
  const en = {
79
86
  nav: "Favicon",
@@ -135,11 +142,18 @@ window.__ModuleLoader__.load({
135
142
  ageMonths: "{n}mo",
136
143
  ageYears: "{n}y",
137
144
  workspaceDotLabel: "Dots on workspace and session rows (experimental)",
138
- 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.",
145
+ 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.",
146
+ appBadgeLabel: "Badge the system app icon",
147
+ 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.",
148
+ appBadgeFootnote: "The badge is only updated by this plugin while the app window is open; above 99 the system shows 99+.",
149
+ appBadgeProbeInstalled: "This environment: installed as an app, so this device can set a badge. Whether it actually appears is up to the system.",
150
+ appBadgeProbeTab: "This environment: a browser tab (not an installed app) — install as an app to get a badge.",
151
+ appBadgeProbeUnsupported: "This environment: no app-badge capability in this browser.",
152
+ appBadgeProbeInsecure: "This environment: not a secure context (use localhost or https) — the system badge is unavailable.",
139
153
  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 counts too — the window may be minimised or the page in the background, so this half cannot tell whether you are looking, and does not pretend to. Only sub-agents never count. Manual = type a number to preview. Resets to Real on reload.",
140
154
  clearDelayLabel: "Mark read after staying:",
141
155
  clearDelayUnit: "seconds",
142
- clearDelayHint: "0 = read as soon as you enter (the dot clears at once). Above 0 you must stay in that session for this many seconds before it counts as read: leaving it, or a fresh ending in it, resets the clock, and so does a reload. Capped at 600 seconds."
156
+ clearDelayHint: "0 = read as soon as you enter (the dot clears at once). Above 0 you must stay in that session for this many seconds before it counts as read: leaving it resets the clock, and so does a reload. The clock only ever covers **the ending that was already there when you entered** — a turn that finishes while you are sitting in the session does not count: it stays red until you leave and come back. Capped at 600 seconds. Read state lives on the Host ($DSH_HOME/custom-favicon/unread-seen.json): read on any device means read on every device, and the browser's own copy is only a cache — clearing site data no longer throws it away."
143
157
  };
144
158
  //#endregion
145
159
 
@@ -255,7 +269,9 @@ window.__ModuleLoader__.load({
255
269
  const UNREAD_FALLBACK_REASONS = { completed: true, error: true, blocked: true, "max-tokens": true, interrupted: true, "aborted:user": false, "aborted:other": true };
256
270
  /** Mirror of the host's cap (see lib/unread.js) — 10 minutes. */
257
271
  const UNREAD_CLEAR_DELAY_MAX_SEC = 600;
258
- let unreadConfig = { reasons: { ...UNREAD_FALLBACK_REASONS }, pending: true, workspaceDot: true, clearDelaySec: 0 };
272
+ /** Mirror of the host's default (see lib/unread.js) — badging the app icon is on. */
273
+ const UNREAD_APP_BADGE_FALLBACK = true;
274
+ let unreadConfig = { reasons: { ...UNREAD_FALLBACK_REASONS }, pending: true, workspaceDot: true, appBadge: UNREAD_APP_BADGE_FALLBACK, clearDelaySec: 0 };
259
275
  const unreadConfigListeners = new Set();
260
276
  /** Mirror of the host's normalizer: any input becomes an integer 0..cap seconds. */
261
277
  function normalizeClearDelay(input) {
@@ -276,12 +292,16 @@ window.__ModuleLoader__.load({
276
292
  reasons,
277
293
  pending: typeof source.pending === "boolean" ? source.pending : true,
278
294
  workspaceDot: typeof source.workspaceDot === "boolean" ? source.workspaceDot : true,
295
+ appBadge: typeof source.appBadge === "boolean" ? source.appBadge : UNREAD_APP_BADGE_FALLBACK,
279
296
  clearDelaySec: normalizeClearDelay(source.clearDelaySec)
280
297
  };
281
298
  }
282
299
  function emitUnreadConfig(input) {
283
300
  unreadConfig = normalizeUnreadConfig(input);
284
301
  unreadConfigListeners.forEach((fn) => { try { fn(unreadConfig); } catch {} });
302
+ // The rule (or just the app-badge switch) moved: the OS badge follows it
303
+ // in the same breath, so toggling it applies without a reload.
304
+ reconcileAppBadge();
285
305
  }
286
306
  function subscribeUnreadConfig(fn) {
287
307
  unreadConfigListeners.add(fn);
@@ -289,7 +309,7 @@ window.__ModuleLoader__.load({
289
309
  }
290
310
 
291
311
  /**
292
- * The seen watermark lives in the HOST's clock domain.
312
+ * The seen watermark: the HOST owns it, this browser keeps the working copy.
293
313
  *
294
314
  * A turn end arrives stamped with the Host's `turn/end.time`; a mark written
295
315
  * from the browser's `Date.now()` only lines up while both clocks agree. A
@@ -298,11 +318,26 @@ window.__ModuleLoader__.load({
298
318
  * all — the one failure users report as "点进去也不消". Marks now store the
299
319
  * `endAt` they acknowledge, and `lastActiveAt` stores a host-domain time, so
300
320
  * every comparison in `collectUnread` stays inside one clock.
321
+ *
322
+ * Since 0.11.2 the authoritative table lives in the Host
323
+ * (`~/.dsh/custom-favicon/unread-seen.json`, merged max-per-session). This
324
+ * copy is what the badge reads — so the page never waits for a round trip and
325
+ * keeps working offline — and doubles as the pending queue: every local mark
326
+ * is pushed back on the next sync, and a merge can only move marks forward.
327
+ * That is what makes "looked at it" survive a cleared browser profile and mean
328
+ * the same thing on every device.
301
329
  */
302
330
  const SEEN_STORE_KEY = "dsh-icon-custom.unread-seen.v2";
303
331
  /** 0.10.1 and earlier wrote browser timestamps here; migrated once. */
304
332
  const LEGACY_SEEN_STORE_KEY = "dsh-icon-custom.unread-seen.v1";
333
+ /** Mirror of the Host's cap (see lib/unread.js). */
305
334
  const SEEN_MAX = 400;
335
+ /** Coalescing window for watermark pushes: one small RPC per burst, not per session. */
336
+ const HOST_SYNC_DEBOUNCE_MS = 2000;
337
+ /** Retry gap after a failed push (offline, or a Host without the endpoint). */
338
+ const HOST_SYNC_RETRY_MS = 30000;
339
+ /** Ceiling for that retry gap: an old Host must not be probed forever every 20 s. */
340
+ const HOST_SYNC_MAX_RETRY_MS = 300000;
306
341
  /** Host clock minus browser clock, from the last Host sample; null until known. */
307
342
  let hostClockOffset = null;
308
343
  /** This browser's clock expressed in the Host's domain (best effort). */
@@ -327,10 +362,140 @@ window.__ModuleLoader__.load({
327
362
  const seenState = loadSeenState();
328
363
  /** When this browser last had the app open before this page load (host domain). */
329
364
  const seenBootAt = seenState.lastActiveAt > 0 ? seenState.lastActiveAt : 0;
365
+ /** The connection RPC, once `apply` has one; null keeps every push a no-op. */
366
+ let seenRpc = null;
367
+ /** Whether this page load has already converged with the Host once. */
368
+ let hostSyncedOnce = false;
369
+ /** Whether local marks changed since the last successful push. */
370
+ let hostDirty = false;
371
+ let hostSyncTimer = 0;
372
+ let hostRetryTimer = 0;
373
+ let hostSyncInFlight = false;
374
+ /** Escalating gap between failed pushes; any success resets it. */
375
+ let hostRetryDelay = HOST_SYNC_RETRY_MS;
376
+ /** No push is attempted before this moment (the backoff window). */
377
+ let hostBackoffUntil = 0;
378
+ /** Whether the current failure streak has been reported (warn once per streak). */
379
+ let hostSyncWarned = false;
380
+ /**
381
+ * Push the local table soon, unless there is nothing to say.
382
+ *
383
+ * Called from `saveSeenState`, which is also the 20-second "this browser is
384
+ * still here" touch: that touch must NOT count as a change, or the table would
385
+ * travel every 20 seconds. `hostSyncedOnce` covers the first push of a page
386
+ * load, which is also the migration (local-only marks going up) and the
387
+ * self-heal (a lost Host file coming back from this browser).
388
+ * @param delayMs - override the coalescing window; non-positive uses the default.
389
+ */
390
+ function scheduleHostSync(delayMs) {
391
+ if (seenRpc === null || hostSyncInFlight) return;
392
+ if (!hostDirty && hostSyncedOnce) return;
393
+ if (hostSyncTimer !== 0) return;
394
+ // A failed push escalates its own retry; the 20-second touch must not keep
395
+ // poking a Host that has no such endpoint (an older DSH, or offline).
396
+ if (Date.now() < hostBackoffUntil) return;
397
+ const wait = typeof delayMs === "number" && delayMs > 0 ? delayMs : HOST_SYNC_DEBOUNCE_MS;
398
+ hostSyncTimer = window.setTimeout(() => { hostSyncTimer = 0; flushHostSeen(); }, wait);
399
+ }
400
+ /**
401
+ * Adopt the Host's table: per session the NEWER of the two wins.
402
+ *
403
+ * Max-merge (never overwrite) is what makes both directions safe: the Host file
404
+ * lost or truncated → this browser's copy wins; this browser's localStorage
405
+ * cleared → the Host's copy wins. It is also the whole migration path.
406
+ * @param seen - the Host's table (`id → endAt`), or anything.
407
+ * @returns whether the local table gained anything.
408
+ */
409
+ function adoptHostSeen(seen) {
410
+ if (seen === null || typeof seen !== "object") return false;
411
+ let gained = false;
412
+ for (const id of Object.keys(seen)) {
413
+ if (typeof id !== "string" || id === "") continue;
414
+ const at = seen[id];
415
+ if (typeof at !== "number" || !Number.isFinite(at) || at <= 0) continue;
416
+ const previous = seenState.seen[id];
417
+ if (typeof previous === "number" && previous >= at) continue;
418
+ seenState.seen[id] = at;
419
+ gained = true;
420
+ }
421
+ return gained;
422
+ }
423
+ /**
424
+ * Send the whole local table and adopt the merged answer.
425
+ *
426
+ * Whole-table, not a delta: the payload is a few KB, the Host merges by max (so
427
+ * a duplicate or partial round trip is harmless), and any lost message heals on
428
+ * the next one. A failure keeps the marks here — they are already in
429
+ * localStorage and the badge reads them — and retries later.
430
+ */
431
+ function flushHostSeen() {
432
+ if (seenRpc === null || hostSyncInFlight) return;
433
+ if (!hostDirty && hostSyncedOnce) return;
434
+ hostSyncInFlight = true;
435
+ const marks = { ...seenState.seen };
436
+ let pending;
437
+ try {
438
+ // Args are nested under the descriptor's parameter name ("request"), which
439
+ // is what the gateway checks: a flat `{ marks }` is rejected with
440
+ // `gateway/arguments-invalid` before the method ever runs.
441
+ pending = seenRpc.call("/api", "iconCustom/setUnreadSeen", { args: { request: { marks } } });
442
+ } catch (error) {
443
+ // A carrier that throws synchronously must not wedge the in-flight guard:
444
+ // that would stop every later push for the life of the page, silently.
445
+ hostSyncInFlight = false;
446
+ noteHostSyncFailure(error);
447
+ return;
448
+ }
449
+ Promise.resolve(pending).then(
450
+ (resp) => {
451
+ hostSyncInFlight = false;
452
+ if (resp === null || typeof resp !== "object" || resp.ok !== true) {
453
+ const reason = resp !== null && typeof resp === "object" && resp.error !== undefined ? JSON.stringify(resp.error) : "resp.ok !== true";
454
+ noteHostSyncFailure(new Error("setUnreadSeen 被拒绝: " + reason));
455
+ return;
456
+ }
457
+ const value = resp.value !== null && typeof resp.value === "object" ? resp.value : {};
458
+ const gained = adoptHostSeen(value.seen);
459
+ hostSyncedOnce = true;
460
+ hostRetryDelay = HOST_SYNC_RETRY_MS;
461
+ hostBackoffUntil = 0;
462
+ hostSyncWarned = false;
463
+ // The Host knew marks this browser did not: keep them and push the union
464
+ // once more. The next answer cannot gain anything, so this terminates.
465
+ // Repaint too: the badge, the row dots and the panel all read this
466
+ // browser's copy, and nothing else would re-render for a mark that
467
+ // arrived over the wire.
468
+ hostDirty = gained;
469
+ if (gained) { saveSeenState(); emitUnreadPoke(); scheduleHostSync(); }
470
+ },
471
+ (error) => { hostSyncInFlight = false; noteHostSyncFailure(error); }
472
+ );
473
+ }
474
+ /**
475
+ * One failed push: report it once per streak, then back off and try again later.
476
+ *
477
+ * Reported (not swallowed) because a watermark that cannot reach the Host is a
478
+ * silent, confusing failure — "为什么另一台设备还亮着" — and there is nothing on
479
+ * screen to explain it. The badge itself keeps working from the local copy, so
480
+ * this must never look fatal, and it warns once rather than on every retry.
481
+ * @param error - whatever the carrier rejected with.
482
+ */
483
+ function noteHostSyncFailure(error) {
484
+ if (!hostSyncWarned) {
485
+ hostSyncWarned = true;
486
+ try { console.warn("dsh-icon-custom: 已读水位线同步到宿主失败(红点仍照常工作,会自动重试):", (error && error.message) || error); } catch {}
487
+ }
488
+ const wait = hostRetryDelay;
489
+ hostRetryDelay = Math.min(hostRetryDelay * 2, HOST_SYNC_MAX_RETRY_MS);
490
+ hostBackoffUntil = Date.now() + wait;
491
+ if (hostRetryTimer !== 0) window.clearTimeout(hostRetryTimer);
492
+ hostRetryTimer = window.setTimeout(() => { hostRetryTimer = 0; flushHostSeen(); }, wait);
493
+ }
330
494
  function saveSeenState() {
331
495
  try {
332
496
  window.localStorage.setItem(SEEN_STORE_KEY, JSON.stringify({ domain: "host", lastActiveAt: seenState.lastActiveAt, seen: seenState.seen }));
333
497
  } catch {}
498
+ scheduleHostSync();
334
499
  }
335
500
  /**
336
501
  * Learn the Host↔browser clock offset from a Host timestamp, and fold a
@@ -370,12 +535,20 @@ window.__ModuleLoader__.load({
370
535
  const previous = seenState.seen[sessionId];
371
536
  if (typeof previous === "number" && previous >= mark) return false;
372
537
  seenState.seen[sessionId] = mark;
538
+ // Only a REAL mark counts as a change: the 20-second "still here" touch also
539
+ // lands in `saveSeenState`, and it must not drag the whole table to the Host.
540
+ hostDirty = true;
373
541
  return true;
374
542
  }
375
543
  /**
376
544
  * First sight of a session is watermarked at the last time this browser was
377
545
  * here — so an ending that happened while the app was closed still counts as
378
546
  * unread, while endings from before the plugin was installed do not.
547
+ *
548
+ * Unchanged by the move to a Host-owned table: a session nobody has a mark for
549
+ * still needs a starting value, and "the last time this browser was here" is
550
+ * the honest one. A mark dropped by the Host's pruning simply re-baselines here
551
+ * on the next load, which is why pruning cannot resurrect old dots.
379
552
  * @returns whether anything was learned (callers persist only then).
380
553
  */
381
554
  function ensureSeenBaseline(ids) {
@@ -431,25 +604,6 @@ window.__ModuleLoader__.load({
431
604
  const value = values !== null && typeof values === "object" ? values[LAST_TURN_END_KEY] : undefined;
432
605
  return value !== null && typeof value === "object" && typeof value.endAt === "number" ? value.endAt : 0;
433
606
  }
434
- /**
435
- * What the "stay here long enough" timer is armed for, or "" when nothing
436
- * should be armed.
437
- *
438
- * Keyed on the session's own `lastTurnEnd.endAt`, deliberately NOT on the
439
- * collected item's `at`: a "waiting for you" item carries `at: Date.now()`,
440
- * which changes on every evaluation — keying on that would tear the timer
441
- * down and re-arm it forever, and a waiting session could never be marked
442
- * read. Including `endAt` is also what makes a NEW ending restart the clock
443
- * instead of inheriting the previous one's progress.
444
- * @param sessionId - the session being viewed.
445
- * @param list - the session list snapshot.
446
- * @returns the key, or "" when there is no session to arm for.
447
- */
448
- function stayKeyFor(sessionId, list) {
449
- if (typeof sessionId !== "string" || sessionId === "") return "";
450
- const byId = list !== null && typeof list === "object" && list.byId !== null && typeof list.byId === "object" ? list.byId : {};
451
- return `${sessionId}\u0000${lastTurnEndAt(byId[sessionId])}`;
452
- }
453
607
  /**
454
608
  * How many sessions deserve the badge right now: endings whose reason is
455
609
  * enabled and that this browser has not seen since, plus sessions waiting
@@ -557,6 +711,8 @@ window.__ModuleLoader__.load({
557
711
  // The header badge is a DOM node, not a listener, so it is reconciled
558
712
  // here as well as from the mutation watchdog. Idempotent either way.
559
713
  paintHeaderBadge();
714
+ // Same number, second consumer: the OS badge behind the app icon.
715
+ reconcileAppBadge();
560
716
  }
561
717
  /** Manual test input. */
562
718
  function emitBadge(value) {
@@ -588,6 +744,180 @@ window.__ModuleLoader__.load({
588
744
  return count > 99 ? "99+" : String(count);
589
745
  }
590
746
 
747
+ //#endregion
748
+ //#region app badge (OS app icon)
749
+ // The badge the OPERATING SYSTEM draws on the icon of the installed app: the
750
+ // taskbar overlay on Windows, the Dock badge on macOS, the number on an iOS
751
+ // home-screen icon. It is the same number the page badge shows, projected
752
+ // onto the system icon — one number, two consumers.
753
+ //
754
+ // Three facts shape this region (all checked against primary sources; see
755
+ // docs/adr/0005-app-icon-badge.md):
756
+ // * detecting the API is NOT delivery. Chromium on Android exposes
757
+ // `setAppBadge` and silently ignores it, and iOS only draws the badge
758
+ // once notifications have been granted. So the probe reports LOCAL facts
759
+ // and never promises that a badge will appear;
760
+ // * the OS owns the rendering. On Windows the badge is a browser-drawn
761
+ // overlay on the taskbar button of an OPEN window: closing the window
762
+ // drops it, and nothing can refresh it afterwards (no push server);
763
+ // * the API is write-only, so this half keeps its own count — the very
764
+ // `effectiveCount()` the favicon and the header badge already use.
765
+ //
766
+ // Everything that DECIDES is a pure function, so a test drives it with no
767
+ // browser at all; exactly one function touches the API.
768
+ /** Display modes that mean "this window IS the installed app", not a tab. */
769
+ const APP_DISPLAY_MODES = ["standalone", "fullscreen", "minimal-ui", "window-controls-overlay"];
770
+
771
+ /**
772
+ * Normalize what this device is, as three independent local facts.
773
+ *
774
+ * Deliberately NOT "does this platform support it": the method that works
775
+ * on Windows resolves and does nothing on Android, and Apple draws the
776
+ * badge only after notifications are granted. Neither is answerable from
777
+ * inside the page, so the probe stays silent about them and the copy says
778
+ * "whether it actually appears is up to the system" instead.
779
+ * @param env - `{ secure, hasApi, installed }`; anything non-boolean reads false.
780
+ * @returns the same three flags, normalized.
781
+ */
782
+ function badgeCapabilityProbe(env) {
783
+ const source = env !== null && typeof env === "object" ? env : {};
784
+ return {
785
+ secure: source.secure === true,
786
+ hasApi: source.hasApi === true,
787
+ installed: source.installed === true
788
+ };
789
+ }
790
+
791
+ /**
792
+ * Read those facts off the window this code runs in.
793
+ *
794
+ * "Installed" is a display-mode match rather than `standalone` alone: the
795
+ * platform manifest asks for `fullscreen`, so an installed window matches
796
+ * THAT mode, and a standalone-only test would report "browser tab" forever.
797
+ * @returns the probe for this device.
798
+ */
799
+ function appBadgeEnv() {
800
+ const nav = typeof navigator === "object" && navigator !== null ? navigator : {};
801
+ let installed = nav.standalone === true;
802
+ if (!installed && typeof window !== "undefined" && typeof window.matchMedia === "function") {
803
+ for (const mode of APP_DISPLAY_MODES) {
804
+ try {
805
+ if (window.matchMedia(`(display-mode: ${mode})`).matches === true) { installed = true; break; }
806
+ } catch {}
807
+ }
808
+ }
809
+ return badgeCapabilityProbe({
810
+ secure: typeof window !== "undefined" && window.isSecureContext === true,
811
+ hasApi: typeof nav.setAppBadge === "function" && typeof nav.clearAppBadge === "function",
812
+ installed
813
+ });
814
+ }
815
+
816
+ /**
817
+ * What the OS badge should say for a given count.
818
+ *
819
+ * The number is handed over AS IS (no `99+` clamping here): the system
820
+ * saturates it in its own way, and duplicating that rule is how the two
821
+ * projections start disagreeing. Switching the feature off clears the
822
+ * badge rather than abandoning it — a number left behind after "off"
823
+ * reads as a bug.
824
+ * @param count - the unread count, i.e. the number the page badge shows.
825
+ * @param enabled - the `appBadge` setting; only `true` enables it.
826
+ * @returns `{ kind: "set", value }` or `{ kind: "clear" }`.
827
+ */
828
+ function appBadgeEffect(count, enabled) {
829
+ const value = enabled === true ? normalizeCount(count) : 0;
830
+ return value > 0 ? { kind: "set", value } : { kind: "clear" };
831
+ }
832
+
833
+ /**
834
+ * Which self-check line the settings page shows for this device.
835
+ *
836
+ * A missing probe is NOT reported as "insecure": that message tells the
837
+ * user to change how they reach the page, which would be chasing the
838
+ * wrong thing. Only a real reading may claim that.
839
+ * @param probe - a `badgeCapabilityProbe` result.
840
+ * @returns a locale key.
841
+ */
842
+ function appBadgeCapabilityKey(probe) {
843
+ if (probe === null || typeof probe !== "object") return "appBadgeProbeUnsupported";
844
+ const facts = badgeCapabilityProbe(probe);
845
+ if (facts.secure !== true) return "appBadgeProbeInsecure";
846
+ if (facts.hasApi !== true) return "appBadgeProbeUnsupported";
847
+ return facts.installed === true ? "appBadgeProbeInstalled" : "appBadgeProbeTab";
848
+ }
849
+
850
+ /** The last effect actually handed to the API, so a repeat is a no-op. */
851
+ let lastAppBadgeId = null;
852
+
853
+ /**
854
+ * The ONE place that talks to the Badging API.
855
+ *
856
+ * Idempotence is required here, not polish: `notifyBadge` fires on every
857
+ * badge change AND again from the DOM watchdog that re-asserts the tab
858
+ * badge, and each call costs an IPC to the browser process plus a taskbar
859
+ * repaint. Only a real change in the projected effect reaches the API.
860
+ * @param effect - an `appBadgeEffect` result.
861
+ * @param force - re-send even when the effect is unchanged. Used only by
862
+ * the low-frequency OS-badge watchdog, because Windows/Edge can replace a
863
+ * numeric badge with a notification dot after our call succeeded.
864
+ */
865
+ function applyAppBadge(effect, force) {
866
+ const intent = effect !== null && typeof effect === "object" ? effect : {};
867
+ // Anything that is not a positive number is a clear, so a bogus effect
868
+ // can never hand the system a `setAppBadge(0)`.
869
+ const id = intent.kind === "set" && typeof intent.value === "number" && intent.value > 0 ? `set:${intent.value}` : "clear";
870
+ if (id === lastAppBadgeId && force !== true) return;
871
+ const nav = typeof navigator === "object" && navigator !== null ? navigator : null;
872
+ if (nav === null || typeof nav.setAppBadge !== "function" || typeof nav.clearAppBadge !== "function") return;
873
+ lastAppBadgeId = id;
874
+ try {
875
+ // Always a number, never the argument-less "flag" form: on WebKit a
876
+ // no-argument call can REMOVE a badge that is already showing.
877
+ const settled = id === "clear" ? nav.clearAppBadge() : nav.setAppBadge(intent.value);
878
+ if (settled !== null && typeof settled === "object" && typeof settled.catch === "function") {
879
+ settled.catch(() => {
880
+ // The API rejected: the browser did NOT apply this effect, so
881
+ // forget it and let the next reconcile/retry try again. Guard
882
+ // against a newer call having replaced the remembered effect.
883
+ if (lastAppBadgeId === id) lastAppBadgeId = null;
884
+ });
885
+ }
886
+ } catch {
887
+ // A synchronous throw means nothing was applied, so the next
888
+ // reconcile retries instead of remembering a phantom success.
889
+ lastAppBadgeId = null;
890
+ }
891
+ }
892
+
893
+ /**
894
+ * Reconcile the OS badge with the page badge.
895
+ *
896
+ * Both inputs are the page's own: the number the favicon and the header
897
+ * badge already show, and the rule the host persists. Called from
898
+ * `emitUnreadConfig` (rule arrived / switch moved) and `notifyBadge`
899
+ * (number moved) — together those cover every way the badge can change.
900
+ * @param force - re-send even if the projected effect is unchanged.
901
+ */
902
+ function reconcileAppBadge(force) {
903
+ applyAppBadge(appBadgeEffect(effectiveCount(), unreadConfig.appBadge === true), force === true);
904
+ }
905
+
906
+ /**
907
+ * Keep a positive numeric badge asserted while the app window is alive.
908
+ *
909
+ * Windows/Edge can render a notification glyph (a dot) over the numeric
910
+ * badge after we set it; unlike the favicon, the OS badge then has no
911
+ * watcher to put the number back. Re-send only when the plugin's own
912
+ * projection says there SHOULD be a number, and never when it says clear —
913
+ * so a count of 0 is not repeatedly cleared and no phantom badge appears.
914
+ */
915
+ function reassertAppBadge() {
916
+ if (unreadConfig.appBadge !== true) return;
917
+ if (normalizeCount(effectiveCount()) <= 0) return;
918
+ reconcileAppBadge(true);
919
+ }
920
+
591
921
  //#endregion
592
922
  //#region favicon compositing
593
923
  // The browser accepts exactly ONE image per <link rel="icon">, so a tab
@@ -827,22 +1157,32 @@ window.__ModuleLoader__.load({
827
1157
  /**
828
1158
  * Roll the pending sessions up to one dot per workspace.
829
1159
  *
830
- * Pure: `titles` maps sessionId → the workspace title the sidebar shows
831
- * (assembled from the official workspace snapshot); a session missing from it
832
- * falls back to the cwd basename `collectUnread` already carries.
833
- * @returns `[{ name, count }]`, busiest workspace first.
1160
+ * Pure: `owners` maps sessionId → `{ id, title }` from the official workspace
1161
+ * snapshot. Both halves matter downstream: the ID anchors the marker to the
1162
+ * row the browser itself keys (`data-row-key="workspace:<id>"`), which is what
1163
+ * makes the folder dot survive a Workspace whose displayed name differs from
1164
+ * the stored title, two Workspaces sharing one title, and a title that is
1165
+ * simply empty. The title stays for the tooltip; a session with neither falls
1166
+ * back to the cwd basename `collectUnread` already carries.
1167
+ * @returns `[{ id, name, count }]`, busiest workspace first.
834
1168
  */
835
- function dotsFromItems(items, titles) {
1169
+ function dotsFromItems(items, owners) {
836
1170
  const counts = new Map();
837
1171
  for (const item of Array.isArray(items) ? items : []) {
838
1172
  if (item === null || typeof item !== "object") continue;
839
- const mapped = titles !== null && typeof titles === "object" ? titles[item.id] : undefined;
840
- const name = typeof mapped === "string" && mapped !== "" ? mapped : (typeof item.where === "string" ? item.where : "");
1173
+ const owner = owners !== null && typeof owners === "object" ? owners[item.id] : undefined;
1174
+ const title = owner !== null && typeof owner === "object" && typeof owner.title === "string" ? owner.title : "";
1175
+ const name = title !== "" ? title : (typeof item.where === "string" ? item.where : "");
841
1176
  if (name === "") continue;
842
- counts.set(name, (counts.get(name) || 0) + 1);
1177
+ const id = owner !== null && typeof owner === "object" && typeof owner.id === "string" ? owner.id : "";
1178
+ // One dot per Workspace: two Sessions of the same Workspace must not
1179
+ // produce two entries that later paint on top of each other.
1180
+ const key = id !== "" ? id : name;
1181
+ const existing = counts.get(key);
1182
+ if (existing === undefined) counts.set(key, { id, name, count: 1 });
1183
+ else existing.count++;
843
1184
  }
844
- return [...counts.entries()]
845
- .map(([name, count]) => ({ name, count }))
1185
+ return [...counts.values()]
846
1186
  .sort((a, b) => b.count - a.count || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
847
1187
  }
848
1188
 
@@ -1112,17 +1452,101 @@ window.__ModuleLoader__.load({
1112
1452
  * Where one session's marker goes: the tight title holder INSIDE that
1113
1453
  * session's own row. The row is found by `data-row-key`; the title inside it
1114
1454
  * is still matched by text, because that is the element the marker hangs off.
1115
- * Falls back to the old whole-region title match on a build without row keys.
1455
+ *
1456
+ * A Session whose row is NOT in the DOM — folded behind "Show more", inside a
1457
+ * collapsed Workspace, or filtered out — gets NO marker. It used to fall back
1458
+ * to a whole-region title match, and that guess is exactly what puts a dot on
1459
+ * the wrong row: a DIFFERENT Session with the same title owns the only
1460
+ * rendered text node of that name, so the folded one's marker landed on the
1461
+ * visible one's row (two markers on one row, none on the real one). The
1462
+ * Workspace folder dot and the pending panel already speak for Sessions whose
1463
+ * rows are not rendered, so skipping loses no information.
1464
+ *
1465
+ * `legacyTitleMatch` is reserved for a build that stamps no row keys at all,
1466
+ * where the title is the only handle that exists.
1467
+ * @param legacyTitleMatch - whether this build predates `data-row-key`.
1116
1468
  * @returns `{ title, row }`, or null when this row cannot be trusted.
1117
1469
  */
1118
- function sessionRowTarget(container, id, title) {
1470
+ function sessionRowTarget(container, id, title, legacyTitleMatch) {
1119
1471
  const row = sessionRowElement(container, id);
1120
1472
  if (row !== null) {
1121
1473
  const inside = labelElementFor(row, title);
1122
1474
  return inside === null ? null : titleTargetFor(inside, title);
1123
1475
  }
1476
+ if (legacyTitleMatch !== true) return null;
1124
1477
  return titleTargetFor(labelElementFor(container, title), title);
1125
1478
  }
1479
+ /**
1480
+ * Whether the shipped browser stamps Session rows with `data-row-key`.
1481
+ *
1482
+ * Asked once per paint, not once per row: the strict path is only relaxed for
1483
+ * a build that has no row keys anywhere, never for one unlucky lookup.
1484
+ * @param container - the browsing region root (non-null).
1485
+ * @returns true when row keys are in use (and on any probe failure — the
1486
+ * strict path is the safe default).
1487
+ */
1488
+ function sessionRowKeysInUse(container) {
1489
+ try { return container.querySelector('[data-row-key^="session:"]') !== null; } catch { return true; }
1490
+ }
1491
+ /**
1492
+ * The DOM row of one Workspace, by the official row key, or null.
1493
+ *
1494
+ * Same handle as `sessionRowElement`, and the reason the folder dot no longer
1495
+ * depends on the displayed name: DSH renders a Workspace still carrying its
1496
+ * automatic title under a localized default name, and two Workspaces can share
1497
+ * one title, so a text match can miss or collide.
1498
+ * @param container - the browsing region root.
1499
+ * @param id - the Workspace id.
1500
+ * @returns the row element, or null.
1501
+ */
1502
+ function workspaceRowElement(container, id) {
1503
+ if (typeof id !== "string" || id === "") return null;
1504
+ try { return container.querySelector(`[data-row-key="workspace:${id}"]`); } catch { return null; }
1505
+ }
1506
+ /**
1507
+ * The row box + folder icon a Workspace marker hangs off, from the keyed row.
1508
+ *
1509
+ * The key normally sits on the row itself; a build that stamps it on a group
1510
+ * wrapper fails the row-size check, so a bounded set of that wrapper's own rows
1511
+ * gets the same test before giving up. Either way the marker must land on a
1512
+ * row-sized box whose LEADING glyph is the folder — requiring a left-hand icon
1513
+ * is what keeps the dot off the end of the text.
1514
+ * @returns `{ row, icon }`, or null when this row cannot be trusted.
1515
+ */
1516
+ function folderTargetFor(row) {
1517
+ if (row === null || row === undefined) return null;
1518
+ const candidates = [row];
1519
+ try {
1520
+ const inner = row.querySelectorAll("div");
1521
+ for (let i = 0; i < inner.length && candidates.length < 12; i++) candidates.push(inner[i]);
1522
+ } catch { /* the keyed element alone is still worth testing */ }
1523
+ for (const candidate of candidates) {
1524
+ const rect = candidate.getBoundingClientRect();
1525
+ if (rect.height < WS_ROW_MIN_H || rect.height > WS_ROW_MAX_H || rect.width < WS_ROW_MIN_W) continue;
1526
+ const icons = candidate.querySelectorAll("svg");
1527
+ for (const icon of icons) {
1528
+ const iconRect = icon.getBoundingClientRect();
1529
+ if (iconRect.width <= 0 || iconRect.height <= 0) continue;
1530
+ if (iconRect.left - rect.left > rect.width / 3) continue;
1531
+ return { row: candidate, icon };
1532
+ }
1533
+ }
1534
+ return null;
1535
+ }
1536
+ /**
1537
+ * The stable key a Workspace marker is stored under: its ID when known.
1538
+ *
1539
+ * The attribute value used to be the displayed title, which two Workspaces can
1540
+ * share — the reconcile pass would then treat one of them as a stranger and
1541
+ * remove a marker that is still wanted.
1542
+ * @param entry - one `{ id, name }` entry from `dotsFromItems`.
1543
+ * @returns the key, or undefined when the entry carries neither.
1544
+ */
1545
+ function workspaceDotKey(entry) {
1546
+ if (entry === null || typeof entry !== "object") return undefined;
1547
+ if (typeof entry.id === "string" && entry.id !== "") return entry.id;
1548
+ return entry.name;
1549
+ }
1126
1550
  function paintWorkspaceDots() {
1127
1551
  if (workspaceDotGivenUp) return;
1128
1552
  const enabled = unreadConfig.workspaceDot !== false;
@@ -1138,7 +1562,7 @@ window.__ModuleLoader__.load({
1138
1562
  // Reconcile, never wipe-and-redraw: a marker that is already correct is
1139
1563
  // left untouched, so one unlucky lookup (a re-render in flight, a name
1140
1564
  // matched twice for a moment) can no longer blink an existing dot away.
1141
- const wantedNames = new Set(wanted.map((entry) => entry.name));
1565
+ const wantedNames = new Set(wanted.map((entry) => workspaceDotKey(entry)));
1142
1566
  const wantedIds = new Set(wantedRows.map((entry) => entry.id));
1143
1567
  document.querySelectorAll("[" + WS_DOT_ATTR + "]").forEach((node) => {
1144
1568
  if (!node.isConnected || !wantedNames.has(node.getAttribute(WS_DOT_ATTR))) node.remove();
@@ -1147,12 +1571,21 @@ window.__ModuleLoader__.load({
1147
1571
  if (!node.isConnected || !wantedIds.has(node.getAttribute(ROW_DOT_ATTR))) node.remove();
1148
1572
  });
1149
1573
  if (container === null) { workspaceDotPainting = false; return; } // start-up race
1574
+ const legacyTitleMatch = !sessionRowKeysInUse(container);
1150
1575
  for (const entry of wanted) {
1151
- const label = labelElementFor(container, entry.name);
1152
- const target = label === null ? null : markerTargetFor(label);
1576
+ const key = workspaceDotKey(entry);
1577
+ if (key === undefined) { missed++; continue; }
1578
+ // The browser's own row key first; the title walk-up is only for a build
1579
+ // that stamps no Workspace keys (or an entry the snapshot could not name).
1580
+ const keyedRow = workspaceRowElement(container, entry.id);
1581
+ let target = keyedRow === null ? null : folderTargetFor(keyedRow);
1582
+ if (target === null && keyedRow === null) {
1583
+ const label = labelElementFor(container, entry.name);
1584
+ target = label === null ? null : markerTargetFor(label);
1585
+ }
1153
1586
  if (target === null) { missed++; continue; }
1154
1587
  const row = target.row;
1155
- if (existingMarker(row, WS_DOT_ATTR, entry.name) !== null) { placed++; continue; }
1588
+ if (existingMarker(row, WS_DOT_ATTR, key) !== null) { placed++; continue; }
1156
1589
  const icon = target.icon;
1157
1590
  const rowRect = row.getBoundingClientRect();
1158
1591
  const anchorRect = icon.getBoundingClientRect();
@@ -1161,9 +1594,9 @@ window.__ModuleLoader__.load({
1161
1594
  row.style.position = "relative";
1162
1595
  }
1163
1596
  const dot = document.createElement("span");
1164
- dot.setAttribute(WS_DOT_ATTR, entry.name);
1597
+ dot.setAttribute(WS_DOT_ATTR, key);
1165
1598
  dot.setAttribute("aria-hidden", "true");
1166
- dot.title = entry.name + " · " + entry.count;
1599
+ dot.title = (typeof entry.name === "string" && entry.name !== "" ? entry.name : key) + " · " + entry.count;
1167
1600
  dot.style.cssText = "position:absolute;pointer-events:none;width:8px;height:8px;border-radius:50%;"
1168
1601
  + "background:#e5484d;box-shadow:0 0 0 1.5px var(--dsw-alias-bg-layer-1,#fff);"
1169
1602
  + "left:" + Math.round(anchorRect.right - rowRect.left - 4) + "px;"
@@ -1175,7 +1608,7 @@ window.__ModuleLoader__.load({
1175
1608
  // Session rows: the marker rides inline right after the title, so it stays
1176
1609
  // beside the name whatever the timestamp happens to say.
1177
1610
  for (const entry of wantedRows) {
1178
- const target = sessionRowTarget(container, entry.id, entry.title);
1611
+ const target = sessionRowTarget(container, entry.id, entry.title, legacyTitleMatch);
1179
1612
  if (target === null) { missed++; continue; }
1180
1613
  if (existingMarker(target.row, ROW_DOT_ATTR, entry.id) !== null) { placed++; continue; }
1181
1614
  const dot = document.createElement("span");
@@ -1258,20 +1691,26 @@ window.__ModuleLoader__.load({
1258
1691
  const currentIds = currentSessionIds(list);
1259
1692
  const items = collectUnread(list, pending, config, seenState.seen);
1260
1693
  const count = items.length;
1261
- // Being in a session is what marks it read — but only once you have STAYED
1262
- // `clearDelaySec` seconds (0 = the moment you enter, the original
1263
- // behaviour). A clock is torn down whenever you leave, or whenever that
1264
- // session becomes unread again from a fresh ending, so the clock restarts;
1265
- // that teardown IS the "continuous stay, leaving resets it" rule. A reload
1266
- // restarts it too, since the timers only live in this page.
1694
+ // Being in a session is what marks it read — and the clock that does it is
1695
+ // armed by ENTERING the session, never by a new ending.
1696
+ //
1697
+ // `clearDelaySec` still means "stay this long before the ending counts as
1698
+ // seen" (0 = the moment you enter). What is new in 0.11.1 is WHEN the clock
1699
+ // takes its snapshot: the moment the session joins the view. An ending that
1700
+ // lands while you are already sitting there is therefore none of this
1701
+ // clock's business — it stays unread until you leave and come back. The
1702
+ // previous version re-armed on every new ending and chased the newest one,
1703
+ // so a turn that finished while you watched was auto-read `clearDelaySec`
1704
+ // seconds later: the dot for the session in front of you was a flash
1705
+ // nobody could catch. See docs/adr/0006-entry-bounded-stay-clock.md.
1706
+ //
1707
+ // Leaving tears the clock down, which is what makes the stay "continuous".
1708
+ // A reload restarts it too, since the timers only live in this page.
1267
1709
  //
1268
1710
  // One clock per session the main view shows: with two retained sessions,
1269
1711
  // marking only the first left the session you were reading unread forever.
1270
- const unreadIds = new Set(items.map((item) => item.id));
1271
- const staySignature = currentIds
1272
- .filter((id) => unreadIds.has(id))
1273
- .map((id) => stayKeyFor(id, list))
1274
- .join("|");
1712
+ const stayIds = currentIds.slice().sort();
1713
+ const staySignature = stayIds.join("|");
1275
1714
  const delayMs = config.clearDelaySec * 1000;
1276
1715
  const [, bumpStay] = React.useState(0);
1277
1716
  // The panel can move the marks without going through this component
@@ -1279,35 +1718,40 @@ window.__ModuleLoader__.load({
1279
1718
  // badge all follow immediately.
1280
1719
  const [, bumpPoke] = React.useState(0);
1281
1720
  React.useEffect(() => subscribeUnreadPoke(() => bumpPoke((n) => n + 1)), []);
1282
- // The timer chain below is keyed on the signature, but every attempt reads
1283
- // the CURRENT list and marks; a ref is how the chain sees them.
1721
+ // The effect below is keyed on the signature, but the snapshot it marks is
1722
+ // taken from the CURRENT list; a ref is how it sees that list.
1284
1723
  const stayLive = React.useRef({ list });
1285
1724
  stayLive.current = { list };
1725
+ /**
1726
+ * Entered-at snapshot: `id → endAt` as it stood when that session joined
1727
+ * the view. Deliberately NOT refreshed while the session stays — that is
1728
+ * the rule ("an ending during your stay waits for the next entry"). A
1729
+ * session that leaves is forgotten, so coming back takes a fresh snapshot.
1730
+ */
1731
+ const stayArmed = React.useRef(new Map());
1286
1732
  React.useEffect(() => {
1733
+ 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 : {};
1734
+ const live = new Set(stayIds);
1735
+ for (const id of Array.from(stayArmed.current.keys())) {
1736
+ if (!live.has(id)) stayArmed.current.delete(id);
1737
+ }
1738
+ for (const id of stayIds) {
1739
+ if (!stayArmed.current.has(id)) stayArmed.current.set(id, lastTurnEndAt(snapshot[id]));
1740
+ }
1287
1741
  if (staySignature === "") return undefined;
1288
- const ids = staySignature.split("|").map((key) => key.slice(0, key.indexOf("\u0000")));
1742
+ const armed = stayIds.map((id) => ({ id, endAt: stayArmed.current.get(id) }));
1289
1743
  let cancelled = false;
1290
1744
  let timer = 0;
1745
+ // Single-shot on purpose: the snapshot is fixed, so there is nothing to
1746
+ // chase. A newer ending found here belongs to the NEXT entry.
1291
1747
  const attempt = () => {
1292
1748
  if (cancelled) return;
1293
- const byId = stayLive.current.list !== null && typeof stayLive.current.list === "object" && stayLive.current.list.byId !== null && typeof stayLive.current.list.byId === "object" ? stayLive.current.list.byId : {};
1294
1749
  let changed = false;
1295
- let stillUnread = false;
1296
- for (const id of ids) {
1297
- const endAt = lastTurnEndAt(byId[id]);
1298
- if (endAt <= 0) continue;
1299
- const seenAt = seenState.seen[id];
1300
- if (typeof seenAt === "number" && endAt <= seenAt) continue;
1301
- stillUnread = true;
1302
- if (noteSeen(id, endAt)) changed = true;
1750
+ for (const target of armed) {
1751
+ if (typeof target.endAt !== "number" || target.endAt <= 0) continue;
1752
+ if (noteSeen(target.id, target.endAt)) changed = true;
1303
1753
  }
1304
1754
  if (changed) { saveSeenState(); bumpStay((n) => n + 1); }
1305
- // Watchdog. With the mark in the Host's own domain it should always
1306
- // land, so this normally stops after the first attempt; it exists for
1307
- // the case the mark DID land and the item is still unread (a fresh
1308
- // ending raced the timer), instead of leaving the dot red for good.
1309
- // A "waiting for you" item has no turn end to acknowledge: stop there.
1310
- timer = stillUnread ? window.setTimeout(attempt, 2000) : 0;
1311
1755
  };
1312
1756
  if (delayMs <= 0) attempt();
1313
1757
  else timer = window.setTimeout(attempt, delayMs);
@@ -1317,19 +1761,21 @@ window.__ModuleLoader__.load({
1317
1761
  };
1318
1762
  }, [staySignature, delayMs]);
1319
1763
  React.useEffect(() => { emitRealBadge(count, items); }, [count, items]);
1320
- // The workspace rows are labelled with the workspace TITLE, which is not
1321
- // always the cwd basename, so map session → title from the official snapshot.
1764
+ // Map session → its Workspace from the official snapshot: the ID anchors the
1765
+ // folder dot to the keyed row, the title is what the row displays (not always
1766
+ // the cwd basename, and not always equal to the stored title either).
1322
1767
  React.useEffect(() => {
1323
- const titles = {};
1768
+ const owners = {};
1324
1769
  const list = workspaces !== null && typeof workspaces === "object" && Array.isArray(workspaces.items) ? workspaces.items : [];
1325
1770
  for (const workspace of list) {
1326
1771
  if (workspace === null || typeof workspace !== "object") continue;
1772
+ if (!Array.isArray(workspace.sessionIds)) continue;
1773
+ const id = typeof workspace.workspaceId === "string" ? workspace.workspaceId : "";
1327
1774
  const title = typeof workspace.title === "string" ? workspace.title : "";
1328
- if (title === "" || !Array.isArray(workspace.sessionIds)) continue;
1329
- for (const id of workspace.sessionIds) titles[id] = title;
1775
+ for (const sessionId of workspace.sessionIds) owners[sessionId] = { id, title };
1330
1776
  }
1331
1777
  emitSidebarMarks({
1332
- workspaces: dotsFromItems(items, titles),
1778
+ workspaces: dotsFromItems(items, owners),
1333
1779
  sessions: items.map((item) => ({ id: item.id, title: item.title }))
1334
1780
  });
1335
1781
  }, [items, workspaces]);
@@ -1776,6 +2222,9 @@ window.__ModuleLoader__.load({
1776
2222
  const [badgeSourceValue, setBadgeSourceValue] = React.useState(badgeSource);
1777
2223
  const [unread, setUnread] = React.useState(unreadConfig);
1778
2224
  React.useEffect(() => subscribeUnreadConfig(setUnread), []);
2225
+ // Sampled when the section mounts, which is the moment the user is
2226
+ // looking at it. It reports local facts only (see badgeCapabilityProbe).
2227
+ const [appBadgeProbe] = React.useState(() => appBadgeEnv());
1779
2228
  // Draft text for the delay box: typing must not save on every keystroke,
1780
2229
  // and the box has to show the CLAMPED value once the save lands (type
1781
2230
  // 99999 and it settles on the 600 cap instead of lying about it).
@@ -1846,7 +2295,7 @@ window.__ModuleLoader__.load({
1846
2295
  const next = normalizeUnreadConfig({ ...unreadConfig, ...patch });
1847
2296
  emitUnreadConfig(next);
1848
2297
  rpc.call("/api", "iconCustom/setUnreadRule", {
1849
- args: { request: { reasons: next.reasons, pending: next.pending, workspaceDot: next.workspaceDot, clearDelaySec: next.clearDelaySec } }
2298
+ args: { request: { reasons: next.reasons, pending: next.pending, workspaceDot: next.workspaceDot, appBadge: next.appBadge, clearDelaySec: next.clearDelaySec } }
1850
2299
  }).catch(() => {});
1851
2300
  }, [rpc]);
1852
2301
  // Toggle one unread reason. Applies locally at once; the host keeps the
@@ -1861,6 +2310,11 @@ window.__ModuleLoader__.load({
1861
2310
  const onWorkspaceDotToggle = React.useCallback((checked) => {
1862
2311
  saveUnreadRule({ workspaceDot: checked });
1863
2312
  }, [saveUnreadRule]);
2313
+ // The system-icon projection of the same number. Saved through the same
2314
+ // one save path, so this field can never be dropped by a caller.
2315
+ const onAppBadgeToggle = React.useCallback((checked) => {
2316
+ saveUnreadRule({ appBadge: checked });
2317
+ }, [saveUnreadRule]);
1864
2318
  // Seconds you must stay in a session before it counts as read; 0 = as soon
1865
2319
  // as you enter. Clamped by the shared normalizer, so a stray keystroke can
1866
2320
  // never persist an absurd delay.
@@ -2129,6 +2583,20 @@ window.__ModuleLoader__.load({
2129
2583
  ),
2130
2584
  React.createElement("div", { style: style.hint }, t("workspaceDotHint"))
2131
2585
  ),
2586
+ React.createElement("div", { style: { marginTop: "10px" } },
2587
+ React.createElement("label", { style: { display: "inline-flex", alignItems: "center", gap: "6px", fontSize: 12, color: "var(--dsw-alias-label-primary)", cursor: "pointer" } },
2588
+ React.createElement("input", {
2589
+ type: "checkbox",
2590
+ checked: unread.appBadge !== false,
2591
+ onChange: (event) => onAppBadgeToggle(event.target.checked === true)
2592
+ }),
2593
+ React.createElement("span", null, t("appBadgeLabel"))
2594
+ ),
2595
+ React.createElement("div", { style: style.hint }, t("appBadgeHint")),
2596
+ // What THIS device is — never a claim about the platform.
2597
+ React.createElement("div", { style: style.hint }, t(appBadgeCapabilityKey(appBadgeProbe))),
2598
+ React.createElement("div", { style: style.hint }, t("appBadgeFootnote"))
2599
+ ),
2132
2600
  React.createElement("div", { style: style.hint }, t("badgeTestHint"))
2133
2601
  ),
2134
2602
  notice ? React.createElement("div", { style: style.notice }, notice) : null,
@@ -2145,6 +2613,10 @@ window.__ModuleLoader__.load({
2145
2613
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-icon-custom: dictionaries");
2146
2614
  const t = ctx.locale.bind(NS);
2147
2615
  const rpc = ctx.connection.rpc;
2616
+ // The watermark's Host half rides the same connection as every other RPC.
2617
+ // Setting it here (not at module scope) is what keeps the store usable in a
2618
+ // test harness with no connection: pushes stay no-ops, the badge still works.
2619
+ seenRpc = rpc;
2148
2620
  // Pull the host state once, so the brand mark and the badge are correct
2149
2621
  // before the settings section is ever opened. The unread rule rides its
2150
2622
  // own endpoint: the icon RPCs keep their pre-badge response shape.
@@ -2156,8 +2628,16 @@ window.__ModuleLoader__.load({
2156
2628
  if (resp === null || typeof resp !== "object" || resp.ok !== true) return;
2157
2629
  // The rule response carries the Host's clock; it is what puts the seen
2158
2630
  // watermark (and a legacy browser-domain store) into the Host's domain.
2159
- if (resp.value !== null && typeof resp.value === "object") noteHostClock(resp.value.hostNow);
2631
+ // It also carries the Host's watermark table, which merges in here —
2632
+ // then the first push sends the union back, which is both the migration
2633
+ // (marks this browser made before 0.11.2) and the self-heal (a Host file
2634
+ // that was lost or pruned gets this browser's copy back).
2635
+ if (resp.value !== null && typeof resp.value === "object") {
2636
+ noteHostClock(resp.value.hostNow);
2637
+ if (adoptHostSeen(resp.value.seen)) saveSeenState();
2638
+ }
2160
2639
  emitUnreadConfig(resp.value);
2640
+ scheduleHostSync();
2161
2641
  })
2162
2642
  .catch(() => {});
2163
2643
  // Keep "the last moment this browser was here" fresh, so an ending that
@@ -2180,18 +2660,33 @@ window.__ModuleLoader__.load({
2180
2660
  // Re-sample the Host↔browser clock offset after a sleep/resume, which is the
2181
2661
  // one moment the two can drift apart mid-page. Throttled: it is one small
2182
2662
  // RPC per visible-again, and never on beforeunload (nothing would come back).
2663
+ // The same response carries the Host's watermark, so this is also how a
2664
+ // SECOND device learns that something was read elsewhere — a page that is
2665
+ // simply left open converges within a minute without a reload. Coming back
2666
+ // into view is also the natural moment to retry a failed push.
2183
2667
  ctx.effect(() => {
2184
2668
  let sampledAt = 0;
2185
2669
  const sample = () => {
2186
2670
  if (document.visibilityState === "hidden") return;
2671
+ scheduleHostSync();
2187
2672
  if (Date.now() - sampledAt < 60000) return;
2188
2673
  sampledAt = Date.now();
2189
2674
  rpc.call("/api", "iconCustom/getUnreadRule", { args: {} })
2190
- .then((resp) => { if (resp && resp.ok === true && resp.value !== null && typeof resp.value === "object") noteHostClock(resp.value.hostNow); })
2675
+ .then((resp) => {
2676
+ if (resp === null || typeof resp !== "object" || resp.ok !== true) return;
2677
+ const value = resp.value !== null && typeof resp.value === "object" ? resp.value : null;
2678
+ if (value === null) return;
2679
+ noteHostClock(value.hostNow);
2680
+ if (adoptHostSeen(value.seen)) { saveSeenState(); emitUnreadPoke(); }
2681
+ })
2191
2682
  .catch(() => {});
2192
2683
  };
2193
2684
  window.addEventListener("visibilitychange", sample);
2194
- return () => window.removeEventListener("visibilitychange", sample);
2685
+ const interval = window.setInterval(sample, 60000);
2686
+ return () => {
2687
+ window.removeEventListener("visibilitychange", sample);
2688
+ window.clearInterval(interval);
2689
+ };
2195
2690
  }, "dsh-icon-custom: host clock sample");
2196
2691
  // Brand-mark seat: replaces only the whale mark (verified: a
2197
2692
  // third-party registration wins over the official occupant and the
@@ -2271,7 +2766,10 @@ window.__ModuleLoader__.load({
2271
2766
  observer.observe(document.head, { childList: true, subtree: true, attributes: true, attributeFilter: ["href", "rel"] });
2272
2767
  window.addEventListener("visibilitychange", schedule);
2273
2768
  window.addEventListener("focus", schedule);
2274
- const interval = window.setInterval(reconcileFavicon, 5000);
2769
+ const interval = window.setInterval(() => {
2770
+ reconcileFavicon();
2771
+ reassertAppBadge();
2772
+ }, 5000);
2275
2773
  return () => {
2276
2774
  observer.disconnect();
2277
2775
  window.removeEventListener("visibilitychange", schedule);