@cowwo/dsh-icon-custom 0.19.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/client/client.js +284 -91
  2. package/package.json +1 -1
package/client/client.js CHANGED
@@ -646,69 +646,102 @@ window.__ModuleLoader__.load({
646
646
  function documentVisible() {
647
647
  try { return document.visibilityState !== "hidden"; } catch { return true; }
648
648
  }
649
+ /**
650
+ * The official "Session on screen" answer, when this build exposes it.
651
+ *
652
+ * `ctx.uiSession.adapter.current` is the very binding the renderer derives
653
+ * `[data-conversation-session]` from, so it answers with the same value without
654
+ * the DOM's first-match ambiguity (see `conversationOnScreen`) — and it keeps
655
+ * answering while a global panel has unmounted the conversation, where the DOM
656
+ * has nothing to say. Filled in by `apply`, where a client context exists;
657
+ * until then it answers nothing, which is exactly what the other fallbacks are
658
+ * for.
659
+ * @returns the session id, or undefined when this build does not say.
660
+ */
661
+ let officialSessionId = () => undefined;
662
+ /**
663
+ * What the conversation on screen is rendering, in two parts.
664
+ *
665
+ * `rendered` says whether the main conversation is on screen at all, and `id` is
666
+ * the session it renders (`undefined` while none is selected — the landing view).
667
+ * The distinction matters: "the conversation is showing no session" is an answer
668
+ * ("you are not in a session"), while "there is no conversation element" is not
669
+ * an answer at all (a global panel replaced it, the page is still booting).
670
+ *
671
+ * TWO occurrences can carry this attribute: the main conversation, and the
672
+ * embedded one the right sidebar keeps MOUNTED while collapsed (hidden by CSS and
673
+ * `inert`). A plain `querySelector` returns the first in document order, so the
674
+ * moment the main occurrence carries no attribute — no session selected, the
675
+ * archive/close path — the hidden one becomes the answer: "you are looking at
676
+ * that subagent conversation". The stay clock reads that as "you left this
677
+ * session", drops the entered-at snapshot, and the ending that arrived meanwhile
678
+ * is acknowledged the moment you come back: a dot that goes out without you ever
679
+ * leaving. Only an occurrence the user can actually see is an answer.
680
+ * @returns `{ rendered, id }`.
681
+ */
682
+ function conversationOnScreen() {
683
+ try {
684
+ const nodes = document.querySelectorAll("[data-conversation-session]");
685
+ let rendered = false;
686
+ for (const node of nodes) {
687
+ if (typeof node.closest === "function" && (node.closest("[data-sidebar-right-panel]") !== null || node.closest("[inert]") !== null)) continue;
688
+ rendered = true;
689
+ const id = node.getAttribute("data-conversation-session");
690
+ if (typeof id === "string" && id !== "") return { rendered: true, id };
691
+ }
692
+ return { rendered, id: undefined };
693
+ } catch { return { rendered: false, id: undefined }; }
694
+ }
649
695
  /**
650
696
  * The session whose conversation the page is rendering right now, from the DOM.
651
697
  *
652
698
  * This is the only signal that names the session on screen, and it is the one
653
699
  * that keeps a stale retention from clearing a dot: `retainedBy.mainView` means
654
- * "recently opened, not yet released", not "displayed". `undefined` means this
655
- * build carries no such marker (or the probe failed) and callers must fall back
656
- * to the retention answer rather than treat it as "nothing is displayed".
700
+ * "recently opened, not yet released", not "displayed". `undefined` means the page
701
+ * does not name one (see `conversationOnScreen`) and callers must fall back to the
702
+ * other answers rather than treat it as "nothing is displayed".
657
703
  * @returns the session id, or undefined when the page cannot say.
658
704
  */
659
705
  function displayedSessionId() {
660
- try {
661
- const node = document.querySelector("[data-conversation-session]");
662
- if (node === null) return undefined;
663
- const id = node.getAttribute("data-conversation-session");
664
- return typeof id === "string" && id !== "" ? id : undefined;
665
- } catch { return undefined; }
666
- }
667
- /**
668
- * Every session the main view is showing.
669
- *
670
- * `view` carries what the page itself says about the screen:
671
- * * `panelActive` — a global panel (settings, plugins, …) replaced the
672
- * conversation. DSH's own sidebar treats that as "no current session"
673
- * (`usePanelInfo((info) => info.activePanelId !== null)`), and following it
674
- * is what fixes the "cleared a dot I never opened" report: the conversation
675
- * is simply not on screen.
676
- * * `visible` — whether this page is on screen at all.
677
- * * `displayed` — the session the conversation is rendering, when the DOM can
678
- * name it. That is the precise answer and it wins over retention.
679
- *
680
- * Retention (`retainedBy.mainView`) stays as the fallback: it is what shipped
681
- * before, it keeps working on a build without the newer signals, and its known
682
- * weakness (a release that lags behind the pane) only costs a false "read" when
683
- * nothing better is available.
684
- * @param list - the session list snapshot (may be absent).
685
- * @param view - the screen report described above (may be absent).
686
- * @returns the ids being viewed, in list order; `[]` when there are none.
687
- */
706
+ return conversationOnScreen().id;
707
+ }
688
708
  /**
689
- * Which session this page is ON — the set that defines "entered" and "left".
709
+ * The session this page is ON — the set that defines "entered" and "left" — plus
710
+ * whether the answer is actually known.
711
+ *
712
+ * Only a change here counts as leaving a session, so this must NOT be emptied by
713
+ * pausing: a background tab, or a global panel (settings, plugins, …) that covers
714
+ * the conversation, keeps you on the same session. Emptying it would drop the
715
+ * entered-at snapshot, and taking a fresh one on return is exactly what
716
+ * acknowledges an ending you never left the session for.
690
717
  *
691
- * Only a change here counts as leaving a session, so this must NOT be emptied
692
- * by pausing: a background tab, or a global panel (settings, plugins, …) that
693
- * covers the conversation, keeps you on the same session. Emptying it would
694
- * drop the entered-at snapshot, and taking a fresh one on return is exactly
695
- * what acknowledges an ending you never left the session for — the bug behind
696
- * "切一下标签页红点就没了".
718
+ * The answers, in order:
719
+ * * the visible conversation's own session (the precise one, and the only one
720
+ * that may overrule retention);
721
+ * * the official "Session on screen" binding (`view.official`, from
722
+ * `ctx.uiSession`) — the very value the attribute above is rendered from, so
723
+ * it still answers while a global panel has unmounted the conversation;
724
+ * * `list.current`, when a build carries it;
725
+ * * retention (`retainedBy.mainView > 0`), the pre-0.12 answer.
697
726
  *
698
- * `view.displayed` (read from the DOM) is the precise answer when available;
699
- * `list.current` and then retention are the fallbacks, which is also what keeps
700
- * the answer meaningful while a panel covers the conversation.
727
+ * `known: false` is the important one: it means the page could not name ANY
728
+ * session, which is NOT the same as "no session is being viewed". Callers must
729
+ * keep the snapshots they already have instead of reading it as a leave — a
730
+ * single such frame used to be enough to acknowledge an ending you were sitting
731
+ * in front of.
701
732
  * @param list - the session list snapshot (may be absent).
702
- * @param view - `{ displayed }`: the session the conversation is rendering.
703
- * @returns the ids being viewed, in list order; `[]` when there are none.
733
+ * @param view - `{ displayed, official }`: what the page says about the screen.
734
+ * @returns `{ ids, known }`; `ids` in list order, `[]` when there are none.
704
735
  */
705
- function currentSessionIds(list, view) {
736
+ function viewedSessions(list, view) {
706
737
  const screen = view !== null && typeof view === "object" ? view : {};
707
- if (typeof screen.displayed === "string" && screen.displayed !== "") return [screen.displayed];
708
- if (list === null || typeof list !== "object") return [];
709
- if (typeof list.current === "string" && list.current !== "") return [list.current];
738
+ if (typeof screen.displayed === "string" && screen.displayed !== "") return { ids: [screen.displayed], known: true };
739
+ if (typeof screen.official === "string" && screen.official !== "") return { ids: [screen.official], known: true };
740
+ if (screen.idle === true) return { ids: [], known: true }; // the conversation is up, showing no session
741
+ if (list === null || typeof list !== "object") return { ids: [], known: false };
742
+ if (typeof list.current === "string" && list.current !== "") return { ids: [list.current], known: true };
710
743
  const byId = list.byId;
711
- if (byId === null || typeof byId !== "object") return [];
744
+ if (byId === null || typeof byId !== "object") return { ids: [], known: false };
712
745
  const ids = [];
713
746
  for (const session of Object.values(byId)) {
714
747
  if (session === null || typeof session !== "object") continue;
@@ -716,7 +749,11 @@ window.__ModuleLoader__.load({
716
749
  const count = retainedBy !== null && typeof retainedBy === "object" ? retainedBy.mainView : undefined;
717
750
  if (typeof count === "number" && count > 0 && typeof session.id === "string" && session.id !== "") ids.push(session.id);
718
751
  }
719
- return ids;
752
+ // Retention with nothing in it, and the conversation not on screen either, is
753
+ // exactly the frame this must not read as a leave: it is what a global panel
754
+ // over a released session, a boot, or a DOM gap looks like. The landing view
755
+ // (`idle`) is the positive "you are on no session" answer, not this.
756
+ return { ids, known: ids.length > 0 };
720
757
  }
721
758
  /**
722
759
  * One session's last `turn/end` timestamp (host domain), or 0 when it has
@@ -762,13 +799,16 @@ window.__ModuleLoader__.load({
762
799
  * @param list - the session list snapshot (may be absent).
763
800
  * @param pending - the pending-interaction map (may be absent).
764
801
  * @param config - the unread rule to apply.
802
+ * @param lit - the ids currently showing a dot, so a change of ENDING KIND cannot
803
+ * put an already lit dot out (see below). Optional.
765
804
  * @returns the badge number.
766
805
  */
767
- function collectUnread(list, pending, config, seen) {
806
+ function collectUnread(list, pending, config, seen, lit) {
768
807
  const reasons = config !== null && typeof config === "object" && config.reasons !== null && typeof config.reasons === "object" ? config.reasons : {};
769
808
  // The watermark is a parameter, not module state: this stays a pure
770
809
  // decision function that a test can drive with any marks it likes.
771
810
  const marks = seen !== null && typeof seen === "object" ? seen : {};
811
+ const litIds = lit !== null && lit !== undefined && typeof lit.has === "function" ? lit : null;
772
812
  /** { id, title, waiting, kind, at } — newest first, "in wait" ahead of "ended". */
773
813
  const hits = [];
774
814
  const ids = new Set();
@@ -788,7 +828,12 @@ window.__ModuleLoader__.load({
788
828
  if (endAt <= 0 || typeof seenAt !== "number" || endAt <= seenAt) continue;
789
829
  // Unknown reasons (a newer DSH adds one) stay hidden until the user
790
830
  // turns them on, so a vocabulary growth can never surprise the badge.
791
- if (reasons[value.reason] !== true) continue;
831
+ // A reason the user turned OFF behaves the same way — with ONE
832
+ // exception: it must never put out a dot that is already lit. That dot
833
+ // stands for an ending nobody has read yet, and "the newest turn ended
834
+ // in a way I do not want to be told about" is not "I read it". Only the
835
+ // watermark clears a lit dot.
836
+ if (reasons[value.reason] !== true && !(litIds !== null && litIds.has(id))) continue;
792
837
  ids.add(id);
793
838
  hits.push({ id, title: titleOf(id, entry), where: sessionWhereOf(entry), waiting: false, kind: value.reason, at: endAt });
794
839
  }
@@ -903,6 +948,16 @@ window.__ModuleLoader__.load({
903
948
  let realCount = 0;
904
949
  /** The sessions behind `realCount`, for the sidebar list (newest first). */
905
950
  let realItems = [];
951
+ /**
952
+ * Which sessions are showing an unread dot right now.
953
+ *
954
+ * Read back by `collectUnread` so a change of ending KIND cannot put a lit dot
955
+ * out (see the reason filter there). Kept here rather than derived on the fly
956
+ * because the projection only carries the LATEST ending: once a newer one lands
957
+ * with a reason the user turned off, the one that lit the dot is no longer
958
+ * visible anywhere.
959
+ */
960
+ let litUnreadIds = new Set();
906
961
  /**
907
962
  * How many sessions are running right now — the official `running` fact,
908
963
  * already folded by `runningSessionIds`. It rides the SAME bridge as the
@@ -963,6 +1018,14 @@ window.__ModuleLoader__.load({
963
1018
  function emitRealBadge(value, items) {
964
1019
  realCount = normalizeCount(value);
965
1020
  realItems = Array.isArray(items) ? items : [];
1021
+ // Remember what is lit BEFORE the next round of folding can hide it: the
1022
+ // projection only carries the latest ending, so an ending whose reason is
1023
+ // turned off would otherwise erase the memory of the one that lit the dot.
1024
+ const lit = new Set();
1025
+ for (const item of realItems) {
1026
+ if (item !== null && typeof item === "object" && typeof item.id === "string" && item.id !== "") lit.add(item.id);
1027
+ }
1028
+ litUnreadIds = lit;
966
1029
  notifyBadge();
967
1030
  }
968
1031
  /**
@@ -1381,11 +1444,34 @@ window.__ModuleLoader__.load({
1381
1444
  // canvas and the merged bitmap is handed back to the browser. Count 0 (and
1382
1445
  // teardown) restores the pristine platform/custom href.
1383
1446
  const FAVICON_SIZE = 64;
1447
+ /**
1448
+ * How long one base-image fetch may take before the compose gives up on it.
1449
+ *
1450
+ * A fetch that neither loads nor errors (a stalled request, a proxy that never
1451
+ * answers) used to leave the compose pending forever — and silently, because no
1452
+ * error is ever raised — so the tab icon simply stopped following the numbers
1453
+ * with nothing in the console. This timeout turns that into a reported failure
1454
+ * the watchdog can retry.
1455
+ */
1456
+ const FAVICON_LOAD_TIMEOUT_MS = 8000;
1384
1457
  let faviconBase = null;
1385
1458
  let composeSeq = 0;
1386
1459
  let faviconComposing = false;
1387
1460
  /** Last compose failure, so a repeated failure warns once instead of spamming. */
1388
1461
  let lastComposeError = null;
1462
+ /**
1463
+ * The picture the tab is carrying right now: `未读|进行中|基图`.
1464
+ *
1465
+ * A `data:` href alone says nothing — an OLD bitmap and a bitmap whose compose
1466
+ * FAILED look exactly the same from the DOM. Remembering which picture was
1467
+ * actually written is what lets the watchdog tell "already right" from "stale
1468
+ * but still ours", which is the hole that used to freeze the tab icon.
1469
+ */
1470
+ let faviconDrawnSignature = "";
1471
+ /** The picture a compose is fetching right now, so a repeat cannot start a second fetch. */
1472
+ let faviconPendingSignature = "";
1473
+ /** Whether the last compose failed: the watchdog must retry instead of trusting the DOM. */
1474
+ let faviconFailed = false;
1389
1475
  /** Which icon the plugin considers current (filled from the status RPC). */
1390
1476
  let iconState = { active: false, rev: null, png: false, mime: null };
1391
1477
  /** The icon URL this page SHOULD be showing, per the plugin's own status. */
@@ -1399,6 +1485,24 @@ window.__ModuleLoader__.load({
1399
1485
  if (iconState.active !== true) return "image/svg+xml";
1400
1486
  return iconState.png === true ? "image/png" : (iconState.mime || "image/svg+xml");
1401
1487
  }
1488
+ /**
1489
+ * One string that names one picture: which numbers, on which base.
1490
+ *
1491
+ * It is the identity used both to remember what was drawn and to decide whether
1492
+ * a compose still has anything to do. The numbers are part of it on purpose —
1493
+ * "1 unread" and "2 unread" are different pictures even though both are `data:`.
1494
+ * @param count - the unread number this picture carries.
1495
+ * @param runningCount - the running number this picture carries.
1496
+ * @param base - the base icon href the numbers ride on.
1497
+ * @returns the signature string.
1498
+ */
1499
+ function faviconSignature(count, runningCount, base) {
1500
+ return normalizeCount(count) + "|" + normalizeCount(runningCount) + "|" + base;
1501
+ }
1502
+ /** The picture this page SHOULD be carrying right now, from live state. */
1503
+ function wantedFaviconSignature() {
1504
+ return faviconSignature(effectiveCount(), runningTotal, desiredIconHref());
1505
+ }
1402
1506
  /**
1403
1507
  * Every `<link rel="icon">` the document carries, creating one if the page
1404
1508
  * has none.
@@ -1545,36 +1649,64 @@ window.__ModuleLoader__.load({
1545
1649
  c2d.fillText(text, cx, cy + size * 0.01);
1546
1650
  c2d.restore();
1547
1651
  }
1652
+ /**
1653
+ * Load one image for the composite, with a deadline.
1654
+ *
1655
+ * Settles exactly once, whatever happens: a late `onload` after the timeout must
1656
+ * not draw on top of the picture that replaced it.
1657
+ * @param href - absolute URL of the base icon.
1658
+ * @returns the loaded image, or a rejection whose message says why.
1659
+ */
1548
1660
  function loadIconImage(href) {
1549
1661
  return new Promise((resolve, reject) => {
1550
1662
  const img = new Image();
1551
- img.onload = () => resolve(img);
1552
- img.onerror = () => reject(new Error("icon-load"));
1663
+ let done = false;
1664
+ const settle = (fn, value) => {
1665
+ if (done) return;
1666
+ done = true;
1667
+ window.clearTimeout(timer);
1668
+ fn(value);
1669
+ };
1670
+ const timer = window.setTimeout(() => { settle(reject, new Error("icon-load-timeout")); }, FAVICON_LOAD_TIMEOUT_MS);
1671
+ img.onload = () => { settle(resolve, img); };
1672
+ img.onerror = () => { settle(reject, new Error("icon-load")); };
1553
1673
  img.src = href;
1554
1674
  });
1555
1675
  }
1556
1676
  async function applyBadgeToFavicon(count, size) {
1557
- const base = desiredIconHref();
1558
- // Two independent marks ride one composite, so "nothing to draw" means
1559
- // neither of them is wanted — not merely "no unread".
1560
- const running = runningTotal > 0;
1561
- if (count <= 0 && !running) {
1562
- // No badge to draw. With a custom icon active the page must carry it on
1563
- // EVERY link (a stale scheme-specific sibling outranks the one we set);
1564
- // with none, the shipped dark/light pair goes back untouched — the
1565
- // plugin promises zero side effects while it has no icon of its own.
1566
- if (iconState.active === true) setIconEverywhere(base, desiredIconType());
1567
- else restoreShippedIcons();
1568
- faviconBase = null;
1569
- return;
1570
- }
1571
- // The base is resolved from the plugin's status, never from whatever the
1572
- // page happens to carry, and never from our own previous bitmap.
1573
- faviconBase = { href: base, type: desiredIconType() };
1677
+ // ONE snapshot decides this whole compose: which marks to draw AND with which
1678
+ // numbers. `runningTotal` used to be read again after the await, so a run that
1679
+ // stopped mid-fetch was painted as a yellow "0", and the two numbers could
1680
+ // come from two different moments.
1681
+ const wantedCount = normalizeCount(count);
1682
+ const runningCount = normalizeCount(runningTotal);
1683
+ const signature = faviconSignature(wantedCount, runningCount, desiredIconHref());
1684
+ // The sequence is taken BEFORE the "nothing to draw" branch: restoring the
1685
+ // plain icon has to invalidate any compose still in flight, or that older
1686
+ // bitmap lands afterwards and paints the number we just cleared back onto
1687
+ // the tab. (That is exactly the "stale dot that will not go away" report.)
1574
1688
  const seq = ++composeSeq;
1575
1689
  faviconComposing = true;
1690
+ faviconPendingSignature = signature;
1691
+ faviconFailed = false;
1576
1692
  try {
1577
- const img = await loadIconImage(new URL(faviconBase.href, document.baseURI).href);
1693
+ if (wantedCount <= 0 && runningCount <= 0) {
1694
+ // No badge to draw. With a custom icon active the page must carry it on
1695
+ // EVERY link (a stale scheme-specific sibling outranks the one we set);
1696
+ // with none, the shipped dark/light pair goes back untouched — the
1697
+ // plugin promises zero side effects while it has no icon of its own.
1698
+ if (seq !== composeSeq) return;
1699
+ if (iconState.active === true) setIconEverywhere(desiredIconHref(), desiredIconType());
1700
+ else restoreShippedIcons();
1701
+ faviconBase = null;
1702
+ faviconDrawnSignature = signature;
1703
+ return;
1704
+ }
1705
+ // The base is resolved from the plugin's status, never from whatever the
1706
+ // page happens to carry, and never from our own previous bitmap.
1707
+ const base = desiredIconHref();
1708
+ faviconBase = { href: base, type: desiredIconType() };
1709
+ const img = await loadIconImage(new URL(base, document.baseURI).href);
1578
1710
  if (seq !== composeSeq) return;
1579
1711
  const canvas = document.createElement("canvas");
1580
1712
  canvas.width = FAVICON_SIZE;
@@ -1590,16 +1722,22 @@ window.__ModuleLoader__.load({
1590
1722
  // together to the largest pair that fits, so neither reads as a bug and
1591
1723
  // the two can never overlap.
1592
1724
  const badgeUnit = badgeScale(size);
1593
- const badgeK = count > 0 && running ? badgeUnit * faviconBadgeFitScale(badgeUnit) : badgeUnit;
1594
- if (count > 0) drawFaviconBadge(c2d, FAVICON_SIZE, count, badgeK);
1595
- if (running) drawFaviconRunningBadge(c2d, FAVICON_SIZE, runningTotal, badgeK); setIconEverywhere(canvas.toDataURL("image/png"), "image/png");
1725
+ const badgeK = wantedCount > 0 && runningCount > 0 ? badgeUnit * faviconBadgeFitScale(badgeUnit) : badgeUnit;
1726
+ if (wantedCount > 0) drawFaviconBadge(c2d, FAVICON_SIZE, wantedCount, badgeK);
1727
+ if (runningCount > 0) drawFaviconRunningBadge(c2d, FAVICON_SIZE, runningCount, badgeK);
1728
+ setIconEverywhere(canvas.toDataURL("image/png"), "image/png");
1729
+ faviconDrawnSignature = signature;
1596
1730
  } catch (error) {
1597
1731
  // Never silent: a failure here is invisible in the UI (the tab simply
1598
1732
  // keeps its previous icon), which once cost a whole debugging session.
1599
1733
  // Warn once per distinct reason so the 5s watchdog cannot spam.
1734
+ faviconFailed = true;
1600
1735
  warnFaviconCompose(error);
1601
1736
  } finally {
1602
- if (seq === composeSeq) faviconComposing = false;
1737
+ if (seq === composeSeq) {
1738
+ faviconComposing = false;
1739
+ faviconPendingSignature = "";
1740
+ }
1603
1741
  }
1604
1742
  }
1605
1743
  /**
@@ -1623,10 +1761,22 @@ window.__ModuleLoader__.load({
1623
1761
  * rejection, and the tab icon would simply never change with nothing in the
1624
1762
  * console — the exact "no warning, no badge" state. Every call site goes through
1625
1763
  * here instead.
1764
+ *
1765
+ * It is also the ONE place that decides whether there is anything to do: this is
1766
+ * called on every badge notification, and a badge notification happens on every
1767
+ * render. Without the signature guard each of those started its own base-image
1768
+ * fetch, and on a page whose base image is slow (a reverse proxy, a cold cache,
1769
+ * a laptop waking up) every fetch was superseded before it landed — the tab icon
1770
+ * fell further behind the numbers with every repaint instead of catching up.
1626
1771
  * @param count - sessions needing you; 0 restores the plain icon.
1627
1772
  * @param size - badge size key.
1773
+ * @param force - recompose even when this exact picture was already drawn (the
1774
+ * page was disturbed, the base icon changed, the last compose failed).
1628
1775
  */
1629
- function composeFavicon(count, size) {
1776
+ function composeFavicon(count, size, force) {
1777
+ const signature = faviconSignature(count, runningTotal, desiredIconHref());
1778
+ if (signature === faviconPendingSignature) return; // this very picture is being fetched
1779
+ if (force !== true && signature === faviconDrawnSignature && faviconFailed !== true) return; // already on the tab
1630
1780
  try {
1631
1781
  Promise.resolve(applyBadgeToFavicon(count, size)).catch(warnFaviconCompose);
1632
1782
  } catch (error) {
@@ -1641,6 +1791,12 @@ window.__ModuleLoader__.load({
1641
1791
  * icon link — a cached page, a browser extension, a tab move, or a plugin
1642
1792
  * reload. Our own output is a `data:` URL, so any other href means the
1643
1793
  * composite was replaced and has to be rebuilt from the new base.
1794
+ *
1795
+ * "Still ours" is not the same as "still right": a `data:` href can carry an
1796
+ * older number, or one whose compose died halfway. Comparing the picture that
1797
+ * was actually written against the one that is wanted now is what lets this
1798
+ * watchdog repair those — and what makes a failed compose a retry instead of a
1799
+ * tab icon that is frozen for the rest of the page's life.
1644
1800
  */
1645
1801
  function reconcileFavicon() {
1646
1802
  // Inspect EVERY icon link: 0.2.x ships two (dark/light), and a sibling
@@ -1648,21 +1804,24 @@ window.__ModuleLoader__.load({
1648
1804
  // stale sibling is exactly what used to outrank the icon we set.
1649
1805
  const hrefs = iconLinks().map((candidate) => candidate.getAttribute("href"));
1650
1806
  const ours = hrefs.length > 0 && hrefs.every((value) => typeof value === "string" && value.slice(0, 5) === "data:");
1651
- if (effectiveCount() <= 0 && runningTotal <= 0) {
1807
+ const wanted = wantedFaviconSignature();
1808
+ if (ours && faviconDrawnSignature === wanted && faviconFailed !== true) return; // exactly right, right now
1809
+ const count = effectiveCount();
1810
+ if (count <= 0 && normalizeCount(runningTotal) <= 0) {
1652
1811
  // Carries the custom icon, or puts the shipped pair back. Both writers
1653
1812
  // only touch a link that actually differs, so this stays a no-op once
1654
1813
  // the page is correct and cannot re-arm the head observer.
1655
- composeFavicon(0);
1814
+ composeFavicon(0, badgeSize, ours !== true);
1656
1815
  return;
1657
1816
  }
1658
- if (ours || faviconComposing) return; // already showing, or a build is running
1659
- composeFavicon(effectiveCount(), badgeSize);
1817
+ if (faviconComposing) return; // a build is already running
1818
+ composeFavicon(count, badgeSize, ours !== true);
1660
1819
  }
1661
1820
  // A freshly applied base icon replaces whatever the browser holds, so the
1662
1821
  // composite must be rebuilt from the NEW base instead of the stale one.
1663
1822
  function refreshFaviconBase() {
1664
1823
  faviconBase = null;
1665
- composeFavicon(effectiveCount(), badgeSize);
1824
+ composeFavicon(effectiveCount(), badgeSize, true);
1666
1825
  }
1667
1826
  //#endregion
1668
1827
 
@@ -2575,8 +2734,19 @@ window.__ModuleLoader__.load({
2575
2734
  }, []);
2576
2735
  /** Looking at the conversation right now: on screen, and this tab in front. */
2577
2736
  const watching = visible && panelActive !== true;
2578
- const currentIds = currentSessionIds(list, { displayed: displayedSessionId() });
2579
- const items = collectUnread(list, pending, config, seenState.seen);
2737
+ // Read the screen once per render: which session the visible conversation
2738
+ // renders (`displayed`), whether it is up but showing none (`idle` — the
2739
+ // landing view, a real "you are not in a session"), and whether it is not up
2740
+ // at all (neither ⇒ `known: false`, and the entered-at snapshots stay).
2741
+ const onScreen = conversationOnScreen();
2742
+ const viewed = viewedSessions(list, {
2743
+ displayed: onScreen.id,
2744
+ official: officialSessionId(),
2745
+ idle: onScreen.rendered === true && onScreen.id === undefined
2746
+ });
2747
+ const currentIds = viewed.ids;
2748
+ const stayKnown = viewed.known;
2749
+ const items = collectUnread(list, pending, config, seenState.seen, litUnreadIds);
2580
2750
  /**
2581
2751
  * Map session → its Workspace from the official snapshot: the ID anchors the
2582
2752
  * folder dot to the keyed row, the title is what the row displays (not always
@@ -2650,9 +2820,17 @@ window.__ModuleLoader__.load({
2650
2820
  const stayArmed = React.useRef(new Map());
2651
2821
  React.useEffect(() => {
2652
2822
  const snapshot = stayLive.current.list !== null && typeof stayLive.current.list === "object" && stayLive.current.list.byId !== null && typeof stayLive.current.list.byId === "object" ? stayLive.current.list.byId : {};
2653
- const live = new Set(stayIds);
2654
- for (const id of Array.from(stayArmed.current.keys())) {
2655
- if (!live.has(id)) stayArmed.current.delete(id);
2823
+ // ONLY A KNOWN ANSWER MAY DROP A SNAPSHOT. A frame in which the page could
2824
+ // not name any session (a panel unmounting the conversation while the
2825
+ // official binding is unavailable, a DOM gap, a build without either
2826
+ // signal) is not a leave: forgetting the snapshot there is what made the
2827
+ // clock re-enter the session behind the reader's back and acknowledge an
2828
+ // ending that arrived while they were sitting in front of it.
2829
+ if (stayKnown !== false) {
2830
+ const live = new Set(stayIds);
2831
+ for (const id of Array.from(stayArmed.current.keys())) {
2832
+ if (!live.has(id)) stayArmed.current.delete(id);
2833
+ }
2656
2834
  }
2657
2835
  for (const id of stayIds) {
2658
2836
  if (!stayArmed.current.has(id)) stayArmed.current.set(id, lastTurnEndAt(snapshot[id]));
@@ -2681,7 +2859,7 @@ window.__ModuleLoader__.load({
2681
2859
  cancelled = true;
2682
2860
  if (timer !== 0) window.clearTimeout(timer);
2683
2861
  };
2684
- }, [staySignature, delayMs, watching]);
2862
+ }, [staySignature, delayMs, watching, stayKnown]);
2685
2863
  React.useEffect(() => { emitRealBadge(count, itemsWithOwner); }, [count, itemsWithOwner]);
2686
2864
  /**
2687
2865
  * One session's displayed title, the way the sidebar shows it.
@@ -3783,6 +3961,18 @@ window.__ModuleLoader__.load({
3783
3961
  // Setting it here (not at module scope) is what keeps the store usable in a
3784
3962
  // test harness with no connection: pushes stay no-ops, the badge still works.
3785
3963
  seenRpc = rpc;
3964
+ // The official "Session on screen" binding, looked up lazily so a build
3965
+ // without it (or a plugin loaded before ui-session) simply answers nothing
3966
+ // and the DOM/retention fallbacks keep the behaviour this file shipped with.
3967
+ officialSessionId = () => {
3968
+ try {
3969
+ const service = ctx.get("uiSession");
3970
+ const current = service !== null && service !== undefined ? service.adapter?.current : undefined;
3971
+ const value = current !== null && current !== undefined && typeof current.getSnapshot === "function" ? current.getSnapshot() : undefined;
3972
+ const key = value !== null && typeof value === "object" ? value.key : undefined;
3973
+ return typeof key === "string" && key !== "" ? key : undefined;
3974
+ } catch { return undefined; }
3975
+ };
3786
3976
  // Pull the host state once, so the brand mark and the badge are correct
3787
3977
  // before the settings section is ever opened. The unread rule rides its
3788
3978
  // own endpoint: the icon RPCs keep their pre-badge response shape.
@@ -3928,10 +4118,12 @@ window.__ModuleLoader__.load({
3928
4118
  // value that is already on the bridge. The brand mark reads it at
3929
4119
  // mount; the tab icon has to be told, or a plugin hot-reload would
3930
4120
  // leave it restored (the old effect's cleanup) until the next change.
3931
- composeFavicon(effectiveCount(), badgeSize);
4121
+ composeFavicon(effectiveCount(), badgeSize, true);
3932
4122
  return () => {
3933
4123
  unsubscribe();
3934
- composeFavicon(0);
4124
+ // Forced: the promise is that unloading this half leaves the page's
4125
+ // own icon behind, whatever this page load had drawn before.
4126
+ composeFavicon(0, badgeSize, true);
3935
4127
  };
3936
4128
  }, "dsh-icon-custom: favicon badge");
3937
4129
  // Watchdog. The tab icon is the one surface other parties can disturb
@@ -4015,8 +4207,9 @@ window.__ModuleLoader__.load({
4015
4207
  };
4016
4208
  }, "dsh-icon-custom: workspace dots");
4017
4209
  // The platform favicon.svg repaints itself for dark mode through
4018
- // prefers-color-scheme; a composited bitmap cannot, so rebuild it.
4019
- ctx.on("theme/change", () => { composeFavicon(effectiveCount(), badgeSize); });
4210
+ // prefers-color-scheme; a composited bitmap cannot, so rebuild it —
4211
+ // forced, because the base it was built from is exactly what changed.
4212
+ ctx.on("theme/change", () => { composeFavicon(effectiveCount(), badgeSize, true); });
4020
4213
  }
4021
4214
  exports.apply = apply;
4022
4215
  exports.inject = inject;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cowwo/dsh-icon-custom",
3
- "version": "0.19.0",
3
+ "version": "0.19.1",
4
4
  "description": "自定义浏览器标签页图标:在设置页上传 SVG/PNG/ICO,立即生效并持久保存,自动适配安卓/iOS/平板 PWA 图标,显示未读红点与进行中黄点(工作区数字/会话行/左上角/标签页,数字可点开清单、点条目定位到侧栏),并可把未读数显示为系统应用图标角标(Windows 任务栏 / macOS Dock,含通知权限申请)。· Custom browser tab favicon: upload SVG/PNG/ICO from the settings page, applies instantly and persists, auto-sizes PWA icons for Android/iOS/tablets, shows unread red dots and running yellow marks (workspace number / session row / top-left / tab icon, both counts open a list whose rows locate the session in the sidebar), and projects the unread count onto the system app-icon badge (Windows taskbar / macOS Dock, notifications permission included).",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",