@cowwo/dsh-icon-custom 0.10.1 → 0.10.2

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
@@ -60,6 +60,14 @@ window.__ModuleLoader__.load({
60
60
  unreadWaiting: "在等你",
61
61
  unreadEnded: "刚结束,还没看",
62
62
  unreadPanelOther: "其他",
63
+ unreadMarkAllRead: "全部标记已读",
64
+ /** 与官方侧栏同一形状("13小时"),方便和列表里的时间戳逐字对上。 */
65
+ ageNow: "刚刚",
66
+ ageMinutes: "{n}分钟",
67
+ ageHours: "{n}小时",
68
+ ageDays: "{n}天",
69
+ ageMonths: "{n}个月",
70
+ ageYears: "{n}年",
63
71
  workspaceDotLabel: "在工作区和会话行上显示红点(实验)",
64
72
  workspaceDotHint: "在侧栏工作区那一行的文件夹图标上点一个小红点。这一项是贴着页面结构做的,DSH 升级后可能失效;失效时只会变成\"不显示\",不会影响红点数字、清单、跳转这些功能,随时可以关掉它。",
65
73
  badgeTestHint: "真实未读 = 有会话发生了上面勾选的情况、而且你还没看过它(打开该会话即视为已读)。你正开着的会话也算——窗口可能被最小化、页面可能切到后台,这里无从判断你在不在看,所以不做这个区分。只有子代理不计。手动模式:自己填数字试看效果。刷新页面后回到真实未读。",
@@ -118,6 +126,14 @@ window.__ModuleLoader__.load({
118
126
  unreadWaiting: "waiting for you",
119
127
  unreadEnded: "ended, not seen yet",
120
128
  unreadPanelOther: "Other",
129
+ unreadMarkAllRead: "Mark all read",
130
+ /** Same shape as the shipped sidebar ("13h"), so the two can be read side by side. */
131
+ ageNow: "now",
132
+ ageMinutes: "{n}min",
133
+ ageHours: "{n}h",
134
+ ageDays: "{n}d",
135
+ ageMonths: "{n}mo",
136
+ ageYears: "{n}y",
121
137
  workspaceDotLabel: "Dots on workspace and session rows (experimental)",
122
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.",
123
139
  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.",
@@ -272,21 +288,67 @@ window.__ModuleLoader__.load({
272
288
  return () => { unreadConfigListeners.delete(fn); };
273
289
  }
274
290
 
275
- const SEEN_STORE_KEY = "dsh-icon-custom.unread-seen.v1";
291
+ /**
292
+ * The seen watermark lives in the HOST's clock domain.
293
+ *
294
+ * A turn end arrives stamped with the Host's `turn/end.time`; a mark written
295
+ * from the browser's `Date.now()` only lines up while both clocks agree. A
296
+ * browser a few seconds behind the Host therefore wrote marks that never
297
+ * reached `endAt`, so the dot could not be cleared by entering the session at
298
+ * all — the one failure users report as "点进去也不消". Marks now store the
299
+ * `endAt` they acknowledge, and `lastActiveAt` stores a host-domain time, so
300
+ * every comparison in `collectUnread` stays inside one clock.
301
+ */
302
+ const SEEN_STORE_KEY = "dsh-icon-custom.unread-seen.v2";
303
+ /** 0.10.1 and earlier wrote browser timestamps here; migrated once. */
304
+ const LEGACY_SEEN_STORE_KEY = "dsh-icon-custom.unread-seen.v1";
276
305
  const SEEN_MAX = 400;
306
+ /** Host clock minus browser clock, from the last Host sample; null until known. */
307
+ let hostClockOffset = null;
308
+ /** This browser's clock expressed in the Host's domain (best effort). */
309
+ function hostNow() {
310
+ return Date.now() + (hostClockOffset === null ? 0 : hostClockOffset);
311
+ }
277
312
  function loadSeenState() {
278
313
  try {
279
314
  const raw = JSON.parse(window.localStorage.getItem(SEEN_STORE_KEY) || "null");
280
- if (raw === null || typeof raw !== "object") return { lastActiveAt: 0, seen: {} };
281
- const seen = raw.seen !== null && typeof raw.seen === "object" ? raw.seen : {};
282
- return { lastActiveAt: typeof raw.lastActiveAt === "number" ? raw.lastActiveAt : 0, seen };
283
- } catch { return { lastActiveAt: 0, seen: {} }; }
315
+ if (raw !== null && typeof raw === "object" && raw.domain === "host") {
316
+ const seen = raw.seen !== null && typeof raw.seen === "object" ? raw.seen : {};
317
+ return { domain: "host", lastActiveAt: typeof raw.lastActiveAt === "number" ? raw.lastActiveAt : 0, seen };
318
+ }
319
+ const legacy = JSON.parse(window.localStorage.getItem(LEGACY_SEEN_STORE_KEY) || "null");
320
+ if (legacy !== null && typeof legacy === "object") {
321
+ const seen = legacy.seen !== null && typeof legacy.seen === "object" ? legacy.seen : {};
322
+ return { domain: "browser", lastActiveAt: typeof legacy.lastActiveAt === "number" ? legacy.lastActiveAt : 0, seen };
323
+ }
324
+ } catch { /* fall through to an empty store */ }
325
+ return { domain: "host", lastActiveAt: 0, seen: {} };
284
326
  }
285
327
  const seenState = loadSeenState();
286
- /** When this browser last had the app open before this page load. */
328
+ /** When this browser last had the app open before this page load (host domain). */
287
329
  const seenBootAt = seenState.lastActiveAt > 0 ? seenState.lastActiveAt : 0;
288
330
  function saveSeenState() {
289
- try { window.localStorage.setItem(SEEN_STORE_KEY, JSON.stringify(seenState)); } catch {}
331
+ try {
332
+ window.localStorage.setItem(SEEN_STORE_KEY, JSON.stringify({ domain: "host", lastActiveAt: seenState.lastActiveAt, seen: seenState.seen }));
333
+ } catch {}
334
+ }
335
+ /**
336
+ * Learn the Host↔browser clock offset from a Host timestamp, and fold a
337
+ * legacy browser-domain store into the host domain. Idempotent: the fold
338
+ * happens once, on the first sample that carries a usable `hostNow`.
339
+ * @param hostAt - the Host's `Date.now()`, or anything else (ignored).
340
+ */
341
+ function noteHostClock(hostAt) {
342
+ if (typeof hostAt !== "number" || !Number.isFinite(hostAt) || hostAt <= 0) return;
343
+ const offset = hostAt - Date.now();
344
+ hostClockOffset = offset;
345
+ if (seenState.domain === "host") return;
346
+ seenState.domain = "host";
347
+ if (seenState.lastActiveAt > 0) seenState.lastActiveAt += offset;
348
+ for (const id of Object.keys(seenState.seen)) {
349
+ if (typeof seenState.seen[id] === "number") seenState.seen[id] += offset;
350
+ }
351
+ saveSeenState();
290
352
  }
291
353
  function pruneSeenState() {
292
354
  const ids = Object.keys(seenState.seen);
@@ -294,10 +356,20 @@ window.__ModuleLoader__.load({
294
356
  ids.sort((a, b) => seenState.seen[b] - seenState.seen[a]);
295
357
  for (const id of ids.slice(SEEN_MAX)) delete seenState.seen[id];
296
358
  }
297
- /** Record that this browser is looking at a session right now. */
298
- function noteSeen(sessionId) {
359
+ /**
360
+ * Record that this browser has seen a session up to `seenAt` — the
361
+ * host-domain `endAt` being acknowledged. Marks never move backwards, so a
362
+ * late attempt cannot undo a newer one.
363
+ * @param sessionId - the session that was looked at.
364
+ * @param seenAt - host-domain watermark to store; defaults to the Host's now.
365
+ * @returns whether the mark actually advanced.
366
+ */
367
+ function noteSeen(sessionId, seenAt) {
299
368
  if (typeof sessionId !== "string" || sessionId === "") return false;
300
- seenState.seen[sessionId] = Date.now();
369
+ const mark = typeof seenAt === "number" && Number.isFinite(seenAt) ? seenAt : hostNow();
370
+ const previous = seenState.seen[sessionId];
371
+ if (typeof previous === "number" && previous >= mark) return false;
372
+ seenState.seen[sessionId] = mark;
301
373
  return true;
302
374
  }
303
375
  /**
@@ -307,7 +379,7 @@ window.__ModuleLoader__.load({
307
379
  * @returns whether anything was learned (callers persist only then).
308
380
  */
309
381
  function ensureSeenBaseline(ids) {
310
- const fallback = seenBootAt > 0 ? seenBootAt : Date.now();
382
+ const fallback = seenBootAt > 0 ? seenBootAt : hostNow();
311
383
  let changed = false;
312
384
  for (const id of ids) {
313
385
  if (typeof id !== "string" || id === "") continue;
@@ -318,30 +390,46 @@ window.__ModuleLoader__.load({
318
390
  return changed;
319
391
  }
320
392
  /**
321
- * The session the main view is showing.
393
+ * Every session the main view is showing.
394
+ *
395
+ * The official signal is the row's `retainedBy.mainView` retention count, the
396
+ * same one ui-layout's DocumentTitle, ui-cordis, ui-open-in-app and ui-session
397
+ * read (`list.current` is honoured first for runtimes that expose it).
322
398
  *
323
- * DSH 0.2.x carries NO `current` field in the session-list snapshot — it is
324
- * written with exactly `ids` / `byId` / `phase` / `projectionsBySession`. The
325
- * official signal is the row's `retainedBy.mainView` retention count, the same
326
- * one ui-layout's DocumentTitle, ui-cordis, ui-open-in-app and ui-session read.
327
- * Reading `list.current` here made `noteSeen()` a no-op, so the seen watermark
328
- * never advanced and an unread dot could never be cleared by opening the
329
- * session. Runtimes that do expose `current` are still honoured first.
399
+ * It returns ALL of them, not one. The pane retains the incoming session
400
+ * BEFORE it releases the outgoing one, and a retention that outlives its pane
401
+ * can leave an old session looking "current" for good — and picking a single
402
+ * id then meant the session you were actually reading was never marked read:
403
+ * the dot stayed red however long you stayed in it. Each retained session
404
+ * gets its own stay clock instead.
330
405
  * @param list - the session list snapshot (may be absent).
331
- * @returns the id being viewed, or undefined.
406
+ * @returns the ids being viewed, in list order; `[]` when there are none.
332
407
  */
333
- function currentSessionId(list) {
334
- if (list === null || typeof list !== "object") return undefined;
335
- if (typeof list.current === "string" && list.current !== "") return list.current;
408
+ function currentSessionIds(list) {
409
+ if (list === null || typeof list !== "object") return [];
410
+ if (typeof list.current === "string" && list.current !== "") return [list.current];
336
411
  const byId = list.byId;
337
- if (byId === null || typeof byId !== "object") return undefined;
412
+ if (byId === null || typeof byId !== "object") return [];
413
+ const ids = [];
338
414
  for (const session of Object.values(byId)) {
339
415
  if (session === null || typeof session !== "object") continue;
340
416
  const retainedBy = session.retainedBy;
341
417
  const count = retainedBy !== null && typeof retainedBy === "object" ? retainedBy.mainView : undefined;
342
- if (typeof count === "number" && count > 0) return session.id;
418
+ if (typeof count === "number" && count > 0 && typeof session.id === "string" && session.id !== "") ids.push(session.id);
343
419
  }
344
- return undefined;
420
+ return ids;
421
+ }
422
+ /**
423
+ * One session's last `turn/end` timestamp (host domain), or 0 when it has
424
+ * none. The projection is what the Host folded; an unknown reason still
425
+ * carries the time.
426
+ * @param entry - the session-list row (may be absent).
427
+ * @returns the epoch milliseconds, or 0.
428
+ */
429
+ function lastTurnEndAt(entry) {
430
+ const values = entry !== null && typeof entry === "object" ? entry.projectionValues : undefined;
431
+ const value = values !== null && typeof values === "object" ? values[LAST_TURN_END_KEY] : undefined;
432
+ return value !== null && typeof value === "object" && typeof value.endAt === "number" ? value.endAt : 0;
345
433
  }
346
434
  /**
347
435
  * What the "stay here long enough" timer is armed for, or "" when nothing
@@ -360,11 +448,7 @@ window.__ModuleLoader__.load({
360
448
  function stayKeyFor(sessionId, list) {
361
449
  if (typeof sessionId !== "string" || sessionId === "") return "";
362
450
  const byId = list !== null && typeof list === "object" && list.byId !== null && typeof list.byId === "object" ? list.byId : {};
363
- const entry = byId[sessionId];
364
- const values = entry !== null && typeof entry === "object" ? entry.projectionValues : undefined;
365
- const value = values !== null && typeof values === "object" ? values[LAST_TURN_END_KEY] : undefined;
366
- const endAt = value !== null && typeof value === "object" && typeof value.endAt === "number" ? value.endAt : 0;
367
- return `${sessionId}\u0000${endAt}`;
451
+ return `${sessionId}\u0000${lastTurnEndAt(byId[sessionId])}`;
368
452
  }
369
453
  /**
370
454
  * How many sessions deserve the badge right now: endings whose reason is
@@ -724,6 +808,19 @@ window.__ModuleLoader__.load({
724
808
  unreadPanelListeners.add(fn);
725
809
  return () => { unreadPanelListeners.delete(fn); };
726
810
  }
811
+ /**
812
+ * Tells the badge source that the marks moved somewhere it did not touch —
813
+ * the panel's "mark all read". It must re-render to re-emit the number (which
814
+ * also repaints the row dots and rebuilds the tab badge).
815
+ */
816
+ const unreadPokeListeners = new Set();
817
+ function emitUnreadPoke() {
818
+ unreadPokeListeners.forEach((fn) => { try { fn(); } catch {} });
819
+ }
820
+ function subscribeUnreadPoke(fn) {
821
+ unreadPokeListeners.add(fn);
822
+ return () => { unreadPokeListeners.delete(fn); };
823
+ }
727
824
  function toggleUnreadPanel(anchor) {
728
825
  emitUnreadPanel(!unreadPanelOpen, anchor === undefined ? null : anchor);
729
826
  }
@@ -773,6 +870,97 @@ window.__ModuleLoader__.load({
773
870
  }
774
871
  return groups;
775
872
  }
873
+ /**
874
+ * Age buckets, in the shipped sidebar's own units.
875
+ *
876
+ * Same thresholds as the official `relativeTime`: under a minute "now", then
877
+ * minutes / hours / days / months / years. Deliberately copied instead of
878
+ * imported — a display plugin must not depend on a Harness Client package —
879
+ * but the SHAPE has to match the sidebar's, because the whole point of
880
+ * showing it here is that a reader can line the panel's "13小时" up with the
881
+ * timestamp on the row itself.
882
+ * @param at - host-domain epoch milliseconds.
883
+ * @param now - host-domain now.
884
+ * @returns `{ unit, n }`, `unit` in `now|minutes|hours|days|months|years`.
885
+ */
886
+ function relativeAgeParts(at, now) {
887
+ const MINUTE = 60000, HOUR = 3600000, DAY = 86400000;
888
+ const diff = Math.max(0, now - at);
889
+ if (diff < MINUTE) return { unit: "now", n: 0 };
890
+ if (diff < HOUR) return { unit: "minutes", n: Math.floor(diff / MINUTE) };
891
+ if (diff < DAY) return { unit: "hours", n: Math.floor(diff / HOUR) };
892
+ if (diff < 30 * DAY) return { unit: "days", n: Math.floor(diff / DAY) };
893
+ if (diff < 365 * DAY) return { unit: "months", n: Math.floor(diff / (30 * DAY)) };
894
+ return { unit: "years", n: Math.floor(diff / (365 * DAY)) };
895
+ }
896
+ /**
897
+ * One ending's age as text ("13小时" / "13h"), or "" when there is no time.
898
+ * @param at - host-domain epoch milliseconds, 0/absent for "unknown".
899
+ * @param now - host-domain now.
900
+ * @param t - locale binder.
901
+ * @returns the label.
902
+ */
903
+ function unreadAgeLabel(at, now, t) {
904
+ if (typeof at !== "number" || !Number.isFinite(at) || at <= 0) return "";
905
+ const { unit, n } = relativeAgeParts(at, now);
906
+ // Same shape as the shipped sidebar's own label: the "now" bucket carries
907
+ // no number.
908
+ return unit === "now" ? t("ageNow") : t(`age${unit[0].toUpperCase()}${unit.slice(1)}`, { n });
909
+ }
910
+ /**
911
+ * A short, unambiguous handle for a session: the first 8 characters of its id.
912
+ *
913
+ * Two sessions can share a title — that is precisely what made one red dot look
914
+ * unclearable, because the row could not be told apart from its namesake — and
915
+ * this is what distinguishes them when the titles do not.
916
+ * @param id - the session id.
917
+ * @returns the short form, or "".
918
+ */
919
+ function shortSessionId(id) {
920
+ if (typeof id !== "string" || id === "") return "";
921
+ const bare = id.startsWith("session-") ? id.slice("session-".length) : id;
922
+ return bare.slice(0, 8);
923
+ }
924
+ /**
925
+ * The dim second line of one panel row: why it is listed, how long ago that
926
+ * was, and which session it is.
927
+ * @param item - a row from `collectUnread`.
928
+ * @param now - host-domain now.
929
+ * @param t - locale binder.
930
+ * @returns the line, already joined.
931
+ */
932
+ function unreadRowSubtitle(item, now, t) {
933
+ const parts = [item.waiting === true ? t("unreadWaiting") : t("unreadEnded")];
934
+ // A "waiting" row's `at` is Date.now() (it has no ending of its own), so only
935
+ // turn-end rows carry a readable age.
936
+ if (item.waiting !== true) {
937
+ const age = unreadAgeLabel(item.at, now, t);
938
+ if (age !== "") parts.push(age);
939
+ }
940
+ const short = shortSessionId(item.id);
941
+ if (short !== "") parts.push(short);
942
+ return parts.join(" · ");
943
+ }
944
+ /**
945
+ * What "mark all read" may actually acknowledge: the turn-end rows.
946
+ *
947
+ * A "waiting for you" row is not watermark-based at all — it is listed while
948
+ * that interaction is live, and no mark can clear it — so the caller must
949
+ * leave it alone.
950
+ * @param items - rows from `collectUnread`.
951
+ * @returns `[{ id, seenAt }]`, each carrying the `endAt` it acknowledges.
952
+ */
953
+ function unreadAckTargets(items) {
954
+ const targets = [];
955
+ for (const item of Array.isArray(items) ? items : []) {
956
+ if (item === null || typeof item !== "object") continue;
957
+ if (item.waiting === true) continue;
958
+ if (typeof item.id !== "string" || item.id === "") continue;
959
+ if (typeof item.at !== "number" || !Number.isFinite(item.at) || item.at <= 0) continue;
960
+ targets.push({ id: item.id, seenAt: item.at });
961
+ }
962
+ return targets;
963
+ }
776
964
  //#endregion
777
965
 
778
966
  //#region workspace dots
@@ -834,6 +1022,11 @@ window.__ModuleLoader__.load({
834
1022
  if (workspaceDotFailures < WS_MAX_FAILURES || workspaceDotGivenUp) return;
835
1023
  workspaceDotGivenUp = true;
836
1024
  try { console.warn("dsh-icon-custom: 工作区红点连续失败,本次会话内已自动关闭,可在设置里关掉这一项。"); } catch {}
1025
+ // Take the markers OUT rather than leaving them frozen: a stale red dot
1026
+ // that no longer reflects the rule (or that can never be cleared) is worse
1027
+ // than no marker at all, and "breaks down to not showing" is this feature's
1028
+ // stated posture.
1029
+ clearWorkspaceDots();
837
1030
  }
838
1031
  /** The marker already sitting in this host for that key, or null. */
839
1032
  function existingMarker(host, attribute, key) {
@@ -898,6 +1091,38 @@ window.__ModuleLoader__.load({
898
1091
  if (rect.height < WS_ROW_MIN_H || rect.height > WS_ROW_MAX_H || rect.width < WS_ROW_MIN_W) return null;
899
1092
  return { title: label, row };
900
1093
  }
1094
+ /**
1095
+ * The DOM row of one session, by the official row key.
1096
+ *
1097
+ * ui-workspace stamps every row with `data-row-key` (`session:<id>`,
1098
+ * `workspace:<id>`, `overflow:<id>`), which is the only stable, unambiguous
1099
+ * handle on a row: titles are neither unique (two sessions can share one) nor
1100
+ * stable (a generated title lands later). Matching by title text used to find
1101
+ * two rows and refuse, so a session whose title collided with another's could
1102
+ * never be marked at all.
1103
+ * @param container - the browsing region root.
1104
+ * @param id - the session id.
1105
+ * @returns the row element, or null.
1106
+ */
1107
+ function sessionRowElement(container, id) {
1108
+ if (typeof id !== "string" || id === "") return null;
1109
+ try { return container.querySelector(`[data-row-key="session:${id}"]`); } catch { return null; }
1110
+ }
1111
+ /**
1112
+ * Where one session's marker goes: the tight title holder INSIDE that
1113
+ * session's own row. The row is found by `data-row-key`; the title inside it
1114
+ * 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.
1116
+ * @returns `{ title, row }`, or null when this row cannot be trusted.
1117
+ */
1118
+ function sessionRowTarget(container, id, title) {
1119
+ const row = sessionRowElement(container, id);
1120
+ if (row !== null) {
1121
+ const inside = labelElementFor(row, title);
1122
+ return inside === null ? null : titleTargetFor(inside, title);
1123
+ }
1124
+ return titleTargetFor(labelElementFor(container, title), title);
1125
+ }
901
1126
  function paintWorkspaceDots() {
902
1127
  if (workspaceDotGivenUp) return;
903
1128
  const enabled = unreadConfig.workspaceDot !== false;
@@ -950,8 +1175,7 @@ window.__ModuleLoader__.load({
950
1175
  // Session rows: the marker rides inline right after the title, so it stays
951
1176
  // beside the name whatever the timestamp happens to say.
952
1177
  for (const entry of wantedRows) {
953
- const label = labelElementFor(container, entry.title);
954
- const target = titleTargetFor(label, entry.title);
1178
+ const target = sessionRowTarget(container, entry.id, entry.title);
955
1179
  if (target === null) { missed++; continue; }
956
1180
  if (existingMarker(target.row, ROW_DOT_ATTR, entry.id) !== null) { placed++; continue; }
957
1181
  const dot = document.createElement("span");
@@ -1031,26 +1255,67 @@ window.__ModuleLoader__.load({
1031
1255
  pruneSeenState();
1032
1256
  saveSeenState();
1033
1257
  }, [list]);
1034
- const currentId = currentSessionId(list);
1258
+ const currentIds = currentSessionIds(list);
1035
1259
  const items = collectUnread(list, pending, config, seenState.seen);
1036
1260
  const count = items.length;
1037
1261
  // Being in a session is what marks it read — but only once you have STAYED
1038
1262
  // `clearDelaySec` seconds (0 = the moment you enter, the original
1039
- // behaviour). The timer is torn down whenever you leave, or whenever that
1263
+ // behaviour). A clock is torn down whenever you leave, or whenever that
1040
1264
  // session becomes unread again from a fresh ending, so the clock restarts;
1041
1265
  // that teardown IS the "continuous stay, leaving resets it" rule. A reload
1042
- // restarts it too, since the timer only lives in this page.
1043
- const currentUnread = items.some((item) => item.id === currentId);
1044
- const stayKey = currentUnread ? stayKeyFor(currentId, list) : "";
1266
+ // restarts it too, since the timers only live in this page.
1267
+ //
1268
+ // One clock per session the main view shows: with two retained sessions,
1269
+ // 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("|");
1045
1275
  const delayMs = config.clearDelaySec * 1000;
1046
1276
  const [, bumpStay] = React.useState(0);
1277
+ // The panel can move the marks without going through this component
1278
+ // ("mark all read"); re-render so the number, the row dots and the tab
1279
+ // badge all follow immediately.
1280
+ const [, bumpPoke] = React.useState(0);
1281
+ 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.
1284
+ const stayLive = React.useRef({ list });
1285
+ stayLive.current = { list };
1047
1286
  React.useEffect(() => {
1048
- if (stayKey === "") return undefined;
1049
- const markRead = () => { if (noteSeen(currentId)) { saveSeenState(); bumpStay((n) => n + 1); } };
1050
- if (delayMs <= 0) { markRead(); return undefined; }
1051
- const timer = window.setTimeout(markRead, delayMs);
1052
- return () => window.clearTimeout(timer);
1053
- }, [stayKey, currentId, delayMs]);
1287
+ if (staySignature === "") return undefined;
1288
+ const ids = staySignature.split("|").map((key) => key.slice(0, key.indexOf("\u0000")));
1289
+ let cancelled = false;
1290
+ let timer = 0;
1291
+ const attempt = () => {
1292
+ 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
+ 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;
1303
+ }
1304
+ 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
+ };
1312
+ if (delayMs <= 0) attempt();
1313
+ else timer = window.setTimeout(attempt, delayMs);
1314
+ return () => {
1315
+ cancelled = true;
1316
+ if (timer !== 0) window.clearTimeout(timer);
1317
+ };
1318
+ }, [staySignature, delayMs]);
1054
1319
  React.useEffect(() => { emitRealBadge(count, items); }, [count, items]);
1055
1320
  // The workspace rows are labelled with the workspace TITLE, which is not
1056
1321
  // always the cwd basename, so map session → title from the official snapshot.
@@ -1187,6 +1452,9 @@ window.__ModuleLoader__.load({
1187
1452
  if (headerBadgeFailures < HEAD_BADGE_MAX_FAILURES || headerBadgeGivenUp) return;
1188
1453
  headerBadgeGivenUp = true;
1189
1454
  try { console.warn("dsh-icon-custom: 工作区标题红点连续失败,本次会话内已自动关闭。"); } catch {}
1455
+ // Same posture as the row dots: give up by disappearing, never by freezing a
1456
+ // number that has stopped following the rule.
1457
+ clearHeaderBadge();
1190
1458
  }
1191
1459
 
1192
1460
  /**
@@ -1339,8 +1607,16 @@ window.__ModuleLoader__.load({
1339
1607
  function UnreadPopup(props) {
1340
1608
  const [snapshot, setSnapshot] = React.useState(badgeSnapshot);
1341
1609
  const [open, setOpen] = React.useState(unreadPanelOpen);
1610
+ // Ages are computed against the HOST's clock (the endings are host
1611
+ // timestamps); re-tick while open so "刚刚" does not stay stale.
1612
+ const [, tick] = React.useState(0);
1342
1613
  React.useEffect(() => subscribeBadge(setSnapshot), []);
1343
1614
  React.useEffect(() => subscribeUnreadPanel(() => setOpen(unreadPanelOpen)), []);
1615
+ React.useEffect(() => {
1616
+ if (!open) return undefined;
1617
+ const interval = window.setInterval(() => tick((n) => n + 1), 30000);
1618
+ return () => window.clearInterval(interval);
1619
+ }, [open]);
1344
1620
  React.useEffect(() => {
1345
1621
  if (!open) return undefined;
1346
1622
  const onKey = (event) => { if (event.key === "Escape") emitUnreadPanel(false); };
@@ -1374,6 +1650,22 @@ window.__ModuleLoader__.load({
1374
1650
  boxShadow: "0 10px 30px rgba(0,0,0,.18)", color: "var(--dsw-alias-label-primary)", fontSize: "12px"
1375
1651
  };
1376
1652
  const items = Array.isArray(snapshot.items) ? snapshot.items : [];
1653
+ const ackTargets = unreadAckTargets(items);
1654
+ /**
1655
+ * Acknowledge every turn-end row at once.
1656
+ *
1657
+ * "Waiting for you" rows cannot be acknowledged (they are not watermark
1658
+ * based), so the panel stays open while any of them remain — closing it
1659
+ * would hide the one thing that still needs the reader.
1660
+ */
1661
+ const markAllRead = () => {
1662
+ let changed = false;
1663
+ for (const target of ackTargets) if (noteSeen(target.id, target.seenAt)) changed = true;
1664
+ if (!changed) return;
1665
+ saveSeenState();
1666
+ emitUnreadPoke();
1667
+ if (ackTargets.length === items.length) emitUnreadPanel(false);
1668
+ };
1377
1669
  return React.createElement("div", { "data-icon-custom-unread": "1", style },
1378
1670
  React.createElement("div", { style: { fontWeight: 600, padding: "2px 6px 8px" } }, t("unreadPanelTitle")),
1379
1671
  items.length === 0
@@ -1389,10 +1681,17 @@ window.__ModuleLoader__.load({
1389
1681
  style: { display: "block", width: "100%", textAlign: "left", padding: "6px 8px", border: "0", borderRadius: "8px", background: "transparent", color: "inherit", font: "inherit", cursor: "pointer" }
1390
1682
  },
1391
1683
  React.createElement("span", { style: { display: "block", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, item.title),
1392
- React.createElement("span", { style: { display: "block", opacity: 0.6, fontSize: "11px" } }, item.waiting === true ? t("unreadWaiting") : t("unreadEnded"))
1684
+ React.createElement("span", { style: { display: "block", opacity: 0.6, fontSize: "11px" } }, unreadRowSubtitle(item, hostNow(), t))
1393
1685
  ))
1394
1686
  ))
1395
- )
1687
+ ),
1688
+ ackTargets.length > 0 && React.createElement("div", { style: { marginTop: "6px", paddingTop: "6px", borderTop: "1px solid var(--dsw-alias-border-l2)" } },
1689
+ React.createElement("button", {
1690
+ type: "button",
1691
+ onClick: markAllRead,
1692
+ style: { display: "block", width: "100%", padding: "5px 8px", border: "0", borderRadius: "8px", background: "transparent", color: "inherit", font: "inherit", cursor: "pointer", opacity: 0.85 }
1693
+ }, t("unreadMarkAllRead"))
1694
+ )
1396
1695
  );
1397
1696
  }
1398
1697
 
@@ -1853,12 +2152,21 @@ window.__ModuleLoader__.load({
1853
2152
  .then((resp) => { if (resp && resp.ok === true) syncStatus(resp.value); })
1854
2153
  .catch(() => {});
1855
2154
  rpc.call("/api", "iconCustom/getUnreadRule", { args: {} })
1856
- .then((resp) => { if (resp && resp.ok === true) emitUnreadConfig(resp.value); })
2155
+ .then((resp) => {
2156
+ if (resp === null || typeof resp !== "object" || resp.ok !== true) return;
2157
+ // The rule response carries the Host's clock; it is what puts the seen
2158
+ // 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);
2160
+ emitUnreadConfig(resp.value);
2161
+ })
1857
2162
  .catch(() => {});
1858
2163
  // Keep "the last moment this browser was here" fresh, so an ending that
1859
2164
  // happens while the app is closed still counts as unread when you return.
1860
2165
  ctx.effect(() => {
1861
- const touch = () => { seenState.lastActiveAt = Date.now(); saveSeenState(); };
2166
+ // Host domain (see the seen store): "the last moment this browser was
2167
+ // here" must be comparable with `turn/end.time`, not with the browser's
2168
+ // own clock.
2169
+ const touch = () => { seenState.lastActiveAt = hostNow(); saveSeenState(); };
1862
2170
  const interval = window.setInterval(touch, 20000);
1863
2171
  window.addEventListener("visibilitychange", touch);
1864
2172
  window.addEventListener("beforeunload", touch);
@@ -1869,6 +2177,22 @@ window.__ModuleLoader__.load({
1869
2177
  touch();
1870
2178
  };
1871
2179
  }, "dsh-icon-custom: unread watermark");
2180
+ // Re-sample the Host↔browser clock offset after a sleep/resume, which is the
2181
+ // one moment the two can drift apart mid-page. Throttled: it is one small
2182
+ // RPC per visible-again, and never on beforeunload (nothing would come back).
2183
+ ctx.effect(() => {
2184
+ let sampledAt = 0;
2185
+ const sample = () => {
2186
+ if (document.visibilityState === "hidden") return;
2187
+ if (Date.now() - sampledAt < 60000) return;
2188
+ sampledAt = Date.now();
2189
+ 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); })
2191
+ .catch(() => {});
2192
+ };
2193
+ window.addEventListener("visibilitychange", sample);
2194
+ return () => window.removeEventListener("visibilitychange", sample);
2195
+ }, "dsh-icon-custom: host clock sample");
1872
2196
  // Brand-mark seat: replaces only the whale mark (verified: a
1873
2197
  // third-party registration wins over the official occupant and the
1874
2198
  // official package's mark steps aside). The brand NAME text is a
package/lib/index.js CHANGED
@@ -459,10 +459,10 @@ export class FaviconCustomService extends TypertRemoteService {
459
459
  * icon RPCs (`getStatus` / `setIcon` / `setPwa` / `setLogo` / `resetIcon`)
460
460
  * keep their EXACT pre-badge response shape, so a problem in this feature
461
461
  * can never break the icon itself.
462
- * @returns `{ reasons, pending }`.
462
+ * @returns `{ reasons, pending, workspaceDot, clearDelaySec, hostNow }`.
463
463
  */
464
464
  async getUnreadRule() {
465
- return { reasons: this.unread.reasons, pending: this.unread.pending, workspaceDot: this.unread.workspaceDot, clearDelaySec: this.unread.clearDelaySec }
465
+ return this._unreadRuleView()
466
466
  }
467
467
 
468
468
  /**
@@ -478,7 +478,27 @@ export class FaviconCustomService extends TypertRemoteService {
478
478
  }
479
479
  this.unread = normalizeUnreadConfig(request)
480
480
  await this._saveUnread().catch(() => {}) // rule file is advisory
481
- return { reasons: this.unread.reasons, pending: this.unread.pending, workspaceDot: this.unread.workspaceDot, clearDelaySec: this.unread.clearDelaySec }
481
+ return this._unreadRuleView()
482
+ }
483
+
484
+ /**
485
+ * The rule as the client reads it, plus the Host's clock.
486
+ *
487
+ * `hostNow` is here because the badge compares the Host's `turn/end.time`
488
+ * against a watermark this browser keeps: without a Host time sample the
489
+ * browser has to guess that domain from its own clock, and a browser a few
490
+ * seconds behind the Host then clears nothing. It rides this existing
491
+ * response instead of a new endpoint, so an older client simply ignores it.
492
+ * @returns the stored rule with `hostNow`.
493
+ */
494
+ _unreadRuleView() {
495
+ return {
496
+ reasons: this.unread.reasons,
497
+ pending: this.unread.pending,
498
+ workspaceDot: this.unread.workspaceDot,
499
+ clearDelaySec: this.unread.clearDelaySec,
500
+ hostNow: Date.now()
501
+ }
482
502
  }
483
503
 
484
504
  /** typert endpoint: store a new icon uploaded as a data URL and make it active. */
@@ -54,7 +54,13 @@ const unreadRuleSchema = z.object({
54
54
  /** 实验项:工作区图标上的红点(贴产品 DOM,可一键关闭)。 */
55
55
  workspaceDot: z.boolean(),
56
56
  /** 进入会话后要连续待满多少秒才算"看过";0 = 进入即已读。上限 10 分钟。 */
57
- clearDelaySec: z.number().int().nonnegative().max(600)
57
+ clearDelaySec: z.number().int().nonnegative().max(600),
58
+ /**
59
+ * 宿主此刻的 `Date.now()`。
60
+ * 客户端用它把"看过"水位换算进宿主时钟域(`turn/end.time` 也是宿主时钟):
61
+ * 浏览器与宿主差几秒,红点就会永远清不掉。
62
+ */
63
+ hostNow: z.number()
58
64
  })
59
65
 
60
66
  const setUnreadRuleRequestSchema = z.object({
@@ -272,7 +278,7 @@ export const TYPERT = {
272
278
  {
273
279
  kind: 'method',
274
280
  name: 'getUnreadRule',
275
- signature: 'async getUnreadRule(): Promise<{ reasons: Record<string, boolean>; pending: boolean; workspaceDot: boolean; clearDelaySec: number }>',
281
+ signature: 'async getUnreadRule(): Promise<{ reasons: Record<string, boolean>; pending: boolean; workspaceDot: boolean; clearDelaySec: number; hostNow: number }>',
276
282
  description: "Read the unread-badge rule.",
277
283
  summary: "Read the unread-badge rule.",
278
284
  jsDoc: "/** Read the unread-badge rule. */",
@@ -281,7 +287,7 @@ export const TYPERT = {
281
287
  {
282
288
  kind: 'method',
283
289
  name: 'setUnreadRule',
284
- signature: 'async setUnreadRule(request: { reasons: Record<string, boolean>; pending?: boolean; workspaceDot?: boolean; clearDelaySec?: number }): Promise<{ reasons: Record<string, boolean>; pending: boolean; workspaceDot: boolean; clearDelaySec: number }>',
290
+ signature: 'async setUnreadRule(request: { reasons: Record<string, boolean>; pending?: boolean; workspaceDot?: boolean; clearDelaySec?: number }): Promise<{ reasons: Record<string, boolean>; pending: boolean; workspaceDot: boolean; clearDelaySec: number; hostNow: number }>',
285
291
  description: "Replace the unread-badge rule: which turn-end reasons light the red dot.",
286
292
  summary: "Set the unread-badge rule.",
287
293
  jsDoc: "/** Set the unread-badge rule. */",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cowwo/dsh-icon-custom",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "description": "自定义浏览器标签页图标:在设置页上传 SVG/PNG/ICO,立即生效并持久保存,自动适配安卓/iOS/平板 PWA 图标。· Custom browser tab favicon: upload SVG/PNG/ICO from the settings page, applies instantly and persists, auto-sizes PWA icons for Android/iOS/tablets.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",