@cowwo/dsh-icon-custom 0.9.2 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.zh.md CHANGED
@@ -59,7 +59,8 @@ SVG 会在浏览器里先栅格化成 512×512 PNG 再上传,ICO 则由后端提
59
59
 
60
60
  - 数字 = **几个会话**在等你(不是几件事);同一个会话只占一格。
61
61
  - 只有子代理会话不计。**你正开着的那个会话也算**——窗口可能被最小化、页面可能切到后台,这里无从判断你在不在看,所以不做这个区分;要清掉它,切走再切回来即可。
62
- - **已读记在每个浏览器自己身上**(localStorage):打开过 = 处理过,刷新/重启不会"复活";装插件之前的历史结束也不算。
62
+ - **已读记在每个浏览器自己身上**(localStorage):进过 = 处理过,刷新/重启不会"复活";装插件之前的历史结束也不算。
63
+ - **可以要求"待够才算已读"**:设置页填一个秒数(默认 0)。填 0 = 进去就算已读(红点立即消失);填 N = 你要在那个会话里**连续待满 N 秒**它才算看过——中途切走、或它又跑完一轮,都清零重数,刷新页面也重新数。上限 600 秒。
63
64
  - 数据全部来自官方:`turn/end` 事件经官方 session projection 折叠,加上官方的"等待交互"注册表——**不依赖任何其他插件**。
64
65
  - 设置页的**数据来源**可以切到"手动测试",手填数字试看效果。
65
66
  - **工作区和会话行上的红点(实验)**:侧栏每个**工作区**那一行的文件夹图标右上角一个小红点,每个**有未读的会话**在标题右边跟一个小红点——一眼看出"事情在哪个工作区的哪个会话"。这一项是**贴着页面结构**做的——DSH 大版本升级后**可能失效**,失效时只会变成"不显示",**不影响**红点数字、清单和跳转;设置页里有一个开关可以随时关掉它。
