dsh-magpie-connector 0.5.0 → 0.6.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/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.2 — 2026-11-03
4
+
5
+ - **Accordion instead of an overlay.** Expanding the sidebar usage card no longer covers the session list: the detail grows in flow inside the same card and the list gives up height from the bottom (the list keeps its top anchor, so rows do not jump). No divider line; the card surface and shadow carry the separation.
6
+ - **Header-only interaction.** Only the header row toggles expand/collapse (the refresh button keeps its own action); the card body and the overflow chevron are no longer click targets — the chevron is a passive "more rows" hint.
7
+ - **Configurable auto refresh.** New host setting `sidebarRefreshMinutes` (whole minutes, default `5`, `0` = off, range `0–1440`, non-integers/out-of-range rejected with 400) and a matching "Auto refresh" field in Settings → Magpie → Sidebar usage. The card re-reads silently on that interval, paused while the page is hidden and resumed on visibility; it never sends `POST {all:true}`. With `0` the previous event-driven behaviour (mount, manual refresh, expand, page visible) is unchanged.
8
+ - **README screenshots.** New sidebar usage card screenshot (`usage.png` / `usage.en.png`) and refreshed Settings screenshots showing the sidebar panel with auto refresh; registered in `docs/SCREENSHOTS.md`.
9
+
10
+ ## 0.6.1 — 2026-11-03
11
+
12
+ - **重置卡改成和 subscriptions 插件一样的折叠列表。** host 现在把网关 `resets.each[]`(每项只保留 `until`/`window`)与 `resets.byWindow`/`fiveHour`/`weekly` 投影给浏览器;卡片上用「用量限制重置 · 可用 N 次」做摘要行,展开后逐张列出「完整重置」/「5 小时」/「每周」和每张的「{时间} 到期」,最早到期那张附相对提示(`N 天后过期` / 不足 24 小时 `N 小时后过期` / 已过期),缺到期时间显示「到期时间不可用」。按窗口发放重置的计划(GLM Coding)摘要显示「5 小时 N 次 · 每周 M 次」。
13
+ - 仍然只读:没有新增按钮,也不调用任何重置端点;网关不给 `each`/`byWindow` 时退回原来的「次数 + `until`」显示。
14
+ - Codex 卡片只显示一个 `7 days` 窗口是网关数据如此:ChatGPT 的 `/wham/usage` 对当前账号只上报周窗口,插件不会补造 5 小时窗口。
15
+ - 规范:`openspec/changes/magpie-reset-card-detail/`。
16
+
17
+ ## 0.6.0 — 2026-11-03
18
+
19
+ - **Sidebar usage card.** Settings → Magpie gains a 「侧边栏额度 / Sidebar usage」 switch (host setting `sidebarUsage`, default off). When on, the sidebar footer shows a dense card above Settings: one row per upstream with its weekly (`7 days`) window, reading and bar; at most five rows, the rest scroll inside the card, and a double chevron (no divider) appears when more exist. Rows keep the gateway's order, a card with an error shows 「读取失败」 instead of a reading, and the whole card has a single refresh (`{all:true}`).
20
+ - **Expands in place.** Clicking the card grows it upward in place: every upstream with every window, reset time, reset chances, balance and the redacted error. The resident card keeps its flow slot, so the session list keeps its height; the expanded layer is an overlay with the same shadow as the resident card, not a layout change. The title row carries one chevron that both expands and collapses.
21
+ - **Balance-only accounts.** An upstream with no quota windows but a gateway-reported balance shows that balance (for example `¥42.50`) instead of a bare dash.
22
+ - **Collapsed sidebar.** With the sidebar in rail mode only a Magpie icon remains (tooltip 「Magpie 额度」); clicking it opens the same content as a 320px anchored popover.
23
+ - **Event-driven reads.** The card reuses `GET /api/dsh-magpie-connector/cards` with a 60s client cache and reads only on mount, manual refresh, expanding, or the page becoming visible again; no timer polling. The settings page is unchanged and keeps per-card refresh.
24
+ - No host request/route changes; the only host addition is the persisted `sidebarUsage` flag.
25
+
26
+ ## 0.5.1 — 2026-11-02
27
+
28
+ - README 中英首屏改用 deviceScaleFactor 3 的静态 PNG(3300x1920 / 2400x2400),替换旧的低分辨率图与 GIF;同时改为 markdown 图片写法,商店详情页可直接渲染。
29
+ - 仅文档与图片调整,无代码/行为变更。
30
+
3
31
  ## 0.5.0 — 2026-10-31
4
32
 
5
33
  - 设置页与速度选择器支持界面语言:新增 `settings.magpie` 的 zh/en 字典(`src/client/locales.ts`),通过 `ctx.locale` 注册并在 `apply()` 绑定;文案不再硬编码中文,跟随 DSH 界面语言(英文界面下为英文)。未绑定翻译器时(单测、无 locale 服务的宿主)仍回退到中文,行为不变。
package/README.md CHANGED
@@ -2,12 +2,13 @@
2
2
 
3
3
  English · [简体中文](README.zh.md)
4
4
 
5
- <p align="center">
6
- <img src="docs/model-picker.png" alt="Model picker grouped by upstream provider: Magpie (Claude), Magpie (GPT), Magpie (deepseek)" width="49%">
7
- <img src="docs/settings.png" alt="Settings: the connection line on top, then models grouped by provider with visibility, default effort and usage cards" width="49%">
8
- </p>
5
+ ![Model picker grouped by upstream provider: Magpie (Claude), Magpie (Codex) and Magpie (Deepseek)](docs/model-picker.en.png)
9
6
 
10
- *Left: model picker grouped by upstream provider. Right: Settings → Magpie — the connection line, then per-provider model visibility, default effort and usage cards. Captured from an isolated DSH host with a local mock Magpie gateway; models and accounts are synthetic.*
7
+ ![Settings: the connection line on top, then models grouped by provider with visibility, default effort and usage cards](docs/settings.en.png)
8
+
9
+ ![Sidebar usage card: one weekly row per upstream with readings and bars](docs/usage.en.png)
10
+
11
+ *Top: model picker grouped by upstream provider. Middle: Settings → Magpie — the connection line, the sidebar usage panel (toggle plus auto-refresh interval in minutes) and per-provider model visibility, default effort and usage cards. Bottom: the resident sidebar usage card (click its header to expand every window). Captured from an isolated DSH host (English UI) with a local mock Magpie gateway; gateway address, models, account and readings are synthetic.*
11
12
 
12
13
  ![Magpie connector architecture: Web GUI and Host plugin, credentials and settings, Magpie gateway and upstream providers, optionally through Cloudflare Access](docs/architecture.en.svg)
