@iasiv5/dsh-quota-watch 0.1.18 → 0.1.20
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.md +3 -3
- package/lib/client.js +43 -8
- package/package.json +1 -1
- package/src/client/prefs.mjs +14 -0
- package/src/client.mjs +73 -8
- package/src/host.mjs +15 -2
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
## ✨ 功能一览
|
|
16
16
|
|
|
17
|
-
- **🚀 悬浮胶囊(默认界面)**:一行「GLM 15% · Copilot 1.8%」常驻屏幕(默认右下角安全区),单击展开面板——胶囊上没有任何隐藏手势(右键 / 长按菜单已于 0.1.14 退役)。拖拽跟手(保持抓取点,transform 渲染)、任意位置停放、位置跨重启记忆、越界自动钳回视口(距边 8px
|
|
17
|
+
- **🚀 悬浮胶囊(默认界面)**:一行「GLM 15% · Copilot 1.8%」常驻屏幕(默认右下角安全区),单击展开面板——胶囊上没有任何隐藏手势(右键 / 长按菜单已于 0.1.14 退役)。拖拽跟手(保持抓取点,transform 渲染)、任意位置停放、位置跨重启记忆、越界自动钳回视口(距边 8px 安全边距),并自动避开 Windows Desktop 壳顶部的标题栏拖拽带(0.1.20:该带按布局吞掉指针输入、无视 z-index,胶囊/面板落进去就再也点不到、拖不回——现与壳自家浮窗同款消费 `--dsh-windows-titlebar-height` 让位,历史卡死位置在启动时自动钳回带外;Web 无此变量,行为零漂移);触摸设备直接拖拽(更宽的防误拖阈值)、点击热区 44px、自动避让 iPhone 刘海 / 小黑条(safe-area)。已用超过 80% 转橙、超过 95% 转红,任一 provider 超 95% 时轻微脉动(尊重系统「减少动态效果」设置)。悬停提示与 aria-label 实时给出摘要。
|
|
18
18
|
- **🖥️ 详情面板**:半透明毛玻璃底,宽 `min(320px, 100vw−24px)`,靠边自动翻侧,滚动 / 缩放跟随;≤480px 窄屏自动改为底部弹层并避开 safe-area;Esc 或点击面板外关闭。概览列两个 provider 行,点击进入详情:
|
|
19
19
|
- **GLM**:今日 Tokens / 调用次数双大数字 + 五列对齐窗口网格(MCP(月)→ 5 小时 → 每月 → 每周固定排序,周窗口未下发时显示「每周额度 · ♾️(无限)」占位)+ 分模型用量行;
|
|
20
20
|
- **Copilot**:可用额度、本期已用(credits 消耗 + 弱化百分比)、重置时间三行。
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
- **🧩 侧边栏卡片(可选,默认隐藏)**:胶囊是默认界面;需要侧栏卡片时从面板底部按钮开启(Web profile 侧栏底部区块存在时才出现该按钮)。卡片提供摘要行 + 行点击开面板 + 行首 provider 名称点击立即探测。侧栏收起(Web 窄栏)或容器过窄(<160px,跨 profile 通用判据)时自动隐藏,侧栏整体不存在(Desktop 收起态)同样自然缺席、无报错——入口始终由悬浮胶囊承载。
|
|
24
24
|
- **🌏 语言跟随宿主**:界面文案跟随 DSH「设置 → 语言」——宿主 locale 特性把解析结果实时镜像到 `<html lang>`,插件监听该属性,切换即热重渲染(无需刷新页面);宿主未同步时回退浏览器语言。
|
|
25
25
|
- **🛟 降级与容错**:凭据缺失的 provider 不渲染摘要行与胶囊分段(两者皆缺时悬浮胶囊与卡片一并隐藏);探测失败时保留最后一次成功数据并在行上加 ⚠ 过期标记(⚠ 只出现在面板与卡片行内,不在胶囊上显示)。
|
|
26
|
-
- **🔁 刷新节奏**:宿主默认每 60 秒探测(窗口 + 用量 + Copilot),profile 可配 30–3600 秒;浏览器页签可见时每 30
|
|
26
|
+
- **🔁 刷新节奏**:宿主默认每 60 秒探测(窗口 + 用量 + Copilot),profile 可配 30–3600 秒;浏览器页签可见时每 30 秒刷新视图,隐藏时暂停。重启冷启动零等待:宿主 0 秒即发首轮探测(网络未就绪时以 5 秒冷重试直至拿到数据),客户端在无数据期间以 2 秒起步的指数退避快速重试、拿到数据后回到常规节奏——重启后数秒内胶囊即出现,不再有 ~30 秒空窗。所有 UI 收在 Shadow DOM 里(ADR 0001),颜色走 DSH 主题语义变量(含硬编码兜底),自动适配浅色 / 深色主题。
|
|
27
27
|
|
|
28
28
|
## 📸 界面巡礼
|
|
29
29
|
|
|
@@ -71,4 +71,4 @@ dsh plugin --profile desktop add @iasiv5/dsh-quota-watch # desktop profile
|
|
|
71
71
|
|
|
72
72
|
## English summary
|
|
73
73
|
|
|
74
|
-
DSH Quota Watch is a personal DSH quota monitor built around a persistent floating capsule: a one-line `GLM x% · Copilot y%` summary that turns amber past 80% and red past 95%. Dragging it is grab-accurate (transform-based, rAF-batched) and free to place anywhere on the screen, with position remembered across restarts and viewport boundaries automatically clamped. Touch devices drag directly (wider slop against accidental drags), the hit area is padded to 44px and placement respects `env(safe-area-inset-*)`. Clicking the capsule opens a floating glass panel whose overview reuses the classic 0.0.18 summary rows (label · bar · percent · extra · chevron) and whose detail pages replicate the 0.0.18 big numbers, window grid and per-model list; the panel closes via ✕, Esc or an outside click, and viewports ≤480px dock it to the bottom as a sheet. The panel footer holds two equal-width buttons — sidebar-card visibility and an immediate provider re-probe — replacing the retired right-click/long-press menu, so the capsule itself carries no hidden gestures. A sidebar card remains an opportunistic extra entry on web profiles, opted in from that footer button, and silently disappears whenever the sidebar does — collapsed web sidebars and the desktop profile are covered by the floating capsule alone. Credentials are resolved on every host-side probe, only normalized values reach the browser, no usage history is kept, and a Copilot balance is never inferred from credits used. UI copy follows the DSH language preference (mirrored live onto `<html lang>`, re-rendering on switch without a reload) and falls back to the browser language; window notes share one paren glyph per locale so the grid's left edges stay aligned. Colors ride DSH semantic theme tokens with hardcoded fallbacks; all UI lives inside a shadow root (ADR 0001).
|
|
74
|
+
DSH Quota Watch is a personal DSH quota monitor built around a persistent floating capsule: a one-line `GLM x% · Copilot y%` summary that turns amber past 80% and red past 95%. Dragging it is grab-accurate (transform-based, rAF-batched) and free to place anywhere on the screen, with position remembered across restarts and viewport boundaries automatically clamped. Since 0.1.20 placement also steers clear of the Windows Desktop shell titlebar drag band (a layout-level `-webkit-app-region:drag` strip that swallows pointer input regardless of z-index — the same trap dsh-m fixed in 0.9.4; the capsule and the panel header consume `--dsh-windows-titlebar-height` the way the shell's own floats do, and positions saved while stuck self-heal below the band on boot; Web, without the variable, is byte-identical). Touch devices drag directly (wider slop against accidental drags), the hit area is padded to 44px and placement respects `env(safe-area-inset-*)`. Clicking the capsule opens a floating glass panel whose overview reuses the classic 0.0.18 summary rows (label · bar · percent · extra · chevron) and whose detail pages replicate the 0.0.18 big numbers, window grid and per-model list; the panel closes via ✕, Esc or an outside click, and viewports ≤480px dock it to the bottom as a sheet. The panel footer holds two equal-width buttons — sidebar-card visibility and an immediate provider re-probe — replacing the retired right-click/long-press menu, so the capsule itself carries no hidden gestures. A sidebar card remains an opportunistic extra entry on web profiles, opted in from that footer button, and silently disappears whenever the sidebar does — collapsed web sidebars and the desktop profile are covered by the floating capsule alone. Credentials are resolved on every host-side probe, only normalized values reach the browser, no usage history is kept, and a Copilot balance is never inferred from credits used. UI copy follows the DSH language preference (mirrored live onto `<html lang>`, re-rendering on switch without a reload) and falls back to the browser language; window notes share one paren glyph per locale so the grid's left edges stay aligned. Colors ride DSH semantic theme tokens with hardcoded fallbacks; all UI lives inside a shadow root (ADR 0001).
|
package/lib/client.js
CHANGED
|
@@ -107,6 +107,11 @@ function clampPoint(point, viewport, size = 0, insets = ZERO_INSETS) {
|
|
|
107
107
|
y: Math.min(Math.max(point.y, minY), maxY)
|
|
108
108
|
};
|
|
109
109
|
}
|
|
110
|
+
function titlebarTopInset({ titlebar = false, fullscreen = false, height } = {}) {
|
|
111
|
+
if (!titlebar || fullscreen) return 0;
|
|
112
|
+
const value = Number.parseFloat(height);
|
|
113
|
+
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
114
|
+
}
|
|
110
115
|
function loadFloatPosition(storage) {
|
|
111
116
|
try {
|
|
112
117
|
const raw = storage.getItem(FLOAT_POSITION_KEY);
|
|
@@ -470,7 +475,9 @@ function mountQuotaCard({
|
|
|
470
475
|
doc = globalThis.document,
|
|
471
476
|
win = globalThis.window,
|
|
472
477
|
fetchImpl = globalThis.fetch,
|
|
473
|
-
pollIntervalMs = CLIENT_POLL_INTERVAL_MS
|
|
478
|
+
pollIntervalMs = CLIENT_POLL_INTERVAL_MS,
|
|
479
|
+
retryNoDataMs = 2e3,
|
|
480
|
+
retryBackoff = 2
|
|
474
481
|
} = {}) {
|
|
475
482
|
if (!doc?.body || typeof fetchImpl !== "function") return () => {
|
|
476
483
|
};
|
|
@@ -549,11 +556,23 @@ function mountQuotaCard({
|
|
|
549
556
|
const safeAreaProbe = text(doc, "div", "", "");
|
|
550
557
|
safeAreaProbe.style.cssText = "position:fixed;visibility:hidden;pointer-events:none;width:0;height:0;overflow:hidden;padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);";
|
|
551
558
|
floatRoot.append(safeAreaProbe);
|
|
559
|
+
const readTitlebarInset = () => {
|
|
560
|
+
try {
|
|
561
|
+
const html = doc.documentElement;
|
|
562
|
+
return titlebarTopInset({
|
|
563
|
+
titlebar: html.hasAttribute("data-windows-titlebar"),
|
|
564
|
+
fullscreen: html.hasAttribute("data-fullscreen"),
|
|
565
|
+
height: parseInset(win.getComputedStyle(html)?.getPropertyValue("--dsh-windows-titlebar-height"))
|
|
566
|
+
});
|
|
567
|
+
} catch {
|
|
568
|
+
return 0;
|
|
569
|
+
}
|
|
570
|
+
};
|
|
552
571
|
const readInsets = () => {
|
|
553
572
|
try {
|
|
554
573
|
const style2 = win.getComputedStyle(safeAreaProbe);
|
|
555
574
|
return {
|
|
556
|
-
top: parseInset(style2?.paddingTop),
|
|
575
|
+
top: Math.max(parseInset(style2?.paddingTop), readTitlebarInset()),
|
|
557
576
|
right: parseInset(style2?.paddingRight),
|
|
558
577
|
bottom: parseInset(style2?.paddingBottom),
|
|
559
578
|
left: parseInset(style2?.paddingLeft)
|
|
@@ -670,11 +689,12 @@ function mountQuotaCard({
|
|
|
670
689
|
let snapshot;
|
|
671
690
|
let requestSequence = 0;
|
|
672
691
|
let timer;
|
|
692
|
+
let noDataRetries = 0;
|
|
673
693
|
let disposed = false;
|
|
674
694
|
let queuedPlace = false;
|
|
675
695
|
let openKey;
|
|
676
696
|
let panelNav = false;
|
|
677
|
-
let dataHidden =
|
|
697
|
+
let dataHidden = true;
|
|
678
698
|
let surfaceFlags = loadSurfaceFlags(win.localStorage);
|
|
679
699
|
const setCardHidden = (hidden) => {
|
|
680
700
|
surfaceFlags = { cardHidden: hidden };
|
|
@@ -915,7 +935,8 @@ function mountQuotaCard({
|
|
|
915
935
|
if (left + width > viewWidth - 8) left = rect.left - width - 8;
|
|
916
936
|
if (left + width > viewWidth - 8) left = viewWidth - width - 8;
|
|
917
937
|
left = Math.max(8, left);
|
|
918
|
-
const
|
|
938
|
+
const minTop = MARGIN + readInsets().top;
|
|
939
|
+
const top = Math.min(Math.max(rect.top, minTop), Math.max(minTop, viewHeight - height - 8));
|
|
919
940
|
panel.style.left = `${Math.round(left)}px`;
|
|
920
941
|
panel.style.top = `${Math.round(top)}px`;
|
|
921
942
|
};
|
|
@@ -1059,6 +1080,13 @@ function mountQuotaCard({
|
|
|
1059
1080
|
if (sequence === requestSequence && !disposed) render(next);
|
|
1060
1081
|
} catch {
|
|
1061
1082
|
if (sequence === requestSequence && !disposed) showTransportError();
|
|
1083
|
+
} finally {
|
|
1084
|
+
if (timer === void 0 && sequence === requestSequence && !disposed && doc.visibilityState !== "hidden") {
|
|
1085
|
+
timer = win.setTimeout(() => {
|
|
1086
|
+
timer = void 0;
|
|
1087
|
+
if (!disposed && doc.visibilityState !== "hidden") void poll();
|
|
1088
|
+
}, nextPollDelay());
|
|
1089
|
+
}
|
|
1062
1090
|
}
|
|
1063
1091
|
};
|
|
1064
1092
|
const place = () => {
|
|
@@ -1089,16 +1117,23 @@ function mountQuotaCard({
|
|
|
1089
1117
|
if (!disposed) renderAll();
|
|
1090
1118
|
}) : null;
|
|
1091
1119
|
langObserver?.observe(doc.documentElement, { attributeFilter: ["lang"] });
|
|
1120
|
+
syncFloatVisibility();
|
|
1092
1121
|
place();
|
|
1122
|
+
const nextPollDelay = () => {
|
|
1123
|
+
if (!dataHidden) {
|
|
1124
|
+
noDataRetries = 0;
|
|
1125
|
+
return pollIntervalMs;
|
|
1126
|
+
}
|
|
1127
|
+
const delay = retryNoDataMs * retryBackoff ** noDataRetries;
|
|
1128
|
+
noDataRetries += 1;
|
|
1129
|
+
return Math.min(delay, pollIntervalMs);
|
|
1130
|
+
};
|
|
1093
1131
|
const startPolling = () => {
|
|
1094
1132
|
if (timer !== void 0 || doc.visibilityState === "hidden" || disposed) return;
|
|
1095
1133
|
void poll();
|
|
1096
|
-
timer = win.setInterval(() => {
|
|
1097
|
-
void poll();
|
|
1098
|
-
}, pollIntervalMs);
|
|
1099
1134
|
};
|
|
1100
1135
|
const stopPolling = () => {
|
|
1101
|
-
if (timer !== void 0) win.
|
|
1136
|
+
if (timer !== void 0) win.clearTimeout(timer);
|
|
1102
1137
|
timer = void 0;
|
|
1103
1138
|
};
|
|
1104
1139
|
const onVisibilityChange = () => {
|
package/package.json
CHANGED
package/src/client/prefs.mjs
CHANGED
|
@@ -31,6 +31,20 @@ export function clampPoint(point, viewport, size = 0, insets = ZERO_INSETS) {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// Windows Desktop shell titlebar contract (0.1.20): the shell runs
|
|
35
|
+
// `titleBarStyle:hidden + titleBarOverlay`, so the top strip of the window is a
|
|
36
|
+
// full-width `-webkit-app-region:drag` band that swallows pointer input by
|
|
37
|
+
// LAYOUT — z-index and paint order are irrelevant — and a capsule parked inside
|
|
38
|
+
// it can never be grabbed or clicked again. The shell flags the band on
|
|
39
|
+
// <html> with `data-windows-titlebar` + `--dsh-windows-titlebar-height` and
|
|
40
|
+
// clears it under `data-fullscreen`; its own floats clamp to exactly this
|
|
41
|
+
// value. Everything else (DSH Web, browsers, jsdom) yields 0 — zero drift.
|
|
42
|
+
export function titlebarTopInset({ titlebar = false, fullscreen = false, height } = {}) {
|
|
43
|
+
if (!titlebar || fullscreen) return 0
|
|
44
|
+
const value = Number.parseFloat(height)
|
|
45
|
+
return Number.isFinite(value) && value > 0 ? value : 0
|
|
46
|
+
}
|
|
47
|
+
|
|
34
48
|
export function loadFloatPosition(storage) {
|
|
35
49
|
try {
|
|
36
50
|
const raw = storage.getItem(FLOAT_POSITION_KEY)
|
package/src/client.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CLIENT_POLL_INTERVAL_MS, CLIENT_ROUTES } from './shared.mjs'
|
|
2
2
|
import { remainingToUsed } from './core/adapters.mjs'
|
|
3
|
-
import { MARGIN, clampPoint, loadFloatPosition, loadSurfaceFlags, saveFloatPosition, saveSurfaceFlags } from './client/prefs.mjs'
|
|
3
|
+
import { MARGIN, clampPoint, loadFloatPosition, loadSurfaceFlags, saveFloatPosition, saveSurfaceFlags, titlebarTopInset } from './client/prefs.mjs'
|
|
4
4
|
import { DRAG_SLOP_MOUSE, DRAG_SLOP_TOUCH, parseInset, clampFrame, dragSlop, grabOffset } from './client/drag.mjs'
|
|
5
5
|
|
|
6
6
|
export const name = 'quota-watch-client'
|
|
@@ -373,6 +373,8 @@ export function mountQuotaCard({
|
|
|
373
373
|
win = globalThis.window,
|
|
374
374
|
fetchImpl = globalThis.fetch,
|
|
375
375
|
pollIntervalMs = CLIENT_POLL_INTERVAL_MS,
|
|
376
|
+
retryNoDataMs = 2_000,
|
|
377
|
+
retryBackoff = 2,
|
|
376
378
|
} = {}) {
|
|
377
379
|
if (!doc?.body || typeof fetchImpl !== 'function') return () => {}
|
|
378
380
|
if (doc.querySelector(CARD_SELECTOR) || doc.querySelector(FLOAT_SELECTOR)) return () => {}
|
|
@@ -448,7 +450,8 @@ export function mountQuotaCard({
|
|
|
448
450
|
const rect = surface.getBoundingClientRect()
|
|
449
451
|
return rect.width > 0 ? { width: rect.width, height: rect.height } : FALLBACK_SIZE
|
|
450
452
|
}
|
|
451
|
-
/** Position = user preferred {x, y} clamped to viewport
|
|
453
|
+
/** Position = user preferred {x, y} clamped to viewport, safe-area insets
|
|
454
|
+
* and the Windows shell titlebar band. */
|
|
452
455
|
const applyPosition = () => {
|
|
453
456
|
const viewWidth = win.innerWidth ?? 1024
|
|
454
457
|
const viewHeight = win.innerHeight ?? 768
|
|
@@ -469,13 +472,37 @@ export function mountQuotaCard({
|
|
|
469
472
|
const safeAreaProbe = text(doc, 'div', '', '')
|
|
470
473
|
safeAreaProbe.style.cssText = 'position:fixed;visibility:hidden;pointer-events:none;width:0;height:0;overflow:hidden;padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);'
|
|
471
474
|
floatRoot.append(safeAreaProbe)
|
|
475
|
+
// Windows Desktop titlebar band (0.1.20): with `titleBarStyle:hidden +
|
|
476
|
+
// titleBarOverlay` the top strip of the window is the shell's full-width
|
|
477
|
+
// `-webkit-app-region:drag` band — it wins pointer input by layout, ignoring
|
|
478
|
+
// z-index, so a capsule dropped there is stuck for good (un-grabbable,
|
|
479
|
+
// un-clickable, and the persisted position kept re-parking it). Folding the
|
|
480
|
+
// band into the top inset routes it through every clamp path at once: live
|
|
481
|
+
// drag frames, release docking, persisted-position restore and the default
|
|
482
|
+
// dock — and mount-time clamping also self-heals positions saved by 0.1.19.
|
|
483
|
+
// Same contract the shell's own floats consume: `data-windows-titlebar` +
|
|
484
|
+
// `--dsh-windows-titlebar-height` on <html>, dropped under `data-fullscreen`.
|
|
485
|
+
// DSH Web / browsers have neither attribute nor variable → 0, zero drift.
|
|
486
|
+
const readTitlebarInset = () => {
|
|
487
|
+
try {
|
|
488
|
+
const html = doc.documentElement
|
|
489
|
+
return titlebarTopInset({
|
|
490
|
+
titlebar: html.hasAttribute('data-windows-titlebar'),
|
|
491
|
+
fullscreen: html.hasAttribute('data-fullscreen'),
|
|
492
|
+
height: parseInset(win.getComputedStyle(html)?.getPropertyValue('--dsh-windows-titlebar-height')),
|
|
493
|
+
})
|
|
494
|
+
} catch {
|
|
495
|
+
return 0
|
|
496
|
+
}
|
|
497
|
+
}
|
|
472
498
|
/** Live env(safe-area-inset-*) reading — re-probed on every use, so window
|
|
473
|
-
* chrome changes (rotation, URL-bar collapse) need no explicit invalidation.
|
|
499
|
+
* chrome changes (rotation, URL-bar collapse) need no explicit invalidation.
|
|
500
|
+
* `top` is the larger of the safe-area inset and the shell titlebar band. */
|
|
474
501
|
const readInsets = () => {
|
|
475
502
|
try {
|
|
476
503
|
const style = win.getComputedStyle(safeAreaProbe)
|
|
477
504
|
return {
|
|
478
|
-
top: parseInset(style?.paddingTop),
|
|
505
|
+
top: Math.max(parseInset(style?.paddingTop), readTitlebarInset()),
|
|
479
506
|
right: parseInset(style?.paddingRight),
|
|
480
507
|
bottom: parseInset(style?.paddingBottom),
|
|
481
508
|
left: parseInset(style?.paddingLeft),
|
|
@@ -610,11 +637,15 @@ export function mountQuotaCard({
|
|
|
610
637
|
let snapshot
|
|
611
638
|
let requestSequence = 0
|
|
612
639
|
let timer
|
|
640
|
+
let noDataRetries = 0
|
|
613
641
|
let disposed = false
|
|
614
642
|
let queuedPlace = false
|
|
615
643
|
let openKey
|
|
616
644
|
let panelNav = false
|
|
617
|
-
|
|
645
|
+
// Cold until the first successful render: a mount whose first poll fails or
|
|
646
|
+
// stalls must behave like the documented no-data state (hidden shell, fast
|
|
647
|
+
// retries), not like a warm client showing an empty capsule.
|
|
648
|
+
let dataHidden = true
|
|
618
649
|
let surfaceFlags = loadSurfaceFlags(win.localStorage)
|
|
619
650
|
const setCardHidden = (hidden) => {
|
|
620
651
|
surfaceFlags = { cardHidden: hidden }
|
|
@@ -886,7 +917,11 @@ export function mountQuotaCard({
|
|
|
886
917
|
if (left + width > viewWidth - 8) left = rect.left - width - 8
|
|
887
918
|
if (left + width > viewWidth - 8) left = viewWidth - width - 8
|
|
888
919
|
left = Math.max(8, left)
|
|
889
|
-
|
|
920
|
+
// The panel header carries the close button: keep its top edge below the
|
|
921
|
+
// shell titlebar band (and safe-area top) for the same reason as the
|
|
922
|
+
// capsule — layout-level drag hits ignore z-index. Web fallback: 8px, as before.
|
|
923
|
+
const minTop = MARGIN + readInsets().top
|
|
924
|
+
const top = Math.min(Math.max(rect.top, minTop), Math.max(minTop, viewHeight - height - 8))
|
|
890
925
|
panel.style.left = `${Math.round(left)}px`
|
|
891
926
|
panel.style.top = `${Math.round(top)}px`
|
|
892
927
|
}
|
|
@@ -1052,6 +1087,18 @@ export function mountQuotaCard({
|
|
|
1052
1087
|
if (sequence === requestSequence && !disposed) render(next)
|
|
1053
1088
|
} catch {
|
|
1054
1089
|
if (sequence === requestSequence && !disposed) showTransportError()
|
|
1090
|
+
} finally {
|
|
1091
|
+
// Self-scheduling chain (replaces the fixed interval): every completed
|
|
1092
|
+
// poll — success OR transport error — arms the next one, so the no-data
|
|
1093
|
+
// backoff also covers a cold start whose first request fails outright.
|
|
1094
|
+
// timer === undefined guards against a concurrent force poll forking a
|
|
1095
|
+
// second chain; the already-armed timer keeps the cadence.
|
|
1096
|
+
if (timer === undefined && sequence === requestSequence && !disposed && doc.visibilityState !== 'hidden') {
|
|
1097
|
+
timer = win.setTimeout(() => {
|
|
1098
|
+
timer = undefined
|
|
1099
|
+
if (!disposed && doc.visibilityState !== 'hidden') void poll()
|
|
1100
|
+
}, nextPollDelay())
|
|
1101
|
+
}
|
|
1055
1102
|
}
|
|
1056
1103
|
}
|
|
1057
1104
|
|
|
@@ -1091,15 +1138,33 @@ export function mountQuotaCard({
|
|
|
1091
1138
|
? new win.MutationObserver(() => { if (!disposed) renderAll() })
|
|
1092
1139
|
: null
|
|
1093
1140
|
langObserver?.observe(doc.documentElement, { attributeFilter: ['lang'] })
|
|
1141
|
+
// Cold until data lands: keep the shell hidden so a slow or failed first
|
|
1142
|
+
// probe never flashes an empty capsule (startPolling arms the retry chain).
|
|
1143
|
+
syncFloatVisibility()
|
|
1094
1144
|
place()
|
|
1095
1145
|
|
|
1146
|
+
/** Cold-start cadence: while no provider has rendered any data, re-poll on
|
|
1147
|
+
* a shortening backoff (retryNoDataMs × retryBackoffⁿ, capped at
|
|
1148
|
+
* pollIntervalMs) so a desktop cold start surfaces the capsule in seconds
|
|
1149
|
+
* instead of waiting out the fixed interval. A successful render resets the
|
|
1150
|
+
* chain to the normal cadence; a transport error keeps the previous
|
|
1151
|
+
* dataHidden state, so a cold client keeps retrying fast and a warm one
|
|
1152
|
+
* stays calm. */
|
|
1153
|
+
const nextPollDelay = () => {
|
|
1154
|
+
if (!dataHidden) {
|
|
1155
|
+
noDataRetries = 0
|
|
1156
|
+
return pollIntervalMs
|
|
1157
|
+
}
|
|
1158
|
+
const delay = retryNoDataMs * (retryBackoff ** noDataRetries)
|
|
1159
|
+
noDataRetries += 1
|
|
1160
|
+
return Math.min(delay, pollIntervalMs)
|
|
1161
|
+
}
|
|
1096
1162
|
const startPolling = () => {
|
|
1097
1163
|
if (timer !== undefined || doc.visibilityState === 'hidden' || disposed) return
|
|
1098
1164
|
void poll()
|
|
1099
|
-
timer = win.setInterval(() => { void poll() }, pollIntervalMs)
|
|
1100
1165
|
}
|
|
1101
1166
|
const stopPolling = () => {
|
|
1102
|
-
if (timer !== undefined) win.
|
|
1167
|
+
if (timer !== undefined) win.clearTimeout(timer)
|
|
1103
1168
|
timer = undefined
|
|
1104
1169
|
}
|
|
1105
1170
|
const onVisibilityChange = () => {
|
package/src/host.mjs
CHANGED
|
@@ -311,7 +311,18 @@ export function createQuotaService(ctx, options = {}) {
|
|
|
311
311
|
timer = setTimeout(async () => {
|
|
312
312
|
timer = undefined
|
|
313
313
|
await refresh()
|
|
314
|
-
|
|
314
|
+
// Cold-start retry: until at least one provider has reached ready/stale,
|
|
315
|
+
// a failed or still-loading round re-arms shortly instead of waiting a
|
|
316
|
+
// full interval — a desktop cold start with a slow network would
|
|
317
|
+
// otherwise stare at no capsule for up to a minute. (updatedAt alone
|
|
318
|
+
// cannot signal "cold": refresh() stamps it even when every probe
|
|
319
|
+
// failed.) All-'missing' (no credentials configured) and warm states
|
|
320
|
+
// keep the normal cadence, so nobody without credentials gets
|
|
321
|
+
// high-frequency probing.
|
|
322
|
+
const states = [...providers.values()].map((provider) => provider.status)
|
|
323
|
+
const retryable = states.some((status) => status === 'loading' || status === 'error')
|
|
324
|
+
&& states.every((status) => status !== 'ready' && status !== 'stale')
|
|
325
|
+
schedule(retryable ? (options.coldRetryMs ?? 5_000) : pollIntervalSec * 1000)
|
|
315
326
|
}, delayMs)
|
|
316
327
|
timer.unref?.()
|
|
317
328
|
}
|
|
@@ -321,7 +332,9 @@ export function createQuotaService(ctx, options = {}) {
|
|
|
321
332
|
refresh,
|
|
322
333
|
start() {
|
|
323
334
|
if (disposed || timer !== undefined) return
|
|
324
|
-
|
|
335
|
+
// Routes register synchronously in apply() before start(), so probing
|
|
336
|
+
// immediately is safe; the legacy 2s delay only padded the cold start.
|
|
337
|
+
schedule(0)
|
|
325
338
|
},
|
|
326
339
|
stop() {
|
|
327
340
|
disposed = true
|