package/client/client.js CHANGED
@@ -62,7 +62,10 @@ window.__ModuleLoader__.load({
62
62
  unreadPanelOther: "其他",
63
63
  workspaceDotLabel: "在工作区和会话行上显示红点(实验)",
64
64
  workspaceDotHint: "在侧栏工作区那一行的文件夹图标上点一个小红点。这一项是贴着页面结构做的,DSH 升级后可能失效;失效时只会变成\"不显示\",不会影响红点数字、清单、跳转这些功能,随时可以关掉它。",
65
- badgeTestHint: "真实未读 = 有会话发生了上面勾选的情况、而且你还没看过它(打开该会话即视为已读)。你正开着的会话也算——窗口可能被最小化、页面可能切到后台,这里无从判断你在不在看,所以不做这个区分。只有子代理不计。手动模式:自己填数字试看效果。刷新页面后回到真实未读。"
65
+ badgeTestHint: "真实未读 = 有会话发生了上面勾选的情况、而且你还没看过它(打开该会话即视为已读)。你正开着的会话也算——窗口可能被最小化、页面可能切到后台,这里无从判断你在不在看,所以不做这个区分。只有子代理不计。手动模式:自己填数字试看效果。刷新页面后回到真实未读。",
66
+ clearDelayLabel: "进入会话后多久算已读:",
67
+ clearDelayUnit: "秒",
68
+ clearDelayHint: "填 0 = 进去就算已读(红点立即消失)。大于 0 时,你要在那个会话里连续待满这么多秒它才算看过:中途切走、或它又跑完一轮,都会清零重数;刷新页面也重新数。上限 600 秒。"
66
69
  };
67
70
  const en = {
68
71
  nav: "Favicon",
@@ -117,7 +120,10 @@ window.__ModuleLoader__.load({
117
120
  unreadPanelOther: "Other",
118
121
  workspaceDotLabel: "Dots on workspace and session rows (experimental)",
119
122
  workspaceDotHint: "Adds a small red dot to the folder icon of each workspace row. This one reads the page structure, so a DSH upgrade may break it; when it does it simply stops showing, never affecting the counts, the list, or navigation. Turn it off any time.",
120
- badgeTestHint: "Real unread = a session ended for one of the checked reasons and you have not looked at it yet (opening a session marks it read). The session you are viewing 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."
123
+ 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.",
124
+ clearDelayLabel: "Mark read after staying:",
125
+ clearDelayUnit: "seconds",
126
+ 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."
121
127
  };
122
128
  //#endregion
123
129
 
@@ -231,8 +237,18 @@ window.__ModuleLoader__.load({
231
237
  };
232
238
  /** Mirror of the host defaults; only used before the first status arrives. */
233
239
  const UNREAD_FALLBACK_REASONS = { completed: true, error: true, blocked: true, "max-tokens": true, interrupted: true, "aborted:user": false, "aborted:other": true };
234
- let unreadConfig = { reasons: { ...UNREAD_FALLBACK_REASONS }, pending: true, workspaceDot: true };
240
+ /** Mirror of the host's cap (see lib/unread.js) — 10 minutes. */
241
+ const UNREAD_CLEAR_DELAY_MAX_SEC = 600;
242
+ let unreadConfig = { reasons: { ...UNREAD_FALLBACK_REASONS }, pending: true, workspaceDot: true, clearDelaySec: 0 };
235
243
  const unreadConfigListeners = new Set();
244
+ /** Mirror of the host's normalizer: any input becomes an integer 0..cap seconds. */
245
+ function normalizeClearDelay(input) {
246
+ const value = typeof input === "string" && input.trim() !== "" ? Number(input) : input;
247
+ if (typeof value !== "number" || !Number.isFinite(value)) return 0;
248
+ const seconds = Math.floor(value);
249
+ if (seconds <= 0) return 0;
250
+ return Math.min(seconds, UNREAD_CLEAR_DELAY_MAX_SEC);
251
+ }
236
252
  function normalizeUnreadConfig(input) {
237
253
  const source = input !== null && typeof input === "object" ? input : {};
238
254
  const raw = source.reasons !== null && typeof source.reasons === "object" ? source.reasons : {};
@@ -243,7 +259,8 @@ window.__ModuleLoader__.load({
243
259
  return {
244
260
  reasons,
245
261
  pending: typeof source.pending === "boolean" ? source.pending : true,
246
- workspaceDot: typeof source.workspaceDot === "boolean" ? source.workspaceDot : true
262
+ workspaceDot: typeof source.workspaceDot === "boolean" ? source.workspaceDot : true,
263
+ clearDelaySec: normalizeClearDelay(source.clearDelaySec)
247
264
  };
248
265
  }
249
266
  function emitUnreadConfig(input) {
@@ -326,6 +343,29 @@ window.__ModuleLoader__.load({
326
343
  }
327
344
  return undefined;
328
345
  }
346
+ /**
347
+ * What the "stay here long enough" timer is armed for, or "" when nothing
348
+ * should be armed.
349
+ *
350
+ * Keyed on the session's own `lastTurnEnd.endAt`, deliberately NOT on the
351
+ * collected item's `at`: a "waiting for you" item carries `at: Date.now()`,
352
+ * which changes on every evaluation — keying on that would tear the timer
353
+ * down and re-arm it forever, and a waiting session could never be marked
354
+ * read. Including `endAt` is also what makes a NEW ending restart the clock
355
+ * instead of inheriting the previous one's progress.
356
+ * @param sessionId - the session being viewed.
357
+ * @param list - the session list snapshot.
358
+ * @returns the key, or "" when there is no session to arm for.
359
+ */
360
+ function stayKeyFor(sessionId, list) {
361
+ if (typeof sessionId !== "string" || sessionId === "") return "";
362
+ 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}`;
368
+ }
329
369
  /**
330
370
  * How many sessions deserve the badge right now: endings whose reason is
331
371
  * enabled and that this browser has not seen since, plus sessions waiting
@@ -991,13 +1031,26 @@ window.__ModuleLoader__.load({
991
1031
  pruneSeenState();
992
1032
  saveSeenState();
993
1033
  }, [list]);
994
- // Looking at a session marks it seen; the count also excludes it outright.
995
1034
  const currentId = currentSessionId(list);
996
- React.useEffect(() => {
997
- if (noteSeen(currentId)) saveSeenState();
998
- }, [currentId]);
999
1035
  const items = collectUnread(list, pending, config, seenState.seen);
1000
1036
  const count = items.length;
1037
+ // Being in a session is what marks it read — but only once you have STAYED
1038
+ // `clearDelaySec` seconds (0 = the moment you enter, the original
1039
+ // behaviour). The timer is torn down whenever you leave, or whenever that
1040
+ // session becomes unread again from a fresh ending, so the clock restarts;
1041
+ // 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) : "";
1045
+ const delayMs = config.clearDelaySec * 1000;
1046
+ const [, bumpStay] = React.useState(0);
1047
+ 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]);
1001
1054
  React.useEffect(() => { emitRealBadge(count, items); }, [count, items]);
1002
1055
  // The workspace rows are labelled with the workspace TITLE, which is not
1003
1056
  // always the cwd basename, so map session → title from the official snapshot.
@@ -1176,7 +1229,8 @@ window.__ModuleLoader__.load({
1176
1229
  event.preventDefault();
1177
1230
  event.stopPropagation();
1178
1231
  const rect = node.getBoundingClientRect();
1179
- toggleUnreadPanel({ left: rect.left, top: rect.top });
1232
+ // `bottom` is what the panel drops below; see unreadPanelPlacement.
1233
+ toggleUnreadPanel({ left: rect.left, top: rect.top, bottom: rect.bottom });
1180
1234
  });
1181
1235
  }
1182
1236
  const count = effectiveCount();
@@ -1231,6 +1285,52 @@ window.__ModuleLoader__.load({
1231
1285
  headerBadgeHost = null;
1232
1286
  headerBadgeHostPosition = "";
1233
1287
  }
1288
+ /** Gap between the panel and whatever it is anchored to. */
1289
+ const UNREAD_PANEL_GAP = 6;
1290
+ /** Breathing room from the window edges. */
1291
+ const UNREAD_PANEL_MARGIN = 8;
1292
+ /** The panel's practical width (between its minWidth and maxWidth below). */
1293
+ const UNREAD_PANEL_WIDTH = 300;
1294
+ /** How tall the scrolling session list may get before it scrolls. */
1295
+ const UNREAD_PANEL_LIST_MAX = 320;
1296
+
1297
+ /**
1298
+ * Where the unread panel goes, in viewport coordinates.
1299
+ *
1300
+ * It opens DOWNWARD from the badge. The original math pinned the panel's
1301
+ * BOTTOM edge just above the anchor — correct while the trigger sat in the
1302
+ * sidebar footer, and wrong the moment that trigger moved to the section
1303
+ * header: the same formula then parked the panel's bottom near the top of the
1304
+ * window and grew the panel upward, off the top of the screen (the title and
1305
+ * the first workspace heading were what got cut off). Anchoring on the
1306
+ * anchor's `bottom` is what drops it below the badge instead.
1307
+ *
1308
+ * Horizontally it stays clear of the sidebar: the panel is a frame-wide
1309
+ * overlay, and covering the sidebar — and the very badge that opened it — was
1310
+ * the other half of the complaint.
1311
+ * @param anchor - the badge's viewport rect (`{left, top, bottom}`), or null.
1312
+ * @param sidebarRight - the browsing region's right edge, or null.
1313
+ * @param viewportWidth - window width.
1314
+ * @param viewportHeight - window height.
1315
+ * @returns `{ left, top, listMaxHeight }` in viewport pixels.
1316
+ */
1317
+ function unreadPanelPlacement(anchor, sidebarRight, viewportWidth, viewportHeight) {
1318
+ let left = UNREAD_PANEL_MARGIN;
1319
+ let top = UNREAD_PANEL_MARGIN;
1320
+ if (anchor !== null && typeof anchor === "object") {
1321
+ if (typeof anchor.left === "number") left = anchor.left;
1322
+ const below = typeof anchor.bottom === "number" ? anchor.bottom : anchor.top;
1323
+ if (typeof below === "number") top = below + UNREAD_PANEL_GAP;
1324
+ }
1325
+ if (typeof sidebarRight === "number" && sidebarRight > 0) left = Math.max(left, sidebarRight + UNREAD_PANEL_GAP);
1326
+ left = Math.max(UNREAD_PANEL_MARGIN, Math.min(left, viewportWidth - UNREAD_PANEL_WIDTH - UNREAD_PANEL_MARGIN));
1327
+ top = Math.max(UNREAD_PANEL_MARGIN, top);
1328
+ // Never taller than what is left below the panel's own top, so a long list
1329
+ // scrolls inside the panel instead of pushing it off the bottom edge.
1330
+ const listMaxHeight = Math.max(120, Math.min(UNREAD_PANEL_LIST_MAX, viewportHeight - top - 72));
1331
+ return { left: Math.round(left), top: Math.round(top), listMaxHeight: Math.round(listMaxHeight) };
1332
+ }
1333
+
1234
1334
  /**
1235
1335
  * The list behind that number, in the frame-wide overlay layer so it escapes
1236
1336
  * the sidebar's clipping and scroll container. Clicking a row opens that
@@ -1259,22 +1359,26 @@ window.__ModuleLoader__.load({
1259
1359
  if (!open) return null;
1260
1360
  const t = props.t;
1261
1361
  const anchor = unreadPanelAnchor;
1362
+ // Measured, not assumed: the panel has to clear whatever width the sidebar
1363
+ // currently has (it is resizable and collapsible).
1364
+ let sidebarRight = null;
1365
+ try {
1366
+ const region = workspaceDotContainer();
1367
+ if (region !== null) sidebarRight = region.getBoundingClientRect().right;
1368
+ } catch { /* measurement is best effort; the margin fallback still applies */ }
1369
+ const place = unreadPanelPlacement(anchor, sidebarRight, window.innerWidth, window.innerHeight);
1262
1370
  const style = {
1263
- position: "fixed", left: "12px", bottom: "52px", zIndex: 60, pointerEvents: "auto",
1371
+ position: "fixed", left: place.left + "px", top: place.top + "px", zIndex: 60, pointerEvents: "auto",
1264
1372
  minWidth: "240px", maxWidth: "340px", padding: "8px", borderRadius: "12px",
1265
1373
  border: "1px solid var(--dsw-alias-border-l2)", background: "var(--dsw-alias-bg-layer-1)",
1266
1374
  boxShadow: "0 10px 30px rgba(0,0,0,.18)", color: "var(--dsw-alias-label-primary)", fontSize: "12px"
1267
1375
  };
1268
- if (anchor !== null && typeof anchor === "object") {
1269
- style.left = Math.max(8, Math.round(anchor.left)) + "px";
1270
- style.bottom = Math.max(8, Math.round(window.innerHeight - anchor.top + 6)) + "px";
1271
- }
1272
1376
  const items = Array.isArray(snapshot.items) ? snapshot.items : [];
1273
1377
  return React.createElement("div", { "data-icon-custom-unread": "1", style },
1274
1378
  React.createElement("div", { style: { fontWeight: 600, padding: "2px 6px 8px" } }, t("unreadPanelTitle")),
1275
1379
  items.length === 0
1276
1380
  ? React.createElement("div", { style: { padding: "2px 6px 8px", opacity: 0.7 } }, t("unreadPanelEmpty"))
1277
- : React.createElement("div", { style: { display: "flex", flexDirection: "column", maxHeight: "320px", overflowY: "auto" } },
1381
+ : React.createElement("div", { style: { display: "flex", flexDirection: "column", maxHeight: place.listMaxHeight + "px", overflowY: "auto" } },
1278
1382
  groupUnreadByWorkspace(items).map((group) => React.createElement("div", { key: group.where === "" ? "\u0000none" : group.where },
1279
1383
  React.createElement("div", { style: { padding: "7px 8px 3px", fontSize: "11px", fontWeight: 600, opacity: 0.55, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } },
1280
1384
  group.where === "" ? t("unreadPanelOther") : group.where
@@ -1373,6 +1477,11 @@ window.__ModuleLoader__.load({
1373
1477
  const [badgeSourceValue, setBadgeSourceValue] = React.useState(badgeSource);
1374
1478
  const [unread, setUnread] = React.useState(unreadConfig);
1375
1479
  React.useEffect(() => subscribeUnreadConfig(setUnread), []);
1480
+ // Draft text for the delay box: typing must not save on every keystroke,
1481
+ // and the box has to show the CLAMPED value once the save lands (type
1482
+ // 99999 and it settles on the 600 cap instead of lying about it).
1483
+ const [clearDelayDraft, setClearDelayDraft] = React.useState(String(unreadConfig.clearDelaySec));
1484
+ React.useEffect(() => { setClearDelayDraft(String(unread.clearDelaySec)); }, [unread.clearDelaySec]);
1376
1485
  const inputRef = React.useRef(null);
1377
1486
 
1378
1487
  const load = React.useCallback(async () => {
@@ -1430,28 +1539,35 @@ window.__ModuleLoader__.load({
1430
1539
  setBadgeSourceValue(value);
1431
1540
  emitBadgeSource(value);
1432
1541
  }, []);
1433
- // Toggle one unread reason. Applies locally at once; the host keeps the
1434
- // durable copy so the rule survives a restart and other browsers.
1435
- const onUnreadToggle = React.useCallback((key, checked) => {
1436
- const current = unreadConfig;
1437
- const reasons = Object.assign({}, current.reasons);
1438
- let pending = current.pending;
1439
- if (key === "pending") pending = checked;
1440
- else reasons[key] = checked;
1441
- const next = normalizeUnreadConfig({ reasons, pending, workspaceDot: current.workspaceDot });
1542
+ // One save path for the whole unread rule: the patch is merged onto the
1543
+ // current config, so every field is carried over by construction. Letting
1544
+ // each control build its own request is how a new field gets silently
1545
+ // dropped by whichever caller forgot it.
1546
+ const saveUnreadRule = React.useCallback((patch) => {
1547
+ const next = normalizeUnreadConfig({ ...unreadConfig, ...patch });
1442
1548
  emitUnreadConfig(next);
1443
1549
  rpc.call("/api", "iconCustom/setUnreadRule", {
1444
- args: { request: { reasons: next.reasons, pending: next.pending, workspaceDot: next.workspaceDot } }
1550
+ args: { request: { reasons: next.reasons, pending: next.pending, workspaceDot: next.workspaceDot, clearDelaySec: next.clearDelaySec } }
1445
1551
  }).catch(() => {});
1446
1552
  }, [rpc]);
1553
+ // Toggle one unread reason. Applies locally at once; the host keeps the
1554
+ // durable copy so the rule survives a restart and other browsers.
1555
+ const onUnreadToggle = React.useCallback((key, checked) => {
1556
+ if (key === "pending") { saveUnreadRule({ pending: checked }); return; }
1557
+ const reasons = Object.assign({}, unreadConfig.reasons);
1558
+ reasons[key] = checked;
1559
+ saveUnreadRule({ reasons });
1560
+ }, [saveUnreadRule]);
1447
1561
  // The fuse for the one fragile piece (see the workspace-dots region).
1448
1562
  const onWorkspaceDotToggle = React.useCallback((checked) => {
1449
- const next = normalizeUnreadConfig({ reasons: unreadConfig.reasons, pending: unreadConfig.pending, workspaceDot: checked });
1450
- emitUnreadConfig(next);
1451
- rpc.call("/api", "iconCustom/setUnreadRule", {
1452
- args: { request: { reasons: next.reasons, pending: next.pending, workspaceDot: next.workspaceDot } }
1453
- }).catch(() => {});
1454
- }, [rpc]);
1563
+ saveUnreadRule({ workspaceDot: checked });
1564
+ }, [saveUnreadRule]);
1565
+ // Seconds you must stay in a session before it counts as read; 0 = as soon
1566
+ // as you enter. Clamped by the shared normalizer, so a stray keystroke can
1567
+ // never persist an absurd delay.
1568
+ const onClearDelayChange = React.useCallback((value) => {
1569
+ saveUnreadRule({ clearDelaySec: normalizeClearDelay(value) });
1570
+ }, [saveUnreadRule]);
1455
1571
  /** One unread checkbox; `key === "pending"` is the "someone is waiting" row. */
1456
1572
  const renderReasonCheckbox = (key, labelKey) => React.createElement("label", {
1457
1573
  key,
@@ -1687,6 +1803,22 @@ window.__ModuleLoader__.load({
1687
1803
  renderReasonCheckbox("pending", "reasonPending")
1688
1804
  )
1689
1805
  ),
1806
+ React.createElement("div", { style: { marginTop: "12px" } },
1807
+ React.createElement("div", { style: style.desc }, t("clearDelayLabel")),
1808
+ React.createElement("label", { style: { display: "inline-flex", alignItems: "center", gap: "6px", marginTop: "6px", fontSize: 12, color: "var(--dsw-alias-label-primary)" } },
1809
+ React.createElement("input", {
1810
+ type: "number", min: 0, max: UNREAD_CLEAR_DELAY_MAX_SEC, step: 1, inputMode: "numeric",
1811
+ "aria-label": t("clearDelayLabel"),
1812
+ value: clearDelayDraft,
1813
+ onChange: (event) => setClearDelayDraft(event.target.value),
1814
+ onBlur: () => onClearDelayChange(clearDelayDraft),
1815
+ onKeyDown: (event) => { if (event.key === "Enter") onClearDelayChange(clearDelayDraft); },
1816
+ style: { width: "76px", padding: "3px 6px", border: "1px solid var(--dsw-alias-border-l2)", borderRadius: "6px", background: "transparent", color: "inherit", font: "inherit", fontSize: 12 }
1817
+ }),
1818
+ React.createElement("span", null, t("clearDelayUnit"))
1819
+ ),
1820
+ React.createElement("div", { style: style.hint }, t("clearDelayHint"))
1821
+ ),
1690
1822
  React.createElement("div", { style: { marginTop: "10px" } },
1691
1823
  React.createElement("label", { style: { display: "inline-flex", alignItems: "center", gap: "6px", fontSize: 12, color: "var(--dsw-alias-label-primary)", cursor: "pointer" } },
1692
1824
  React.createElement("input", {
package/lib/index.js CHANGED
@@ -462,14 +462,14 @@ export class FaviconCustomService extends TypertRemoteService {
462
462
  * @returns `{ reasons, pending }`.
463
463
  */
464
464
  async getUnreadRule() {
465
- return { reasons: this.unread.reasons, pending: this.unread.pending, workspaceDot: this.unread.workspaceDot }
465
+ return { reasons: this.unread.reasons, pending: this.unread.pending, workspaceDot: this.unread.workspaceDot, clearDelaySec: this.unread.clearDelaySec }
466
466
  }
467
467
 
468
468
  /**
469
469
  * typert endpoint: replace the unread-badge rule — which turn-end reasons
470
470
  * light the badge. The rule is applied on the client, so this takes effect
471
471
  * without re-folding any session log.
472
- * @param request - `{ reasons, pending? }`; unknown or missing keys fall back to defaults.
472
+ * @param request - `{ reasons, pending?, workspaceDot?, clearDelaySec? }`; unknown or missing keys fall back to defaults.
473
473
  * @returns the stored rule.
474
474
  */
475
475
  async setUnreadRule(request) {
@@ -478,7 +478,7 @@ 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 }
481
+ return { reasons: this.unread.reasons, pending: this.unread.pending, workspaceDot: this.unread.workspaceDot, clearDelaySec: this.unread.clearDelaySec }
482
482
  }
483
483
 
484
484
  /** typert endpoint: store a new icon uploaded as a data URL and make it active. */
@@ -52,13 +52,16 @@ const unreadRuleSchema = z.object({
52
52
  reasons: z.record(z.string(), z.boolean()),
53
53
  pending: z.boolean(),
54
54
  /** 实验项:工作区图标上的红点(贴产品 DOM,可一键关闭)。 */
55
- workspaceDot: z.boolean()
55
+ workspaceDot: z.boolean(),
56
+ /** 进入会话后要连续待满多少秒才算"看过";0 = 进入即已读。上限 10 分钟。 */
57
+ clearDelaySec: z.number().int().nonnegative().max(600)
56
58
  })
57
59
 
58
60
  const setUnreadRuleRequestSchema = z.object({
59
61
  reasons: z.record(z.string(), z.boolean()),
60
62
  pending: z.boolean().optional(),
61
- workspaceDot: z.boolean().optional()
63
+ workspaceDot: z.boolean().optional(),
64
+ clearDelaySec: z.number().int().nonnegative().max(600).optional()
62
65
  })
63
66
 
64
67
  const setIconResultSchema = statusSchema
@@ -269,7 +272,7 @@ export const TYPERT = {
269
272
  {
270
273
  kind: 'method',
271
274
  name: 'getUnreadRule',
272
- signature: 'async getUnreadRule(): Promise<{ reasons: Record<string, boolean>; pending: boolean; workspaceDot: boolean }>',
275
+ signature: 'async getUnreadRule(): Promise<{ reasons: Record<string, boolean>; pending: boolean; workspaceDot: boolean; clearDelaySec: number }>',
273
276
  description: "Read the unread-badge rule.",
274
277
  summary: "Read the unread-badge rule.",
275
278
  jsDoc: "/** Read the unread-badge rule. */",
@@ -278,7 +281,7 @@ export const TYPERT = {
278
281
  {
279
282
  kind: 'method',
280
283
  name: 'setUnreadRule',
281
- signature: 'async setUnreadRule(request: { reasons: Record<string, boolean>; pending?: boolean; workspaceDot?: boolean }): Promise<{ reasons: Record<string, boolean>; pending: boolean; workspaceDot: boolean }>',
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 }>',
282
285
  description: "Replace the unread-badge rule: which turn-end reasons light the red dot.",
283
286
  summary: "Set the unread-badge rule.",
284
287
  jsDoc: "/** Set the unread-badge rule. */",
package/lib/unread.js CHANGED
@@ -52,6 +52,29 @@ export const UNREAD_PENDING_DEFAULT = true
52
52
  */
53
53
  export const UNREAD_WORKSPACE_DOT_DEFAULT = true
54
54
 
55
+ /**
56
+ * 默认:进入会话即视为已读(0 秒)。
57
+ * 大于 0 时,你必须在那个会话里**连续**待满这么多秒它才算"看过";中途切走会
58
+ * 清零重数。填 0 就是"不设置",行为跟没有这一项完全一样。
59
+ */
60
+ export const UNREAD_CLEAR_DELAY_DEFAULT = 0
61
+
62
+ /**
63
+ * 秒数上限:10 分钟。
64
+ * 挡住误填的大数(想填 5 却打成 50000)——那会让红点实际上永远清不掉,
65
+ * 而症状是"点进去也不消",很难联想到是这里填错了。
66
+ */
67
+ export const UNREAD_CLEAR_DELAY_MAX_SEC = 600
68
+
69
+ /** 把任意输入夹成 `0..上限` 的整数秒(小数向下取整,脏值回落默认)。 */
70
+ export function normalizeClearDelay(input) {
71
+ const value = typeof input === 'string' && input.trim() !== '' ? Number(input) : input
72
+ if (typeof value !== 'number' || !Number.isFinite(value)) return UNREAD_CLEAR_DELAY_DEFAULT
73
+ const seconds = Math.floor(value)
74
+ if (seconds <= 0) return 0
75
+ return Math.min(seconds, UNREAD_CLEAR_DELAY_MAX_SEC)
76
+ }
77
+
55
78
  /** 把任意输入夹成一张完整、只有布尔值的开关表(缺项取默认)。 */
56
79
  export function normalizeReasons(input) {
57
80
  const out = {}
@@ -64,15 +87,16 @@ export function normalizeReasons(input) {
64
87
 
65
88
  /**
66
89
  * 把任意输入夹成完整设置。
67
- * @param input - `{ reasons?, pending? }`,可以是脏的、缺项的、null
68
- * @returns `{ reasons, pending }` —— 两张表都完整
90
+ * @param input - `{ reasons?, pending?, workspaceDot?, clearDelaySec? }`,可以是脏的、缺项的、null
91
+ * @returns `{ reasons, pending, workspaceDot, clearDelaySec }` —— 每一项都完整
69
92
  */
70
93
  export function normalizeUnreadConfig(input) {
71
94
  const source = input !== null && typeof input === 'object' ? input : {}
72
95
  return {
73
96
  reasons: normalizeReasons(source.reasons),
74
97
  pending: typeof source.pending === 'boolean' ? source.pending : UNREAD_PENDING_DEFAULT,
75
- workspaceDot: typeof source.workspaceDot === 'boolean' ? source.workspaceDot : UNREAD_WORKSPACE_DOT_DEFAULT
98
+ workspaceDot: typeof source.workspaceDot === 'boolean' ? source.workspaceDot : UNREAD_WORKSPACE_DOT_DEFAULT,
99
+ clearDelaySec: normalizeClearDelay(source.clearDelaySec)
76
100
  }
77
101
  }
78
102
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cowwo/dsh-icon-custom",
3
- "version": "0.9.2",
3
+ "version": "0.10.1",
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",