13
14
 
@@ -24,7 +25,9 @@ English · [简体中文](README.zh.md)
24
25
  | **Grouped by upstream provider** (signature) | Works on stock DSH, no Host extension needed: the plugin registers one real provider route per gateway `owned_by` (`dsh-magpie-connector-claude`, `-codex`, `-opencode-go`, …; missing or invalid ownership goes to `-other`), so the official model picker shows `Magpie (Claude)`, `Magpie (Codex)`, `Magpie (Opencode-go)`, `Magpie (Other)` groups. Settings and usage cards use the same grouping, and a provider that only appears in usage is never dropped. |
25
26
  | **Line panel and latency** | Configured lines start collapsed; click a row header to edit it, and reorder/delete are always-visible DSH product-icon buttons with tooltips. Opening Settings measures every saved line with a parseable address once (parallel TCP connect time, ≤600ms per line, no HTTP, no credentials) and shows a 4-bar signal plus milliseconds; timeouts show a timeout reading and failures or older hosts degrade to a dash. Display-only: it never affects automatic line selection, is not persisted, and never polls. |
26
27
  | **Per-line Cloudflare Access** | Each line independently chooses Direct or Cloudflare Access. Access-enabled line requests carry the service token pair plus the bearer key; the token is never sent to a plain-HTTP line. |
28
+ | **Read-only usage cards** | Each upstream account shows plan, expiry, window readings and, when the gateway provides them, its Codex reset credits: a folded `Usage limit resets · N available` line that expands to each credit with its expiry and how far off the soonest is (`expires in N days`); plans that hand out one kind per window show `2 five-hour · 1 weekly` in the summary instead. Cards are read once when settings open and only re-read when you press **Refresh** on that card; bars turn orange above 70% and red at 90%. |
27
29
  | **Read-only usage cards** | Each upstream account shows plan, expiry, window readings and Codex reset credits when the gateway provides them. Cards are read once when settings open and only re-read when you press **Refresh** on that card; bars turn orange above 70% and red at 90%. |
30
+ | **Sidebar usage card** (0.6.0) | Settings → Magpie has a 「Sidebar usage」 switch (host setting, default off) and an auto-refresh interval (minutes, `0` = off, default 5). When on, a dense card sits above Settings: one weekly (`7 days`) row per upstream with its reading and bar, at most five rows with the rest scrolling inside and a overflow chevron when more exist, and one refresh for the whole card. Clicking the header row toggles an in-flow detail list (every window, reset time/chances, balance, error) that shrinks the session list instead of covering it; in rail mode a single Magpie icon opens the same content as a 320px popover. Reads reuse the existing usage endpoint with a 60s cache; only the configured interval polls, silently and paused while the page is hidden. |
28
31
  | **Model visibility and default effort** | Per provider, leave visibility untouched to follow the gateway catalog, or save an explicit allowlist so new models stay hidden. Per model, save a default reasoning effort; DSH applies it on the next request. |
29
32
  | **Per-session fast mode** | Models advertising the `priority` service tier get a standard/fast switch next to the composer. The choice is per session and in host memory only; it adds `service_tier: "priority"` to requests. |
30
33
 
package/README.zh.md CHANGED
@@ -2,12 +2,11 @@
2
2
 
3
3
  [English](README.md) · 简体中文
4
4
 
5
- <p align="center">
6
- <img src="docs/model-picker.png" alt="模型选择器:按上游 provider 分组为 Magpie (Claude)、Magpie (GPT)、Magpie (deepseek)" width="49%">
7
- <img src="docs/settings.png" alt="设置页:顶部连接线路,下方按 provider 分组的模型可见性、默认 effort 与用量卡片" width="49%">
8
- </p>
5
+ ![模型选择器:按上游 provider 分组为 Magpie (Claude)、Magpie (Codex)、Magpie (Deepseek)](docs/model-picker.png)
9
6
 
10
- *左:按上游 provider 分组的模型选择器;右:设置 → Magpie,顶部连接线路,下方按 provider 分组的模型可见性、默认 effort 与用量卡片。截图来自隔离 DSH 宿主与本地 mock Magpie 网关,模型和账号均为演示数据。*
7
+ ![设置页:顶部连接线路,下方按 provider 分组的模型可见性、默认 effort 与用量卡片](docs/settings.png)
8
+
9
+ *上:按上游 provider 分组的模型选择器;下:设置 → Magpie,顶部连接线路,下方按 provider 分组的模型可见性、默认 effort 与用量卡片。截自隔离 DSH 宿主与本地 mock Magpie 网关,网关地址、模型和账号均为演示数据。*
11
10
 
12
11
  ![Magpie connector 架构:Web GUI 与 Host 插件、凭据与设置、Magpie 网关与上游 provider,可选经 Cloudflare Access](docs/architecture.zh.svg)
13
12
 
@@ -24,7 +23,9 @@
24
23
  | **按上游 provider 分组**(特色) | 原版 DSH 即可使用,无需 Host 扩展:插件按网关 `owned_by` 为每个归属注册一个真实 provider route(`dsh-magpie-connector-claude`、`-codex`、`-opencode-go` 等;缺失或不合法归入 `-other`),官方模型选择器因此显示 `Magpie (Claude)`、`Magpie (Codex)`、`Magpie (Opencode-go)`、`Magpie (Other)` 分组。设置页和用量卡片使用同一套分组,只出现在用量里的 provider 也不会被丢弃。 |
25
24
  | **线路面板与延迟** | 已配置的线路默认全部收起,点行头部展开编辑;上移 / 下移 / 删除是常驻的 DSH 产品图标按钮(带 tooltip)。打开设置页对每条已保存且地址可解析的线路做一次 TCP 建连测速(并行、单条 ≤600ms、不发 HTTP、不带凭据),用 4 格信号条 + 毫秒显示;超时显示「超时」,旧版宿主或失败显示「—」。只展示当时的网络质量,不参与自动选线、不持久化、不轮询。 |
26
25
  | **按线路的 Cloudflare Access** | 每条线路可单独选择「直连」或「Cloudflare Access」。启用后该线路的请求带上服务令牌两项并保留 bearer key;服务令牌永远不会发往明文 HTTP 线路。 |
26
+ | **只读用量卡片** | 每个上游账号展示计划、到期时间、各窗口读数和 Codex 重置卡(网关提供时):折叠行「用量限制重置 · 可用 N 次」,展开逐张看到期时间与最早一张还剩多久(`N 天后过期`);按窗口发放的计划摘要显示「5 小时 N 次 · 每周 M 次」。打开设置页只读一次,之后只有点单张卡片的「刷新」才会重新读取;用量条超过 70% 变橙、达到 90% 变红。 |
27
27
  | **只读用量卡片** | 每个上游账号展示计划、到期时间、各窗口读数和 Codex 重置次数(网关提供时)。打开设置页只读一次,之后只有点单张卡片的「刷新」才会重新读取;用量条超过 70% 变橙、达到 90% 变红。 |
28
+ | **侧栏额度卡**(0.6.0) | 设置 → Magpie 新增「侧边栏额度」开关(host 持久化,默认关闭)与自动刷新间隔(分钟,`0` = 关闭,默认 5)。打开后左侧栏底部常驻一张密集卡片:每个上游一行周窗口(`7 days`)读数与进度条,最多 5 行、多出的在卡内滚动并显示溢出箭头,整张卡只有一个刷新。点标题行在手风琴里展开每个上游的全部窗口、重置时间/机会、余额与错误,占列表高度而不是盖住列表;侧栏收起时只剩一个 Magpie icon,点开是 320px 浮层。读取复用现有用量接口,客户端 60s 缓存,只有配置的间隔会静默轮询,页面隐藏时暂停。 |
28
29
  | **模型可见性与默认 effort** | 每个 provider 不设置可见性时跟随网关目录;保存过选择后只显示勾选项,新上架模型默认隐藏。每个模型可保存一个默认推理 effort,DSH 在下一次请求应用。 |
29
30
  | **会话级快速档** | 网关声明 `priority` 服务档的模型,输入框旁会出现标准/快速切换。选择按会话保存在 host 内存,快速档会给请求带上 `service_tier: "priority"`。 |
30
31
 
@@ -1,6 +1,8 @@
1
1
  # Screenshots
2
2
 
3
- | file | kind | source | date | reviewer | verdict | note |
4
- |---|---|---|---|---|---|---|
5
- | model-picker.png | screenshot | frame 11 of the 2026-10-28 recording (isolated DSH_HOME instance, temp HOME/XDG/DSH_HOME under /tmp, port 3199; local mock Magpie gateway on loopback with demo models; lossless deviceScaleFactor-2 screenshots of the real Web UI, 1500x880 at 1:1), exported losslessly from the global-palette GIF | 2026-10-29 | lead | pass (reviewed frame: demo workspace and mock models only; no hostname, user, path, token or real session) | 106789 bytes; picker open with Magpie (Claude), Magpie (GPT) and Magpie (deepseek) groups; replaces model-picker.gif because the plugin store does not show GIFs by default |
6
- | settings.png | screenshot | isolated preview home (`git-common-dir/ws/dsh-home/<op>`, port 3202) running the 0.4.0 worktree build against a local loopback mock Magpie gateway (demo models and usage cards); headless Chrome 156 via agent-browser at 1600x1600 CSS pixels | 2026-10-30 | agent (privacy-reviewed; pending lead acceptance) | pass (reviewed: loopback/mock-only addresses 127.0.0.1 and 10.255.255.1, demo@example.com account, demo models and readings; no real hostname, user, key, path, token or session) | 137797 bytes; 0.4.0 connection section with two lines collapsed by default (fast line with 4-bar latency reading, backup line showing timeout), then the claude group with per-model default effort and its usage card with rounded bars; replaces the 0.3.1 screenshot |
3
+ | model-picker.png | screenshot | 0.6.1 build in an isolated DSH_HOME (temp HOME/DSH_HOME under /tmp, port 3255) against a local loopback mock Magpie gateway; Chinese UI; region clip 1100x640 at deviceScaleFactor 3 | 2026-11-02 | agent (privacy-reviewed; pending lead acceptance) | pass (reviewed: Chinese UI, demo workspace, loopback gateway address, mock models only; no hostname, user, key, path or real session) | 3300x1920, 195233 bytes; model picker open with Magpie (Claude), Magpie (Codex) and Magpie (Deepseek) groups; Chinese README first image |
4
+ | settings.png | screenshot | same 0.6.1 isolated host and mock gateway; Chinese UI; settings dialog region 800x786 at deviceScaleFactor 3 | 2026-11-03 | agent (privacy-reviewed; pending lead acceptance) | pass (reviewed: Chinese settings copy, demo@example.com account, loopback gateway address, mock models and readings; no hostname, user, key, path or real session) | 2400x2358, 353740 bytes; connection line, sidebar usage panel with auto-refresh minutes, per-provider models, default effort and the Claude usage card; Chinese README second image |
5
+ | usage.png | screenshot | same 0.6.1 isolated host and mock gateway; Chinese UI; sidebar card region clip 256x164 at deviceScaleFactor 3 | 2026-11-03 | agent (privacy-reviewed; pending lead acceptance) | pass (reviewed: Chinese UI, mock models and readings, synthetic balance; no hostname, user, key, path or real session) | 768x492, 47436 bytes; compact resident card with 5 weekly rows, overflow chevron, balance-only DeepSeek row and an unreadable upstream; Chinese README third image |
6
+ | model-picker.en.png | screenshot | same 0.6.1 isolated host and mock gateway; English UI; region clip 1100x640 at deviceScaleFactor 3 | 2026-11-02 | agent (privacy-reviewed; pending lead acceptance) | pass (reviewed: English UI, demo workspace, loopback gateway address, mock models only; no hostname, user, key, path or real session) | 3300x1920, 188924 bytes; English README first image |
7
+ | settings.en.png | screenshot | same 0.6.1 isolated host and mock gateway; English UI; settings dialog region 800x786 at deviceScaleFactor 3 | 2026-11-03 | agent (privacy-reviewed; pending lead acceptance) | pass (reviewed: English settings copy, demo@example.com account, loopback gateway address, mock models and readings; no hostname, user, key, path or real session) | 2400x2358, 366280 bytes; English README second image |
8
+ | usage.en.png | screenshot | same 0.6.1 isolated host and mock gateway; English UI; sidebar card region clip 256x161 at deviceScaleFactor 3 | 2026-11-03 | agent (privacy-reviewed; pending lead acceptance) | pass (reviewed: English UI, mock models and readings, synthetic balance; no hostname, user, key, path or real session) | 768x483, 47039 bytes; English README third image |
Binary file
Binary file
Binary file
package/docs/settings.png CHANGED
Binary file
Binary file
package/docs/usage.png ADDED
Binary file
@@ -5,6 +5,7 @@ import { lineLatency, parseLatency } from './latency.js';
5
5
  import { settingsCss } from './styles.js';
6
6
  import { UsageCard } from './UsageCard.js';
7
7
  import { t } from './locales.js';
8
+ import { DEFAULT_REFRESH_MINUTES, MAX_REFRESH_MINUTES, setSidebarPreference } from './sidebar-preference.js';
8
9
  import { cacheIds, cardKey, checkDiff, checkSignature, formatTime, parseCards, parseCheck, providerOrder, readTime, sameTarget, } from './view-model.js';
9
10
  /**
10
11
  * Magpie settings section: connection (with an advanced access mode), then
@@ -281,7 +282,7 @@ export function MagpieSettings({ api }) {
281
282
  const groupNote = (provider) => noteScope === provider ? { notice, alert: usageAlert } : {};
282
283
  return h('div', { className: 'dmg-settings' }, h('style', null, settingsCss), h('p', { className: 'dmg-intro' }, t('intro')), settingsError ? h('p', { className: 'dmg-error', role: 'alert' }, t('settingsReadFailed', { error: settingsError })) : null, settings ? h(Connection, { settings, saving: savingSettings, mutateSettings, api, latency, onRefreshLatency: (lineId) => { void readLatency(lineId); },
283
284
  usage: h(UsageStatus, { cards, busy, ...(noteScope === undefined ? { notice, alert: usageAlert } : {}), onFetchAll: () => { void fetchAll(); }, saving: savingSettings }) })
284
- : settingsError ? null : h('p', { role: 'status', className: 'dmg-hint' }, t('readingSettings')), ...providerOrder(groups, cardList).map(provider => h(ProviderGroup, {
285
+ : settingsError ? null : h('p', { role: 'status', className: 'dmg-hint' }, t('readingSettings')), settings ? h(SidebarUsagePreference, { settings, saving: savingSettings, mutateSettings }) : null, ...providerOrder(groups, cardList).map(provider => h(ProviderGroup, {
285
286
  key: provider, provider,
286
287
  group: groups.find(g => g.provider === provider),
287
288
  settings,
@@ -640,3 +641,56 @@ function ModelLine({ model, visible, effort, disabled, onVisible, onEffort }) {
640
641
  value: effort ?? '', onChange: (e) => onEffort(e.target.value === '' ? null : e.target.value),
641
642
  }, h('option', { value: '' }, t('defaultEffortNone')), stale ? h('option', { value: effort, disabled: true }, t('effortStale', { effort })) : null, ...efforts.map(id => h('option', { key: id, value: id }, id))) : null);
642
643
  }
644
+ /**
645
+ * The sidebar preference: one host-persisted flag. The host is the source of
646
+ * truth; the module store is updated so the already-mounted sidebar card
647
+ * follows the toggle without a reload.
648
+ */
649
+ function SidebarUsagePreference({ settings, saving, mutateSettings }) {
650
+ const [failed, setFailed] = useState('');
651
+ const enabled = settings.sidebarUsage === true;
652
+ const savedMinutes = settings.sidebarRefreshMinutes ?? DEFAULT_REFRESH_MINUTES;
653
+ const [draft, setDraft] = useState(String(savedMinutes));
654
+ useEffect(() => { setDraft(String(savedMinutes)); }, [savedMinutes]);
655
+ const toggle = async () => {
656
+ setFailed('');
657
+ const next = !enabled;
658
+ const result = await mutateSettings({ sidebarUsage: next });
659
+ if (result?.error) {
660
+ setFailed(result.error);
661
+ return;
662
+ }
663
+ setSidebarPreference({ usage: result?.view === undefined ? next : result.view.sidebarUsage === true });
664
+ };
665
+ const commitMinutes = async () => {
666
+ setFailed('');
667
+ const raw = draft.trim();
668
+ if (!/^\d+$/.test(raw)) {
669
+ setDraft(String(savedMinutes));
670
+ return;
671
+ }
672
+ const minutes = Math.min(MAX_REFRESH_MINUTES, Number(raw));
673
+ if (minutes !== savedMinutes) {
674
+ const result = await mutateSettings({ sidebarRefreshMinutes: minutes });
675
+ if (result?.error) {
676
+ setFailed(result.error);
677
+ setDraft(String(savedMinutes));
678
+ return;
679
+ }
680
+ setSidebarPreference({ refreshMinutes: result?.view === undefined ? minutes : result.view.sidebarRefreshMinutes ?? minutes });
681
+ }
682
+ setDraft(String(minutes));
683
+ };
684
+ return h('section', { className: 'dmg-panel', 'data-sidebar-usage': '' }, h('h3', null, t('sidebarPanelTitle')), h('p', { className: 'dmg-hint' }, t('sidebarToggleHint')), h('div', { className: 'dmg-switch-row' }, h('span', null, t('sidebarToggle')), h('button', {
685
+ type: 'button', className: 'dmg-switch', role: 'switch', disabled: saving,
686
+ 'aria-checked': enabled, 'aria-label': t('sidebarToggle'), 'data-sidebar-usage-switch': '',
687
+ onClick: () => { void toggle(); },
688
+ }, h('i', null))), h('div', { className: 'dmg-refresh-row' }, h('span', null, t('sidebarRefreshLabel')), h('span', { className: 'dmg-refresh-control' }, h('input', {
689
+ type: 'number', className: 'dmg-refresh-input', min: 0, max: MAX_REFRESH_MINUTES, step: 1,
690
+ value: draft, disabled: saving, 'data-sidebar-refresh': '', 'aria-label': t('sidebarRefreshLabel'),
691
+ onChange: (event) => setDraft(event.target.value),
692
+ onBlur: () => { void commitMinutes(); },
693
+ onKeyDown: (event) => { if (event.key === 'Enter')
694
+ event.currentTarget.blur(); },
695
+ }), h('span', { className: 'dmg-refresh-unit' }, t('sidebarRefreshUnit')))), h('p', { className: 'dmg-hint' }, t('sidebarRefreshHint')), failed ? h('p', { className: 'dmg-error', role: 'alert' }, failed) : null);
696
+ }
@@ -0,0 +1,22 @@
1
+ import { type Api } from './api.js';
2
+ /**
3
+ * Sidebar usage card (`sidebar.footer.action`, owner prop `wide`).
4
+ *
5
+ * Wide sidebar: an accordion. The header row is the only interactive surface
6
+ * (except the refresh button): clicking it toggles the detail, which grows in
7
+ * flow and takes height away from the session list instead of covering it.
8
+ * Collapsed, it shows one weekly row per upstream (at most five) plus a
9
+ * decorative overflow chevron. Collapsed sidebar: one icon that opens the same
10
+ * content as a 320px anchored popover.
11
+ *
12
+ * Reads are event-driven through `sidebar-api` (60s TTL); no timers.
13
+ */
14
+ export interface SidebarUsageProps {
15
+ api?: Api;
16
+ wide?: boolean;
17
+ }
18
+ export declare function SidebarUsage({ api, wide }: SidebarUsageProps): import("react").FunctionComponentElement<import("react").FragmentProps> | import("react").DetailedReactHTMLElement<{
19
+ className: string;
20
+ 'data-dmg-sidebar': string;
21
+ 'data-open': boolean;
22
+ }, HTMLElement> | null;
@@ -0,0 +1,283 @@
1
+ import { createElement as h, Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { useAnchoredPosition, useDismissOnOutsidePointer } from '@deepseek-ai/dsh-client-ui-primitives';
4
+ import { errorText } from './api.js';
5
+ import { settingsCss } from './styles.js';
6
+ import { t } from './locales.js';
7
+ import { loadCards, refreshCards } from './sidebar-api.js';
8
+ import { DEFAULT_REFRESH_MINUTES, hydrateSidebarPreference, readSidebarPreference, subscribeSidebarPreference } from './sidebar-preference.js';
9
+ import { ageMinutes, cardIdentity, cardReadTime, formatResetShort, formatUsed, sidebarRows, unchangedReads, usedLevel, } from './sidebar-view.js';
10
+ import { formatTime } from './view-model.js';
11
+ const MAX_ROWS = 5;
12
+ const RAIL_POPOVER_WIDTH = 320;
13
+ export function SidebarUsage({ api, wide = true }) {
14
+ const [preference, setPreference] = useState(() => readSidebarPreference());
15
+ const [view, setView] = useState(undefined);
16
+ const [open, setOpen] = useState(false);
17
+ const [busy, setBusy] = useState(false);
18
+ const [error, setError] = useState('');
19
+ const [notice, setNotice] = useState('');
20
+ const anchorRef = useRef(null);
21
+ const bodyRef = useRef(null);
22
+ const [bodyMax, setBodyMax] = useState(undefined);
23
+ const panelRef = useRef(null);
24
+ const viewRef = useRef(undefined);
25
+ viewRef.current = view;
26
+ // Follow the host preference (hydrated once per page, then kept live by the
27
+ // settings toggle through the module store).
28
+ useEffect(() => {
29
+ const unsubscribe = subscribeSidebarPreference(value => setPreference(value));
30
+ void hydrateSidebarPreference(api).then(() => setPreference(readSidebarPreference()));
31
+ return unsubscribe;
32
+ }, [api]);
33
+ const enabled = preference?.usage;
34
+ const refreshMinutes = preference?.refreshMinutes ?? DEFAULT_REFRESH_MINUTES;
35
+ useEffect(() => {
36
+ if (enabled !== true || api === undefined)
37
+ return;
38
+ let alive = true;
39
+ void loadCards(api).then(next => { if (alive)
40
+ setView(next); }, reason => { if (alive)
41
+ setError(errorText(reason)); });
42
+ return () => { alive = false; };
43
+ }, [enabled, api]);
44
+ // Show exactly MAX_ROWS rows: measure the fifth row's bottom instead of
45
+ // assuming a per-row height (host font metrics and rows without a bar differ).
46
+ useLayoutEffect(() => {
47
+ const element = bodyRef.current;
48
+ if (element === null || typeof ResizeObserver === 'undefined')
49
+ return;
50
+ const measure = () => {
51
+ const fifth = element.children[MAX_ROWS - 1];
52
+ if (element.children.length <= MAX_ROWS || !(fifth instanceof HTMLElement)) {
53
+ setBodyMax(undefined);
54
+ return;
55
+ }
56
+ const height = Math.round(fifth.getBoundingClientRect().bottom - element.getBoundingClientRect().top);
57
+ if (height > 0)
58
+ setBodyMax(height);
59
+ };
60
+ measure();
61
+ const observer = new ResizeObserver(measure);
62
+ observer.observe(element);
63
+ for (const child of Array.from(element.children))
64
+ if (child instanceof HTMLElement)
65
+ observer.observe(child);
66
+ return () => observer.disconnect();
67
+ }, [enabled, open, view]);
68
+ // Returning to the page re-reads once (the 60s TTL suppresses it otherwise).
69
+ useEffect(() => {
70
+ if (enabled !== true || api === undefined)
71
+ return;
72
+ const onVisibilityChange = () => {
73
+ if (document.visibilityState !== 'visible')
74
+ return;
75
+ void loadCards(api).then(setView, () => { });
76
+ };
77
+ document.addEventListener('visibilitychange', onVisibilityChange);
78
+ return () => document.removeEventListener('visibilitychange', onVisibilityChange);
79
+ }, [enabled, api]);
80
+ // Auto refresh: only when the card is enabled and the interval is positive.
81
+ // A chained timeout avoids overlap; a hidden page only reschedules (the
82
+ // visibility listener above still re-reads once the page returns).
83
+ useEffect(() => {
84
+ if (enabled !== true || api === undefined || refreshMinutes <= 0)
85
+ return;
86
+ let timer;
87
+ let stopped = false;
88
+ const interval = refreshMinutes * 60_000;
89
+ const read = () => { void loadCards(api, { force: true }).then(setView, () => { }); };
90
+ const schedule = () => {
91
+ timer = setTimeout(() => {
92
+ if (stopped)
93
+ return;
94
+ if (document.visibilityState === 'visible')
95
+ read();
96
+ schedule();
97
+ }, interval);
98
+ };
99
+ schedule();
100
+ return () => { stopped = true; if (timer !== undefined)
101
+ clearTimeout(timer); };
102
+ }, [enabled, api, refreshMinutes]);
103
+ const railOpen = open && !wide;
104
+ const pos = useAnchoredPosition({ open: railOpen, anchorRef, panelRef, side: 'top', align: 'start', gap: 8, margin: 12 });
105
+ useDismissOnOutsidePointer(anchorRef, railOpen, setOpen, panelRef);
106
+ useEffect(() => {
107
+ if (!open)
108
+ return;
109
+ const onKeyDown = (event) => { if (event.key === 'Escape')
110
+ setOpen(false); };
111
+ document.addEventListener('keydown', onKeyDown);
112
+ return () => document.removeEventListener('keydown', onKeyDown);
113
+ }, [open]);
114
+ const refresh = async () => {
115
+ if (api === undefined || busy)
116
+ return;
117
+ setBusy(true);
118
+ setError('');
119
+ setNotice('');
120
+ try {
121
+ const result = await refreshCards(api);
122
+ if (!result.ok) {
123
+ setError(result.error);
124
+ return;
125
+ }
126
+ const previous = viewRef.current?.cards ?? [];
127
+ setView(result.view);
128
+ setNotice(unchangedReads(previous, result.view.cards) ? t('notReread') : '');
129
+ }
130
+ catch (reason) {
131
+ setError(errorText(reason));
132
+ }
133
+ finally {
134
+ setBusy(false);
135
+ }
136
+ };
137
+ const expand = () => {
138
+ setOpen(true);
139
+ if (api === undefined)
140
+ return;
141
+ void loadCards(api).then(setView, () => { });
142
+ };
143
+ const collapse = () => { setOpen(false); setNotice(''); };
144
+ const toggle = () => { if (open)
145
+ collapse();
146
+ else
147
+ expand(); };
148
+ if (enabled !== true)
149
+ return null;
150
+ const now = Date.now();
151
+ const cards = view?.cards ?? [];
152
+ const rows = sidebarRows(cards);
153
+ const latest = cards.map(cardReadTime).filter((value) => typeof value === 'string').sort().at(-1);
154
+ const status = statusText(view, error);
155
+ const style = h('style', null, settingsCss);
156
+ const head = () => h('div', { className: 'dmg-sidebar-head', onClick: toggle }, h('span', { className: 'dmg-sidebar-bird', 'aria-hidden': true }), h('span', { className: 'dmg-sidebar-title' }, t('sidebarTitle')), latest === undefined ? null : h('span', { className: 'dmg-sidebar-fresh' }, ageText(latest, now)), refreshButton(refresh, busy), h('button', {
157
+ type: 'button', className: 'dmg-sidebar-icon', 'data-dmg-sidebar-toggle': '',
158
+ 'aria-label': open ? t('sidebarCollapse') : t('sidebarExpand'), 'aria-expanded': open,
159
+ title: open ? t('sidebarCollapse') : t('sidebarExpand'),
160
+ }, chevron(open)));
161
+ if (!wide) {
162
+ return h(Fragment, null, style, h('button', {
163
+ type: 'button', className: 'dmg-sidebar-rail is-button', 'data-dmg-sidebar-rail': '',
164
+ ref: (node) => { anchorRef.current = node; },
165
+ title: t('sidebarTitle'), 'aria-label': t('sidebarTitle'), 'aria-haspopup': 'dialog', 'aria-expanded': open,
166
+ onClick: () => { if (open)
167
+ setOpen(false);
168
+ else
169
+ expand(); },
170
+ }, h('span', { className: 'dmg-sidebar-bird', 'aria-hidden': true })), open ? createPortal(h('div', {
171
+ ref: panelRef, className: 'dmg-sidebar-popover', role: 'dialog', 'aria-label': t('sidebarTitle'), 'data-dmg-sidebar-popover': '',
172
+ style: { ...(pos ?? { visibility: 'hidden', left: 0, top: 0 }), width: RAIL_POPOVER_WIDTH },
173
+ }, head(), h('div', { className: 'dmg-sidebar-rule', 'aria-hidden': true }), notice !== '' ? h('p', { className: 'dmg-sidebar-status', role: 'status' }, notice) : null, detail(cards, now, status)), document.body) : null);
174
+ }
175
+ const scrollable = rows.length > MAX_ROWS;
176
+ return h('div', { className: 'dmg-sidebar', 'data-dmg-sidebar': '', 'data-open': open }, style, h('div', { className: 'dmg-sidebar-card' }, head(), open
177
+ ? h(Fragment, null, notice !== '' ? h('p', { className: 'dmg-sidebar-status', role: 'status' }, notice) : null, h('div', { className: 'dmg-sidebar-detail', 'data-dmg-sidebar-expanded': '' }, detail(cards, now, status)))
178
+ : h(Fragment, null, h('div', {
179
+ className: `dmg-sidebar-body${scrollable ? ' is-scroll' : ''}`,
180
+ ref: (node) => { bodyRef.current = node; },
181
+ ...(bodyMax === undefined ? {} : { style: { maxHeight: bodyMax } }),
182
+ }, status !== undefined
183
+ ? h('p', { className: 'dmg-sidebar-status', role: 'status' }, status)
184
+ : rows.map(row => compactRow(row))), scrollable ? h('div', { className: 'dmg-sidebar-more', 'aria-hidden': true, 'data-dmg-sidebar-more': '' }, doubleChevron()) : null)));
185
+ }
186
+ /** One compact row: name, weekly window, reading, bar (or the failure text). */
187
+ function compactRow(row) {
188
+ const reading = rowReading(row);
189
+ const level = row.used === undefined || row.failed ? undefined : usedLevel(row.used);
190
+ return h('div', { className: 'dmg-sidebar-row', 'data-dmg-sidebar-row': row.card.provider, key: cardIdentity(row.card) }, h('div', { className: 'dmg-sidebar-row-line' }, h('span', { className: 'dmg-sidebar-name' }, row.card.name ?? row.card.provider), row.failed || row.window?.name === undefined ? null : h('span', { className: 'dmg-sidebar-window' }, row.window.name), h('span', {
191
+ className: 'dmg-sidebar-pct', 'data-reading': '', ...(level === undefined ? {} : { 'data-level': level }),
192
+ ...(row.failed || row.used !== undefined || row.display !== undefined || row.unlimited === true || row.balance === undefined
193
+ ? {}
194
+ : { 'data-balance': '', title: `${t('usageBalance')} ${row.balance}` }),
195
+ }, reading)), row.failed || row.used === undefined
196
+ ? null
197
+ : h('div', { className: 'dmg-sidebar-bar', 'aria-hidden': true }, h('i', { ...(level === undefined ? {} : { 'data-level': level }), style: { width: `${row.used}%` } })));
198
+ }
199
+ /** Expanded content: every upstream with every window, reset, balance and error. */
200
+ function detail(cards, now, status) {
201
+ if (status !== undefined)
202
+ return h('p', { className: 'dmg-sidebar-status', role: 'status' }, status);
203
+ return h(Fragment, null, ...cards.map(card => h('section', {
204
+ className: 'dmg-sidebar-prov', 'data-provider': card.provider, key: cardIdentity(card),
205
+ }, h('div', { className: 'dmg-sidebar-prov-head' }, h('strong', null, card.name ?? card.provider), h('span', { className: 'dmg-sidebar-meta' }, providerMeta(card, now))), card.error !== undefined
206
+ ? h('p', { className: 'dmg-sidebar-error' }, card.error)
207
+ : (card.windows ?? []).map((window, index) => {
208
+ const used = typeof window.used === 'number' && Number.isFinite(window.used) ? Math.min(100, Math.max(0, window.used)) : undefined;
209
+ const level = used === undefined ? undefined : usedLevel(used);
210
+ return h('div', { className: 'dmg-sidebar-win', key: index }, h('div', { className: 'dmg-sidebar-win-line' }, h('span', { className: 'dmg-sidebar-win-name' }, window.name ?? '—'), h('span', { className: 'dmg-sidebar-pct', ...(level === undefined ? {} : { 'data-level': level }) }, windowReading(window)), resetShort(window.resetsAt, now) === undefined ? null : h('span', { className: 'dmg-sidebar-reset', title: window.resetsAt }, resetShort(window.resetsAt, now))), used === undefined ? null : h('div', { className: 'dmg-sidebar-bar', 'aria-hidden': true }, h('i', { 'data-level': level, style: { width: `${used}%` } })));
211
+ }), card.resets !== undefined || card.balance !== undefined
212
+ ? h('div', { className: 'dmg-sidebar-foot' }, typeof card.resets?.count === 'number' ? h('span', null, resetLine(card.resets.count)) : null, card.resets?.until === undefined ? null : h('span', null, formatTime(card.resets.until)), card.balance === undefined ? null : h('span', null, t('usageBalance'), ' ', h('b', null, String(card.balance)))) : null)));
213
+ }
214
+ const statusText = (view, error) => {
215
+ if (error !== '')
216
+ return t('usageError');
217
+ if (view === undefined)
218
+ return t('usageReading');
219
+ if (view.status === 'forbidden')
220
+ return t('usageForbidden');
221
+ if (view.status === 'unconfigured')
222
+ return t('usageUnconfigured');
223
+ if (view.status === 'error')
224
+ return view.error ?? t('usageError');
225
+ if (view.cards.length === 0)
226
+ return t('usageEmpty');
227
+ return undefined;
228
+ };
229
+ const providerMeta = (card, now) => {
230
+ const parts = [];
231
+ if (card.plan !== undefined)
232
+ parts.push(card.plan);
233
+ const read = cardReadTime(card);
234
+ if (read !== undefined) {
235
+ const age = ageText(read, now);
236
+ if (card.asOf !== undefined)
237
+ parts.push(`${t('usageReadAtStale')} ${formatTime(read)}`);
238
+ else
239
+ parts.push(age ?? formatTime(read));
240
+ }
241
+ return parts.join(' · ');
242
+ };
243
+ const resetShort = (resetsAt, now) => formatResetShort(resetsAt, now);
244
+ const resetLine = (count) => count === 1 ? t('resetCountLineOne') : t('resetCountLineMany', { n: count });
245
+ const windowReading = (window) => {
246
+ if (window.unlimited === true)
247
+ return t('unlimited');
248
+ if (window.display !== undefined && window.display !== '')
249
+ return window.display;
250
+ if (typeof window.used === 'number' && Number.isFinite(window.used))
251
+ return formatUsed(Math.min(100, Math.max(0, window.used)));
252
+ return '—';
253
+ };
254
+ const rowReading = (row) => {
255
+ if (row.failed)
256
+ return t('usageReadingFailed');
257
+ if (row.used === undefined && row.display === undefined && row.unlimited !== true && row.balance !== undefined)
258
+ return row.balance;
259
+ return windowReading({ ...(row.used === undefined ? {} : { used: row.used }), ...(row.display === undefined ? {} : { display: row.display }), ...(row.unlimited === true ? { unlimited: true } : {}) });
260
+ };
261
+ const ageText = (readAt, now) => {
262
+ const minutes = ageMinutes(readAt, now);
263
+ if (minutes === undefined)
264
+ return undefined;
265
+ if (minutes < 1)
266
+ return t('sidebarJustNow');
267
+ if (minutes < 60)
268
+ return t('sidebarMinutesAgo', { n: minutes });
269
+ const hours = Math.floor(minutes / 60);
270
+ if (hours < 24)
271
+ return t('sidebarHoursAgo', { n: hours });
272
+ return t('sidebarDaysAgo', { n: Math.floor(hours / 24) });
273
+ };
274
+ const refreshButton = (refresh, busy) => h('button', {
275
+ type: 'button', className: 'dmg-sidebar-icon', 'data-dmg-sidebar-refresh': '', 'aria-label': t('refresh'), title: t('refresh'),
276
+ disabled: busy, onClick: (event) => { event.stopPropagation(); void refresh(); },
277
+ }, refreshIcon());
278
+ const refreshIcon = () => h('svg', { viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: '1.4', 'aria-hidden': true }, h('path', { d: 'M13 8a5 5 0 1 1-1.6-3.7' }), h('path', { d: 'M13 2.4V5h-2.6' }));
279
+ const chevron = (open) => h('svg', {
280
+ viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: '1.4', 'aria-hidden': true,
281
+ className: `dmg-sidebar-chevron${open ? ' is-open' : ''}`,
282
+ }, h('path', { d: 'M4 6.5l4 4 4-4' }));
283
+ const doubleChevron = () => h('svg', { viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: '1.4', 'aria-hidden': true }, h('path', { d: 'M4 6l4 4 4-4' }), h('path', { d: 'M4 10l4 4 4-4', opacity: '.55' }));
@@ -1,15 +1,21 @@
1
1
  import { createElement as h } from 'react';
2
- import { formatTime, readTime, windowReading } from './view-model.js';
2
+ import { expiryHint, formatTime, readTime, windowReading } from './view-model.js';
3
3
  import { t } from './locales.js';
4
4
  /**
5
5
  * One read-only usage card. Every gateway string is a React text child (never
6
6
  * HTML), absent fields are omitted, and the only control is "刷新" — there is
7
- * deliberately no reset action (the Codex reset count is display-only).
7
+ * deliberately no reset action (the Codex reset credits are display-only).
8
8
  */
9
9
  const time = (raw) => h('time', { dateTime: raw }, formatTime(raw));
10
+ /** A reset credit's expiry: the localized instant, "expires …", in one machine-readable `<time>`. */
11
+ const expiryTime = (raw) => h('time', { dateTime: raw }, t('resetCreditExpires', { date: formatTime(raw) }));
10
12
  const fact = (field, label, value) => h('span', { 'data-field': field, key: field }, h('b', null, `${label} `), h('span', { 'data-value': '' }, value));
11
13
  const scalar = (value) => typeof value === 'boolean' ? (value ? t('yes') : t('no')) : String(value);
12
14
  const present = (value) => value !== undefined && value !== null && value !== '';
15
+ /** Children on one line, separated by the page's inline separator. */
16
+ const inline = (nodes) => nodes.flatMap((node, index) => index === 0 ? [node] : [' · ', node]);
17
+ /** Which window a reset credit is for, when the gateway names one it knows. */
18
+ const windowLabel = (window) => window === 'fiveHour' ? t('resetWindowFiveHour') : window === 'weekly' ? t('resetWindowWeekly') : undefined;
13
19
  /** Bar colour: ≤70% normal (blue), above 70 and below 90 warn (orange), 90% and above high (red). */
14
20
  export const usageLevel = (used) => used >= 90 ? 'high' : used > 70 ? 'warn' : 'normal';
15
21
  function WindowRow({ window }) {
@@ -18,6 +24,41 @@ function WindowRow({ window }) {
18
24
  const used = typeof window.used === 'number' && reading?.endsWith('%') ? Math.max(0, Math.min(100, window.used)) : undefined;
19
25
  return h('div', { className: 'dmg-window', 'data-window': '' }, h('div', { className: 'dmg-window-line' }, h('span', null, present(window.name) ? h('span', { 'data-window-name': '' }, window.name) : null, present(window.name) && reading !== undefined ? ' ' : null, reading !== undefined ? h('span', { 'data-reading': '' }, reading) : null), showReset ? h('span', null, t('usageResetAt'), time(window.resetsAt)) : null), used !== undefined ? h('div', { className: 'dmg-track', 'aria-hidden': true }, h('div', { className: 'dmg-fill', 'data-level': usageLevel(used), style: { width: `${used}%` } })) : null);
20
26
  }
27
+ /** The count half of the reset summary: one count, or a count per window for plans handing out one kind each. */
28
+ function resetCount(resets) {
29
+ if (resets.byWindow === true) {
30
+ const parts = inline([
31
+ ...(typeof resets.fiveHour === 'number' && resets.fiveHour > 0 ? [h('span', { 'data-reset-five-hour': '' }, t('resetFiveHour', { n: resets.fiveHour }))] : []),
32
+ ...(typeof resets.weekly === 'number' && resets.weekly > 0 ? [h('span', { 'data-reset-weekly': '' }, t('resetWeekly', { n: resets.weekly }))] : []),
33
+ ]);
34
+ return parts.length ? h('span', { 'data-reset-count': '' }, ...parts) : null;
35
+ }
36
+ return typeof resets.count === 'number'
37
+ ? h('span', { 'data-reset-count': '' }, t('resetCreditsAvailable', { n: resets.count }))
38
+ : null;
39
+ }
40
+ /** One reset credit: which kind it is, when it runs out and, for the soonest, how far off that is. */
41
+ function resetItem(item, soonest, key) {
42
+ const at = item.until;
43
+ const hint = present(at) && soonest !== undefined && Date.parse(at) === soonest ? expiryHint(at) : undefined;
44
+ return h('div', { className: 'dmg-reset-item', 'data-reset-item': '', key }, h('span', null, windowLabel(item.window) ?? t('resetCreditFull')), h('span', null, present(at) ? expiryTime(at) : t('resetCreditExpiryUnknown'), hint === undefined ? null : [' · ', hint]));
45
+ }
46
+ /**
47
+ * The card's reset credits, folded like the subscriptions plugin does: the count
48
+ * on the summary line, every credit and its expiry when opened. Read-only —
49
+ * spending a credit is not offered here.
50
+ */
51
+ function ResetLine({ resets }) {
52
+ const each = resets.each ?? [];
53
+ const items = each.length ? each : present(resets.until) ? [{ until: resets.until }] : [];
54
+ const times = items.flatMap(item => { const at = present(item.until) ? Date.parse(item.until) : NaN; return Number.isNaN(at) ? [] : [at]; });
55
+ // The soonest credit carries the relative hint; it is the one worth not wasting.
56
+ const soonest = times.length ? Math.min(...times) : undefined;
57
+ const count = resetCount(resets);
58
+ if (count === null && items.length === 0)
59
+ return null;
60
+ return h('details', { className: 'dmg-resets', 'data-field': 'resets' }, h('summary', null, h('span', { className: 'dmg-chevron', 'aria-hidden': true }, '▸'), h('span', null, t('resetCreditsTitle')), count === null ? null : h('span', { className: 'dmg-resets-count' }, count)), items.length ? h('div', { className: 'dmg-reset-items' }, ...items.map((item, index) => resetItem(item, soonest, `reset-${index}`))) : null);
61
+ }
21
62
  export function UsageCard({ card, busy, onRefresh }) {
22
63
  const read = readTime(card);
23
64
  const facts = [
@@ -29,5 +70,5 @@ export function UsageCard({ card, busy, onRefresh }) {
29
70
  present(read) && fact('readAt', card.asOf !== undefined ? t('usageReadAtStale') : t('usageReadAt'), time(read)),
30
71
  ].filter(Boolean);
31
72
  const resets = card.resets;
32
- return h('article', { className: 'dmg-card', 'data-card': '' }, h('div', { className: 'dmg-card-title' }, h('strong', null, present(card.name) ? card.name : card.provider), h('button', { type: 'button', className: 'dmg-button', disabled: busy, onClick: onRefresh }, t('refresh'))), facts.length ? h('div', { className: 'dmg-facts' }, ...facts) : null, ...(card.windows ?? []).map((window, index) => h(WindowRow, { key: index, window })), resets && typeof resets.count === 'number' ? h('div', { className: 'dmg-facts', 'data-field': 'resets' }, h('span', null, h('b', null, t('resetCountLabel')), t('resetRemaining'), h('span', { 'data-reset-count': '' }, String(resets.count)), t('resetRemainingSuffix')), present(resets.until) ? h('span', null, h('b', null, t('resetExpiresLabel')), time(resets.until)) : null) : null, present(card.error) ? h('p', { className: 'dmg-error', 'data-field': 'error', role: 'alert' }, card.error) : null);
73
+ return h('article', { className: 'dmg-card', 'data-card': '' }, h('div', { className: 'dmg-card-title' }, h('strong', null, present(card.name) ? card.name : card.provider), h('button', { type: 'button', className: 'dmg-button', disabled: busy, onClick: onRefresh }, t('refresh'))), facts.length ? h('div', { className: 'dmg-facts' }, ...facts) : null, ...(card.windows ?? []).map((window, index) => h(WindowRow, { key: `window-${index}`, window })), resets ? h(ResetLine, { resets }) : null, present(card.error) ? h('p', { className: 'dmg-error', 'data-field': 'error', role: 'alert' }, card.error) : null);
33
74
  }
@@ -10,7 +10,7 @@ interface SlotRegistration {
10
10
  id: string;
11
11
  order: number;
12
12
  label?: () => string;
13
- inject?: (sessionId: string) => unknown;
13
+ inject?: (owner?: unknown) => unknown;
14
14
  }
15
15
  /** Minimal locale face: register the dictionaries once and bind a translator. */
16
16
  interface LocaleService {