dsh-plugin-subscription-oauth 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +413 -2
  3. package/cordis.patch.yml +13 -0
  4. package/lib/auth/device-flow.d.ts +112 -0
  5. package/lib/auth/device-flow.js +319 -0
  6. package/lib/auth/device-id.d.ts +31 -0
  7. package/lib/auth/device-id.js +58 -0
  8. package/lib/auth/flow.d.ts +33 -0
  9. package/lib/auth/flow.js +12 -0
  10. package/lib/auth/glm-flow.d.ts +99 -0
  11. package/lib/auth/glm-flow.js +194 -0
  12. package/lib/auth/headers.d.ts +61 -0
  13. package/lib/auth/headers.js +106 -0
  14. package/lib/auth/pkce-flow.d.ts +78 -0
  15. package/lib/auth/pkce-flow.js +205 -0
  16. package/lib/auth/provider-controller.d.ts +241 -0
  17. package/lib/auth/provider-controller.js +502 -0
  18. package/lib/auth/registry.d.ts +91 -0
  19. package/lib/auth/registry.js +149 -0
  20. package/lib/auth/rpc.d.ts +31 -0
  21. package/lib/auth/rpc.js +200 -0
  22. package/lib/auth/store.d.ts +99 -0
  23. package/lib/auth/store.js +213 -0
  24. package/lib/auth/tokens.d.ts +76 -0
  25. package/lib/auth/tokens.js +103 -0
  26. package/lib/client/SubscriptionsSection.d.ts +34 -0
  27. package/lib/client/SubscriptionsSection.js +742 -0
  28. package/lib/client/index.d.ts +36 -0
  29. package/lib/client/index.js +47 -0
  30. package/lib/client/locales.d.ts +187 -0
  31. package/lib/client/locales.js +185 -0
  32. package/lib/client/styles.d.ts +18 -0
  33. package/lib/client/styles.js +257 -0
  34. package/lib/client/subscriptions-rpc.d.ts +24 -0
  35. package/lib/client/subscriptions-rpc.js +44 -0
  36. package/lib/client.js +2078 -0
  37. package/lib/client.js.map +1 -0
  38. package/lib/constants.d.ts +79 -0
  39. package/lib/constants.js +91 -0
  40. package/lib/errors.d.ts +28 -0
  41. package/lib/errors.js +34 -0
  42. package/lib/http.d.ts +24 -0
  43. package/lib/http.js +98 -0
  44. package/lib/index.d.ts +57 -0
  45. package/lib/index.js +185 -0
  46. package/lib/protocol.d.ts +316 -0
  47. package/lib/protocol.js +29 -0
  48. package/lib/providers/antigravity-catalog.d.ts +53 -0
  49. package/lib/providers/antigravity-catalog.js +269 -0
  50. package/lib/providers/antigravity.d.ts +331 -0
  51. package/lib/providers/antigravity.js +821 -0
  52. package/lib/providers/auto.d.ts +151 -0
  53. package/lib/providers/auto.js +351 -0
  54. package/lib/providers/catalog.d.ts +69 -0
  55. package/lib/providers/catalog.js +78 -0
  56. package/lib/providers/common.d.ts +51 -0
  57. package/lib/providers/common.js +169 -0
  58. package/lib/providers/glm-provider.d.ts +41 -0
  59. package/lib/providers/glm-provider.js +165 -0
  60. package/lib/providers/glm.d.ts +159 -0
  61. package/lib/providers/glm.js +445 -0
  62. package/lib/providers/grok.d.ts +224 -0
  63. package/lib/providers/grok.js +700 -0
  64. package/lib/providers/kimi-provider.d.ts +45 -0
  65. package/lib/providers/kimi-provider.js +148 -0
  66. package/lib/providers/kimi.d.ts +151 -0
  67. package/lib/providers/kimi.js +400 -0
  68. package/lib/providers/responses-stream.d.ts +58 -0
  69. package/lib/providers/responses-stream.js +115 -0
  70. package/lib/providers/usage.d.ts +65 -0
  71. package/lib/providers/usage.js +309 -0
  72. package/lib/settings.d.ts +122 -0
  73. package/lib/settings.js +333 -0
  74. package/lib/translate/antigravity-schema.d.ts +24 -0
  75. package/lib/translate/antigravity-schema.js +174 -0
  76. package/lib/translate/antigravity.d.ts +184 -0
  77. package/lib/translate/antigravity.js +419 -0
  78. package/lib/translate/responses.d.ts +156 -0
  79. package/lib/translate/responses.js +395 -0
  80. package/lib/translate/sse.d.ts +27 -0
  81. package/lib/translate/sse.js +82 -0
  82. package/package.json +126 -4
@@ -0,0 +1,742 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Subscriptions settings page.
4
+ *
5
+ * One card per provider route — Kimi Code, Grok (X Premium), Google Antigravity
6
+ * — describing that route's connection, accounts, and model catalog. Every fact
7
+ * arrives from the node half's `subscription-oauth` RPC endpoints, so this
8
+ * component holds no credential state of its own. The status poll runs only
9
+ * while a login is pending, so an idle page performs no requests.
10
+ *
11
+ * Colors resolve through `--dsw-alias-*` design tokens, so the page follows the
12
+ * active theme; every user-visible string goes through the locale-bound `t`.
13
+ *
14
+ * @module dsh-plugin-subscription-oauth/client/SubscriptionsSection
15
+ */
16
+ import { useCallback, useEffect, useRef, useState } from 'react';
17
+ import { en } from './locales.js';
18
+ import { subscriptionsCss } from './styles.js';
19
+ import { SubscriptionsRpcError } from './subscriptions-rpc.js';
20
+ /** Poll cadence while a login attempt is pending. */
21
+ const POLL_INTERVAL_MS = 2000;
22
+ /**
23
+ * How often an open page re-reads every signed-in route's allowance.
24
+ *
25
+ * Slow on purpose: it is fast enough that a figure shown on screen is current, and
26
+ * slow enough that leaving the page open costs one small request a minute per
27
+ * signed-in provider.
28
+ */
29
+ const USAGE_POLL_INTERVAL_MS = 60_000;
30
+ /**
31
+ * Disclosures start folded, every time the page is opened.
32
+ *
33
+ * Nothing is remembered between visits on purpose: the section is a list of
34
+ * providers first, and a card left open last time should not decide what this
35
+ * visit looks like. A fold is a gesture within one visit, not a stored setting.
36
+ */
37
+ /** Horizontal room the page takes inside the settings panel. */
38
+ const SECTION_WIDTH = 560;
39
+ /** Minimal translator used when the locale seat is absent (standalone render). */
40
+ function fallbackTranslate(key, params) {
41
+ const template = en[key];
42
+ if (params === undefined)
43
+ return template;
44
+ return template.replace(/\{(\w+)\}/g, (match, name) => name in params ? String(params[name]) : match);
45
+ }
46
+ /** Human-readable message for any thrown value. */
47
+ function messageOf(error) {
48
+ if (error instanceof SubscriptionsRpcError)
49
+ return error.message;
50
+ return error instanceof Error ? error.message : String(error);
51
+ }
52
+ /** Local time for one epoch-millisecond instant. */
53
+ function formatTime(at) {
54
+ try {
55
+ return new Date(at).toLocaleString();
56
+ }
57
+ catch {
58
+ return String(at);
59
+ }
60
+ }
61
+ /** Compact token count for a context window. */
62
+ function formatTokens(tokens) {
63
+ if (tokens >= 1_000_000)
64
+ return `${String(Math.round(tokens / 100_000) / 10)}M`;
65
+ if (tokens >= 1_000)
66
+ return `${String(Math.round(tokens / 1_000))}K`;
67
+ return String(tokens);
68
+ }
69
+ /**
70
+ * Routes worth asking for allowances.
71
+ *
72
+ * A route with no account of its own — `auto` — reports itself as signed in so its
73
+ * card reads as usable, but it holds no allowance to read: asking it every minute
74
+ * spends a request to be told nothing. The routes it stands in for are each read
75
+ * on their own.
76
+ *
77
+ * @param providers - the routes in the current status.
78
+ * @returns the route keys to read usage for.
79
+ */
80
+ function usageTargets(providers) {
81
+ return providers
82
+ .filter((provider) => provider.loggedIn && provider.connectable !== false)
83
+ .map((provider) => provider.provider);
84
+ }
85
+ const styles = {
86
+ section: {
87
+ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: SECTION_WIDTH,
88
+ color: 'var(--dsw-alias-label-primary)',
89
+ },
90
+ intro: { margin: 0, color: 'var(--dsw-alias-label-tertiary)', fontSize: 14, lineHeight: '22px' },
91
+ card: {
92
+ border: '0.5px solid var(--dsw-alias-settings-card-stroke, var(--dsw-alias-border-l4))',
93
+ borderRadius: 'var(--dsw-radius-xl, 20px)',
94
+ background: 'var(--dsw-alias-settings-card-fill, var(--dsw-alias-bg-layer-2))',
95
+ padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 6,
96
+ },
97
+ cardHeader: { display: 'flex', alignItems: 'center', gap: 8 },
98
+ /** Everything a folded card hides. */
99
+ cardBody: { display: 'flex', flexDirection: 'column', gap: 6 },
100
+ /** The one-line state a folded card shows in place of its body. */
101
+ cardSummary: { fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-label-tertiary)' },
102
+ dot: { width: 8, height: 8, borderRadius: '50%', flexShrink: 0 },
103
+ name: { fontWeight: 500, fontSize: 14, lineHeight: '22px', color: 'var(--dsw-alias-label-primary)' },
104
+ providerId: { fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-label-dimmed)' },
105
+ statusLine: { margin: 0, fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-label-tertiary)' },
106
+ errorLine: { margin: 0, fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-state-error-primary)' },
107
+ successLine: { margin: 0, fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-state-success-primary)' },
108
+ actions: { display: 'flex', gap: 8, marginTop: 4, alignItems: 'center', flexWrap: 'wrap' },
109
+ accountRow: {
110
+ display: 'flex', flexDirection: 'column', gap: 6,
111
+ border: '0.5px solid var(--dsw-alias-border-l2)', borderRadius: 'var(--dsw-radius-lg, 16px)',
112
+ padding: '8px 10px', marginTop: 4,
113
+ },
114
+ accountHeader: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' },
115
+ starButton: {
116
+ border: 'none', padding: 0, width: 20, background: 'transparent',
117
+ font: 'inherit', fontSize: 14, lineHeight: '20px', cursor: 'pointer',
118
+ color: 'var(--dsw-alias-state-warn-label)',
119
+ },
120
+ accountName: { fontSize: 13, lineHeight: '20px', color: 'var(--dsw-alias-label-primary)', userSelect: 'all' },
121
+ badge: {
122
+ fontSize: 11, lineHeight: '16px', padding: '0 6px', borderRadius: 8,
123
+ background: 'var(--dsw-alias-bg-layer-1)', border: '0.5px solid var(--dsw-alias-border-l2)',
124
+ color: 'var(--dsw-alias-label-secondary)',
125
+ },
126
+ pushRight: { marginLeft: 'auto', flexShrink: 0 },
127
+ catalog: {
128
+ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 4,
129
+ borderTop: '0.5px solid var(--dsw-alias-border-l2)', paddingTop: 8,
130
+ },
131
+ catalogHeader: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' },
132
+ catalogTitle: { fontSize: 12, lineHeight: '18px', fontWeight: 500, color: 'var(--dsw-alias-label-secondary)' },
133
+ catalogMeta: { fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-label-tertiary)' },
134
+ /**
135
+ * Button surface geometry lives in the scoped stylesheet; only the layout a
136
+ * rule cannot express stays inline.
137
+ */
138
+ pill: {},
139
+ pillSmall: {},
140
+ /** The catalog heading: a summary-style disclosure, not a bordered control. */
141
+ disclosure: {
142
+ display: 'inline-flex', alignItems: 'center', gap: 4, margin: 0,
143
+ },
144
+ chevron: { fontSize: 10, lineHeight: '18px', color: 'var(--dsw-alias-label-tertiary)' },
145
+ modelList: { display: 'flex', flexDirection: 'column', gap: 6, marginTop: 2 },
146
+ /** One model line: a visibility toggle and the model's own metadata. */
147
+ modelRowGrow: { display: 'flex', alignItems: 'flex-start', gap: 8, flex: '1 1 auto', minWidth: 0 },
148
+ /** The pinned `auto` line: listed, switchable, and never part of its own chain. */
149
+ autoRow: {
150
+ display: 'flex', alignItems: 'flex-start', gap: 8, cursor: 'pointer',
151
+ fontSize: 13, lineHeight: '20px', color: 'var(--dsw-alias-label-primary)',
152
+ },
153
+ /** The models `auto` will walk, indented under it. */
154
+ chain: {
155
+ display: 'flex', flexDirection: 'column', gap: 3,
156
+ margin: '2px 0 4px 26px', paddingLeft: 8,
157
+ borderLeft: '0.5px solid var(--dsw-alias-border-l2)',
158
+ },
159
+ chainTitle: { fontSize: 11, lineHeight: '16px', color: 'var(--dsw-alias-label-tertiary)' },
160
+ /** The add-a-model-id row: the field, then confirm and cancel. */
161
+ addRow: { display: 'flex', alignItems: 'center', gap: 6, marginTop: 2 },
162
+ addInput: {},
163
+ /** The remove control on a hand-added row: a bare glyph beside the row. */
164
+ removeButton: { flexShrink: 0, alignSelf: 'center', padding: '0 6px' },
165
+ /** One draggable chain row: the grip, then the remove-from-auto switch. */
166
+ chainRowWrap: { display: 'flex', alignItems: 'center', gap: 4 },
167
+ chainRow: {
168
+ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer', flex: '1 1 auto',
169
+ fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-label-secondary)',
170
+ },
171
+ usage: {
172
+ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 4,
173
+ borderTop: '0.5px solid var(--dsw-alias-border-l2)', paddingTop: 8,
174
+ },
175
+ usageHeader: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' },
176
+ usageRow: { display: 'flex', flexDirection: 'column', gap: 3 },
177
+ usageLine: { display: 'flex', alignItems: 'baseline', gap: 8, justifyContent: 'space-between' },
178
+ usageMeter: {
179
+ height: 6, borderRadius: 999, background: 'var(--dsw-alias-bg-layer-3, var(--dsw-alias-bg-layer-2))',
180
+ overflow: 'hidden',
181
+ },
182
+ modelRow: {
183
+ display: 'flex', alignItems: 'flex-start', gap: 8,
184
+ cursor: 'pointer', fontSize: 13, lineHeight: '20px',
185
+ color: 'var(--dsw-alias-label-primary)',
186
+ },
187
+ /** A hidden model stays listed and editable, but reads as inactive. */
188
+ modelRowHidden: {
189
+ display: 'flex', alignItems: 'flex-start', gap: 8,
190
+ cursor: 'pointer', fontSize: 13, lineHeight: '20px',
191
+ color: 'var(--dsw-alias-label-dimmed)',
192
+ },
193
+ modelBody: { display: 'flex', flexDirection: 'column', gap: 1, minWidth: 0 },
194
+ modelName: { fontSize: 13, lineHeight: '20px' },
195
+ modelMeta: { fontSize: 11, lineHeight: '16px', color: 'var(--dsw-alias-label-tertiary)', userSelect: 'all' },
196
+ device: {
197
+ marginTop: 4, display: 'flex', flexDirection: 'column', gap: 6,
198
+ border: '0.5px solid var(--dsw-alias-border-l2)', borderRadius: 'var(--dsw-radius-lg, 16px)',
199
+ padding: '10px 12px', background: 'var(--dsw-alias-bg-layer-1)',
200
+ },
201
+ deviceTitle: { fontWeight: 500, fontSize: 13, lineHeight: '20px', color: 'var(--dsw-alias-label-primary)' },
202
+ deviceCode: {
203
+ fontFamily: 'monospace', fontSize: 18, lineHeight: '24px', letterSpacing: 2,
204
+ color: 'var(--dsw-alias-label-primary)', userSelect: 'all',
205
+ },
206
+ details: {
207
+ display: 'flex', flexDirection: 'column', gap: 4,
208
+ fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-label-tertiary)',
209
+ },
210
+ detailRow: { display: 'flex', gap: 8, justifyContent: 'space-between', alignItems: 'baseline' },
211
+ detailValue: { userSelect: 'all', overflowWrap: 'anywhere', textAlign: 'right' },
212
+ /** Dialog layout only: its surface comes from `.dsh-subscription-manager`. */
213
+ modal: {
214
+ width: 440, maxWidth: '100%', maxHeight: '85vh', overflowY: 'auto',
215
+ boxSizing: 'border-box', display: 'flex', flexDirection: 'column', gap: 10,
216
+ padding: '16px 18px', margin: 'auto',
217
+ },
218
+ modalHeader: { display: 'flex', alignItems: 'center', gap: 8 },
219
+ modalTitle: { fontWeight: 500, fontSize: 16, lineHeight: '24px', color: 'var(--dsw-alias-label-primary)' },
220
+ modalActions: { display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 2 },
221
+ divider: { borderTop: '0.5px solid var(--dsw-alias-border-l2)' },
222
+ };
223
+ /** Status dot color for one provider's connection state. */
224
+ function dotColor(status) {
225
+ if (status.busy)
226
+ return 'var(--dsw-alias-state-warn-label)';
227
+ if (status.loggedIn)
228
+ return 'var(--dsw-alias-state-success-primary)';
229
+ return 'var(--dsw-alias-label-dimmed)';
230
+ }
231
+ /** Localized label for one provider-owned detail row. */
232
+ function detailLabel(t, row) {
233
+ const key = `detail_${row.key}`;
234
+ return key in en ? t(key) : row.key;
235
+ }
236
+ /** Localized label for one metered window the provider states. */
237
+ function windowLabel(t, window) {
238
+ const key = `usage_${window.label}`;
239
+ return key in en ? t(key) : window.label;
240
+ }
241
+ /** How much of one window is spent, as a display string. */
242
+ function windowAmount(window) {
243
+ if (window.usedPercent !== undefined)
244
+ return `${String(Math.round(window.usedPercent))}%`;
245
+ if (window.used !== undefined && window.limit !== undefined)
246
+ return `${String(window.used)}/${String(window.limit)}`;
247
+ return '—';
248
+ }
249
+ /** One account's plan and metered allowances. */
250
+ function UsageBlock(props) {
251
+ const { usage, error, t, busyAction, onRefresh } = props;
252
+ return (_jsxs("div", { style: styles.usage, children: [_jsxs("div", { style: styles.usageHeader, children: [_jsx("span", { style: styles.catalogTitle, children: t('usageTitle') }), _jsx("button", { type: "button", className: "dsh-subscription-quiet", style: styles.pushRight, disabled: busyAction, onClick: onRefresh, children: busyAction ? t('refreshing') : t('usageRefresh') })] }), error !== undefined && _jsx("p", { className: "dsh-subscription-error", children: t('usageError', { message: error }) }), usage.length === 0 && error === undefined && _jsx("p", { style: styles.statusLine, children: t('usageNone') }), usage.map((entry) => (_jsxs("div", { style: styles.usageRow, children: [_jsxs("div", { style: styles.usageLine, children: [_jsxs("span", { style: styles.modelMeta, children: [entry.plan === undefined ? entry.account : t('usagePlan', { plan: entry.plan }), entry.renewsAt === undefined ? '' : ` · ${t('usageRenews', { time: formatTime(entry.renewsAt) })}`] }), _jsx("span", { style: styles.modelMeta, children: t('usageFetched', { time: formatTime(entry.fetchedAt) }) })] }), entry.windows.map((window) => (_jsxs("div", { style: styles.usageRow, children: [_jsxs("div", { style: styles.usageLine, children: [_jsxs("span", { style: styles.modelName, children: [windowLabel(t, window), ' · ', windowAmount(window)] }), _jsx("span", { style: styles.modelMeta, children: window.resetsAt === undefined ? '' : t('usageReset', { time: formatTime(window.resetsAt) }) })] }), window.usedPercent !== undefined && (_jsx("div", { className: "dsh-subscription-meter", children: _jsx("span", { style: { width: `${String(Math.min(100, Math.max(0, window.usedPercent)))}%` }, "data-exhausted": window.usedPercent >= 90 ? 'true' : 'false' }) }))] }, `${entry.account}-${window.label}`)))] }, entry.account)))] }));
253
+ }
254
+ /** The pending-login block: a device code, or a link to the sign-in page. */
255
+ function PendingLogin(props) {
256
+ const { prompt, t, copied, onCopy } = props;
257
+ return (_jsxs("div", { style: styles.device, children: [_jsx("span", { style: styles.deviceTitle, children: prompt.mode === 'device-code' ? t('deviceTitle') : t('openLogin') }), _jsx("span", { style: styles.statusLine, children: prompt.mode === 'device-code' ? t('devicePrompt') : t('openLoginHint') }), prompt.userCode !== undefined && _jsx("span", { style: styles.deviceCode, children: prompt.userCode }), _jsx("span", { style: styles.statusLine, children: t('deviceExpires', { time: formatTime(prompt.expiresAt) }) }), _jsxs("div", { style: styles.actions, children: [_jsx("button", { type: "button", onClick: () => { window.open(prompt.url, '_blank', 'noopener'); }, children: prompt.mode === 'device-code' ? t('deviceOpen') : t('openLogin') }), prompt.userCode !== undefined && (_jsx("button", { type: "button", onClick: () => { onCopy(prompt.userCode); }, children: copied ? t('deviceCopied') : t('deviceCopy') }))] })] }));
258
+ }
259
+ /** One provider's card. */
260
+ function ProviderCard(props) {
261
+ const { status, t, busyAction, message } = props;
262
+ const [copied, setCopied] = useState(false);
263
+ // Both disclosures open folded, on every visit: the choice is a gesture, not a
264
+ // stored setting. See the note beside the interval constants.
265
+ const [modelsOpen, setModelsOpen] = useState(false);
266
+ /** Whether this provider's whole card is unfolded. */
267
+ const [cardOpen, setCardOpen] = useState(false);
268
+ const [manageOpen, setManageOpen] = useState(false);
269
+ const mounted = useRef(true);
270
+ const dialog = useRef(null);
271
+ useEffect(() => {
272
+ mounted.current = true;
273
+ return () => { mounted.current = false; };
274
+ }, []);
275
+ // The dialog is a native modal, so the browser owns focus trapping and Esc.
276
+ useEffect(() => {
277
+ const element = dialog.current;
278
+ if (element === null || typeof element.showModal !== 'function')
279
+ return;
280
+ if (manageOpen && !element.open)
281
+ element.showModal();
282
+ if (!manageOpen && element.open)
283
+ element.close();
284
+ }, [manageOpen]);
285
+ const copyCode = useCallback(async (code) => {
286
+ try {
287
+ await navigator.clipboard.writeText(code);
288
+ if (mounted.current) {
289
+ setCopied(true);
290
+ setTimeout(() => { if (mounted.current)
291
+ setCopied(false); }, 2000);
292
+ }
293
+ }
294
+ catch {
295
+ // A clipboard refusal is not worth an error banner: the code is on screen
296
+ // and selectable, which is the fallback the user already has.
297
+ }
298
+ }, []);
299
+ const toggleModelsOpen = useCallback(() => {
300
+ setModelsOpen((open) => !open);
301
+ }, []);
302
+ /** Fold or unfold this provider's card. */
303
+ const toggleCardOpen = useCallback(() => {
304
+ setCardOpen((open) => !open);
305
+ }, []);
306
+ const accounts = status.accounts;
307
+ const models = status.models;
308
+ /** Whether this route signs in at all; the auto provider does not. */
309
+ const connectable = status.connectable !== false;
310
+ const hiddenCount = models.filter((model) => !model.visible).length;
311
+ /** Whether the add-a-model-id field is open, and what has been typed. */
312
+ const [adding, setAdding] = useState(false);
313
+ const [draft, setDraft] = useState('');
314
+ /** The row being dragged, so it can be dimmed while it moves. */
315
+ const [dragFrom, setDragFrom] = useState(undefined);
316
+ /**
317
+ * The dragged row, mirrored in a ref.
318
+ *
319
+ * The drop is handled by an ancestor whose handler was attached in an earlier
320
+ * render, so it must not depend on state that was set during the drag.
321
+ */
322
+ const dragFromRef = useRef(undefined);
323
+ /** The row the pointer is currently over, so a drop lands where the user aims. */
324
+ const dropTargetRef = useRef(undefined);
325
+ /** The same row in state, purely so it can be highlighted while dragging. */
326
+ const [dropTarget, setDropTarget] = useState(undefined);
327
+ /**
328
+ * Commit one dropped arrangement.
329
+ *
330
+ * The node half takes the full chain in its new order and appends whatever the
331
+ * page left out, so sending just these ids is enough — and a model hidden in
332
+ * another window is never dropped from the stored rank by a stale page.
333
+ */
334
+ const commitOrder = useCallback((ids) => {
335
+ props.onSetModelOrder(ids);
336
+ }, [props]);
337
+ /** Drag-and-drop plus its keyboard equivalent on the drag handle. */
338
+ const drag = {
339
+ start: (event, index) => {
340
+ setDragFrom(index);
341
+ dragFromRef.current = index;
342
+ event.dataTransfer.effectAllowed = 'move';
343
+ // Firefox refuses to start a drag without payload.
344
+ event.dataTransfer.setData('text/plain', String(index));
345
+ },
346
+ over: (event, _index) => {
347
+ // A drop is only possible on a target that cancels the default.
348
+ event.preventDefault();
349
+ event.dataTransfer.dropEffect = 'move';
350
+ const container = event.currentTarget;
351
+ const to = indexAt(container, event.clientY);
352
+ dropTargetRef.current = to;
353
+ if (to !== undefined && to !== dropTarget)
354
+ setDropTarget(to);
355
+ },
356
+ drop: (event, index) => {
357
+ event.preventDefault();
358
+ // The target comes from the last `dragover`, not from this event's own
359
+ // coordinates: `drop` is not guaranteed to carry the pointer position, and a
360
+ // zero there silently collapsed every drop onto the first row.
361
+ const to = dropTargetRef.current ?? indexAt(event.currentTarget, event.clientY) ?? index;
362
+ move(dragFromRef.current, to);
363
+ dragFromRef.current = undefined;
364
+ dropTargetRef.current = undefined;
365
+ setDropTarget(undefined);
366
+ setDragFrom(undefined);
367
+ },
368
+ /** A drop anywhere in the list lands on the row under the pointer. */
369
+ dropAt: (event) => {
370
+ event.preventDefault();
371
+ const container = event.currentTarget;
372
+ const to = dropTargetRef.current ?? indexAt(container, event.clientY);
373
+ if (to !== undefined)
374
+ move(dragFromRef.current, to);
375
+ dragFromRef.current = undefined;
376
+ dropTargetRef.current = undefined;
377
+ setDropTarget(undefined);
378
+ setDragFrom(undefined);
379
+ },
380
+ end: () => {
381
+ dragFromRef.current = undefined;
382
+ dropTargetRef.current = undefined;
383
+ setDropTarget(undefined);
384
+ setDragFrom(undefined);
385
+ },
386
+ key: (event, index) => {
387
+ if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown')
388
+ return;
389
+ event.preventDefault();
390
+ move(index, index + (event.key === 'ArrowUp' ? -1 : 1));
391
+ },
392
+ };
393
+ /** Move one model row to another position and persist the result. */
394
+ function move(from, to) {
395
+ if (from === undefined || from === to)
396
+ return;
397
+ if (from < 0 || to < 0 || from >= models.length || to >= models.length)
398
+ return;
399
+ const ids = models.map((model) => model.id);
400
+ const [moved] = ids.splice(from, 1);
401
+ if (moved === undefined)
402
+ return;
403
+ ids.splice(to, 0, moved);
404
+ commitOrder(ids);
405
+ }
406
+ /**
407
+ * The row index a drop at this pointer position lands on.
408
+ *
409
+ * The drop is handled on the whole list rather than per row: rows are tall and
410
+ * separated by gaps, so a drop between two of them hits no target at all and
411
+ * silently does nothing. Comparing the pointer against each row's midpoint also
412
+ * makes the gesture match what the user sees.
413
+ */
414
+ function indexAt(container, clientY) {
415
+ const rows = Array.from(container.querySelectorAll('[data-model-index]'));
416
+ if (rows.length === 0)
417
+ return undefined;
418
+ let closest = 0;
419
+ let best = Number.POSITIVE_INFINITY;
420
+ for (const row of rows) {
421
+ const box = row.getBoundingClientRect();
422
+ const distance = Math.abs(clientY - (box.top + box.height / 2));
423
+ if (distance >= best)
424
+ continue;
425
+ best = distance;
426
+ closest = Number(row.dataset.modelIndex);
427
+ }
428
+ return closest;
429
+ }
430
+ const busy = status.busy;
431
+ const liveCatalog = (status.discovered?.length ?? 0) > 0;
432
+ /**
433
+ * What a folded card says about itself.
434
+ *
435
+ * The connection is the reason to open a card, so the folded row carries it;
436
+ * `auto` has no account and counts the models it will actually try instead.
437
+ */
438
+ const summaryLine = !connectable
439
+ ? t('statusAuto', { count: models.filter((model) => model.visible).length })
440
+ : busy
441
+ ? t('statusBusy')
442
+ : status.loggedIn
443
+ ? t('statusConnected', { count: accounts.length })
444
+ : t('statusSignedOut');
445
+ return (_jsxs("div", { style: styles.card, children: [_jsxs("div", { style: styles.cardHeader, children: [_jsx("span", { style: { ...styles.dot, background: dotColor(status) }, "aria-hidden": "true" }), _jsx("span", { style: styles.name, children: status.displayName }), _jsx("span", { style: styles.providerId, children: status.provider }), !cardOpen && _jsx("span", { style: styles.cardSummary, children: summaryLine }), _jsx("button", { type: "button", className: "dsh-subscription-quiet", style: styles.pushRight, "aria-expanded": cardOpen, "aria-controls": `dsh-subscription-card-${status.provider}`, onClick: toggleCardOpen, children: cardOpen ? t('collapse') : t('edit') })] }), _jsxs("div", { id: `dsh-subscription-card-${status.provider}`, style: cardOpen ? styles.cardBody : { ...styles.cardBody, display: 'none' }, children: [_jsx("p", { style: styles.statusLine, children: !connectable
446
+ // `auto` has no account: it stands in for the models the other routes
447
+ // signed in for, so it counts the candidates instead.
448
+ ? t('statusAuto', { count: models.filter((model) => model.visible).length })
449
+ : busy
450
+ ? t('statusBusy')
451
+ : status.loggedIn
452
+ ? t('statusConnected', { count: accounts.length })
453
+ : t('statusSignedOut') }), connectable && !status.loggedIn && !busy && _jsx("p", { style: styles.statusLine, children: t('statusSignIn') }), status.lastError !== undefined && (_jsx("p", { className: "dsh-subscription-error", children: t('lastError', { message: status.lastError }) })), message?.tone === 'error' && _jsx("p", { className: "dsh-subscription-error", children: message.text }), message?.tone === 'success' && _jsx("p", { className: "dsh-subscription-success", children: message.text }), accounts.map((account) => (_jsx("div", { style: styles.accountRow, children: _jsxs("div", { style: styles.accountHeader, children: [_jsx("button", { type: "button", style: styles.starButton, title: account.isDefault ? t('defaultBadge') : t('makeDefault'), "aria-label": account.isDefault ? t('defaultBadge') : t('makeDefault'), disabled: busyAction || account.isDefault, onClick: () => { props.onSetDefault(account.account); }, children: account.isDefault ? '★' : '☆' }), _jsx("span", { style: styles.accountName, children: account.label }), account.isDefault && _jsx("span", { style: styles.badge, children: t('defaultBadge') }), account.needsLogin === true && (_jsx("span", { style: styles.badge, title: t('accountNeedsLoginHint'), children: t('accountNeedsLogin') })), _jsx("span", { style: styles.statusLine, children: account.expired
454
+ ? t('accountExpired')
455
+ : t('accountExpires', { time: formatTime(account.expiresAt) }) }), _jsx("button", { type: "button", className: "dsh-subscription-quiet", style: styles.pushRight, disabled: busyAction, onClick: () => { props.onLogout(account.account); }, children: t('logout') })] }) }, account.account))), status.loggedIn && (_jsxs("div", { style: styles.catalog, children: [_jsxs("div", { style: styles.catalogHeader, children: [_jsxs("button", { type: "button", className: "dsh-subscription-disclosure", style: styles.disclosure, "aria-expanded": modelsOpen, "aria-controls": `dsh-subscription-models-${status.provider}`, onClick: toggleModelsOpen, children: [_jsx("span", { style: styles.chevron, "aria-hidden": "true", children: modelsOpen ? '▾' : '▸' }), _jsx("span", { style: styles.catalogTitle, children: t('modelsTitle') })] }), _jsxs("span", { style: styles.catalogMeta, children: [t('modelsCount', { count: models.length }), ' · ', liveCatalog ? t('modelsLive') : t('modelsBuiltIn'), hiddenCount === 0 ? '' : ` ${t('modelsHiddenCount', { count: hiddenCount })}`] }), _jsx("button", { type: "button", className: "dsh-subscription-quiet", style: styles.pushRight, disabled: busyAction, onClick: props.onRefreshModels, children: busyAction ? t('refreshing') : t('refresh') }), _jsx("button", { type: "button", className: "dsh-subscription-quiet", disabled: busyAction || !connectable, title: t('modelsAddHint'), onClick: () => { setAdding((open) => !open); }, children: t('modelsAdd') })] }), adding && (_jsxs("form", { style: styles.addRow, onSubmit: (event) => {
456
+ event.preventDefault();
457
+ const id = draft.trim();
458
+ if (id.length === 0)
459
+ return;
460
+ props.onAddModel(id);
461
+ setDraft('');
462
+ setAdding(false);
463
+ }, children: [_jsx("input", { type: "text", className: "dsh-subscription-input", style: styles.addInput, value: draft, autoFocus: true, spellCheck: false, placeholder: t('modelsAddPlaceholder'), "aria-label": t('modelsAdd'), disabled: busyAction, onChange: (event) => { setDraft(event.target.value); } }), _jsx("button", { type: "submit", disabled: busyAction || draft.trim().length === 0, children: t('modelsAddConfirm') }), _jsx("button", { type: "button", className: "dsh-subscription-quiet", onClick: () => { setAdding(false); setDraft(''); }, children: t('cancel') })] })), modelsOpen && (_jsxs("div", { id: `dsh-subscription-models-${status.provider}`, style: styles.modelList,
464
+ // The whole list is the drop target, so a release between two rows still
465
+ // lands somewhere instead of being cancelled unnoticed.
466
+ onDragOver: (event) => { drag.over(event, 0); }, onDrop: drag.dropAt, children: [status.provider === 'auto' && _jsx("span", { style: styles.chainTitle, children: t('modelsAutoChain') }), models.map((model, index) => (_jsxs("div", { "data-model-index": index, draggable: !busyAction, style: {
467
+ ...styles.modelRowWrap,
468
+ ...dragFrom === index ? { opacity: 0.5 } : {},
469
+ // The row a drop would land on, so the gesture is visible while it
470
+ // happens rather than only in its result.
471
+ ...dropTarget === index && dragFrom !== undefined && dragFrom !== index
472
+ ? { boxShadow: 'inset 0 2px 0 0 var(--dsw-alias-brand-primary)' }
473
+ : {},
474
+ }, onDragStart: (event) => { drag.start(event, index); }, onDragEnd: drag.end, children: [_jsx("button", { type: "button", className: "dsh-subscription-handle", disabled: busyAction, title: t('modelsDrag'), "aria-label": `${t('modelsDrag')} · ${model.name}`, onKeyDown: (event) => { drag.key(event, index); }, children: "\u283F" }), _jsxs("label", { style: model.visible
475
+ ? { ...styles.modelRowGrow, cursor: 'pointer' }
476
+ : { ...styles.modelRowGrow, cursor: 'pointer', color: 'var(--dsw-alias-label-dimmed)' }, title: model.visible ? t('modelsHidden') : t('modelsShow'), children: [_jsx("input", { type: "checkbox", className: "dsh-subscription-switch", role: "switch", checked: model.visible, disabled: busyAction, "aria-label": `${t('modelsShow')} · ${model.name}`, onChange: (event) => { props.onSetModelVisible(model.id, event.target.checked); } }), _jsxs("span", { style: styles.modelBody, children: [_jsxs("span", { style: styles.modelName, children: [model.name, model.source === undefined ? null : _jsx("span", { style: styles.badge, children: model.source })] }), _jsxs("span", { style: styles.modelMeta, children: [model.id, ' · ', t('modelsContext', { tokens: formatTokens(model.contextWindow) }), model.defaultEffort === undefined ? '' : ` · ${model.defaultEffort}`, model.inputModalities === undefined || model.inputModalities.length === 0
477
+ ? ''
478
+ : ` · ${model.inputModalities.join('/')}`] })] })] }), model.userAdded === true && (_jsx("button", { type: "button", className: "dsh-subscription-quiet", style: styles.removeButton, disabled: busyAction, title: t('modelsRemove'), "aria-label": `${t('modelsRemove')} · ${model.name}`, onClick: () => { props.onRemoveModel(model.id); }, children: "\u00D7" }))] }, model.id))), models.length === 0 && (_jsx("p", { style: styles.statusLine, children: status.provider === 'auto' ? t('modelsAutoChainEmpty') : t('modelsEmpty') }))] }))] })), _jsx(UsageBlock, { usage: status.usage ?? [], ...status.usageError === undefined ? {} : { error: status.usageError }, t: t, busyAction: busyAction, onRefresh: props.onRefreshUsage }), connectable && (_jsxs("div", { style: styles.actions, children: [!busy && (_jsx("button", { type: "button", disabled: busyAction, onClick: props.onLogin, children: accounts.length === 0 ? t('connect') : t('addAccount') })), !busy && (_jsx("button", { type: "button", disabled: busyAction, onClick: () => { setManageOpen(true); }, children: t('manage') })), busy && (_jsx("button", { type: "button", disabled: busyAction, onClick: props.onCancel, children: t('cancel') }))] })), connectable && accounts.length > 0 && _jsx("p", { style: styles.statusLine, children: t('hint') }), busy && status.pending !== undefined && (_jsx(PendingLogin, { prompt: status.pending, t: t, copied: copied, onCopy: (code) => { void copyCode(code); } }))] }), _jsxs("dialog", { ref: dialog, className: "dsh-subscription-manager", "aria-labelledby": `dsh-subscription-manage-${status.provider}`,
479
+ // A closed `<dialog>` is hidden by the user-agent stylesheet, and an
480
+ // inline `display` would override that rule — so the open state has to
481
+ // be restated here rather than left to `showModal()` alone.
482
+ style: manageOpen ? styles.modal : { ...styles.modal, display: 'none' }, onClose: () => { setManageOpen(false); }, onCancel: () => { setManageOpen(false); }, children: [_jsx("div", { style: styles.modalHeader, children: _jsx("span", { style: styles.modalTitle, id: `dsh-subscription-manage-${status.provider}`, children: t('manageTitle', { provider: status.displayName }) }) }), _jsx("span", { style: styles.statusLine, children: t('manageAccounts') }), accounts.map((account) => (_jsxs("div", { style: styles.accountRow, children: [_jsxs("div", { style: styles.accountHeader, children: [_jsx("span", { style: styles.accountName, children: account.label }), account.isDefault && _jsx("span", { style: styles.badge, children: t('defaultBadge') })] }), _jsxs("div", { style: styles.actions, children: [_jsx("button", { type: "button", disabled: busyAction, onClick: () => { props.onRefreshToken(account.account); }, children: t('refreshToken') }), !account.isDefault && (_jsx("button", { type: "button", disabled: busyAction, onClick: () => { props.onSetDefault(account.account); }, children: t('makeDefault') })), _jsx("button", { type: "button", disabled: busyAction, onClick: () => { props.onLogout(account.account); }, children: t('logout') })] })] }, account.account))), accounts.length === 0 && _jsx("p", { style: styles.statusLine, children: t('statusSignedOut') }), _jsx("div", { style: styles.divider }), _jsx("div", { style: styles.actions, children: _jsx("button", { type: "button", disabled: busyAction, onClick: props.onReload, children: t('reload') }) }), _jsx("p", { style: styles.statusLine, children: t('reloadHint') }), _jsx("div", { style: styles.divider }), _jsxs("div", { style: styles.details, children: [status.details.map((row) => (_jsxs("div", { style: styles.detailRow, children: [_jsx("span", { children: detailLabel(t, row) }), _jsx("span", { style: styles.detailValue, children: row.value })] }, row.key))), status.discovered !== undefined && status.discovered.length > 0 && (_jsxs("div", { style: styles.detailRow, children: [_jsx("span", { children: t('modelsLive') }), _jsx("span", { style: styles.detailValue, children: status.discovered.join(', ') })] }))] }), _jsx("div", { className: "dsh-subscription-dialog-actions", style: styles.modalActions, children: _jsx("button", { type: "button", onClick: () => { setManageOpen(false); }, children: t('close') }) })] })] }));
483
+ }
484
+ /**
485
+ * The Subscriptions settings section.
486
+ * @param props - section props (inject face plus the shell's `close`).
487
+ * @returns the section content.
488
+ */
489
+ export function SubscriptionsSection(props) {
490
+ const { call } = props;
491
+ const t = props.t ?? fallbackTranslate;
492
+ const [status, setStatus] = useState(undefined);
493
+ const [error, setError] = useState(undefined);
494
+ const [messages, setMessages] = useState({});
495
+ const [busyAction, setBusyAction] = useState(false);
496
+ const mounted = useRef(true);
497
+ const timer = useRef(undefined);
498
+ /** Fetch the current state; a failure is shown rather than swallowed. */
499
+ const refresh = useCallback(async () => {
500
+ if (call === undefined)
501
+ return;
502
+ try {
503
+ const next = await call('status', undefined);
504
+ if (!mounted.current)
505
+ return;
506
+ setStatus(next);
507
+ setError(undefined);
508
+ }
509
+ catch (caught) {
510
+ if (mounted.current)
511
+ setError(t('actionFailed', { message: messageOf(caught) }));
512
+ }
513
+ }, [call, t]);
514
+ const stopPolling = useCallback(() => {
515
+ if (timer.current !== undefined) {
516
+ clearInterval(timer.current);
517
+ timer.current = undefined;
518
+ }
519
+ }, []);
520
+ const startPolling = useCallback(() => {
521
+ if (timer.current !== undefined)
522
+ return;
523
+ // The node half clears `busy` once the attempt settles; the effect below
524
+ // observes that and stops this interval.
525
+ timer.current = setInterval(() => { void refresh(); }, POLL_INTERVAL_MS);
526
+ }, [refresh]);
527
+ useEffect(() => {
528
+ mounted.current = true;
529
+ void refresh();
530
+ return () => {
531
+ mounted.current = false;
532
+ stopPolling();
533
+ };
534
+ }, [refresh, stopPolling]);
535
+ /** Every route worth reading, as of the latest render, for the usage poll. */
536
+ const signedInRef = useRef([]);
537
+ useEffect(() => {
538
+ signedInRef.current = usageTargets(status?.providers ?? []);
539
+ });
540
+ /**
541
+ * Read each named route's plan and allowance, applying every answer.
542
+ *
543
+ * A read is not forced: the node half reuses a figure only for a few seconds and
544
+ * never reuses a substitute, so a steady interval refreshes the figures while
545
+ * collapsing bursts into one request per route.
546
+ */
547
+ const readUsage = useCallback(async (providers) => {
548
+ if (call === undefined)
549
+ return;
550
+ for (const provider of providers) {
551
+ try {
552
+ const result = await call('usage', { provider });
553
+ if (!mounted.current)
554
+ return;
555
+ setStatus(result.status);
556
+ }
557
+ catch {
558
+ // An unreadable quota is already surfaced inside the card by the status it
559
+ // carries; the page itself stays usable.
560
+ }
561
+ }
562
+ }, [call]);
563
+ // The figures are read as soon as the first status lands...
564
+ const usageRequested = useRef(false);
565
+ useEffect(() => {
566
+ if (call === undefined || status === undefined || usageRequested.current)
567
+ return;
568
+ const signedIn = usageTargets(status.providers);
569
+ if (signedIn.length === 0)
570
+ return;
571
+ usageRequested.current = true;
572
+ void readUsage(signedIn);
573
+ }, [call, status, readUsage]);
574
+ // ...and then kept current while the page is open. Allowances move as the account
575
+ // is used, so a figure that is only read on mount reports the state of a page that
576
+ // may have been open for hours.
577
+ useEffect(() => {
578
+ if (call === undefined)
579
+ return;
580
+ const tick = () => {
581
+ // Nothing is refreshed for a page nobody is looking at.
582
+ if (globalThis.document?.visibilityState === 'hidden')
583
+ return;
584
+ void readUsage(signedInRef.current);
585
+ };
586
+ const interval = setInterval(tick, USAGE_POLL_INTERVAL_MS);
587
+ return () => { clearInterval(interval); };
588
+ }, [call, readUsage]);
589
+ // Poll only while some provider is waiting for browser approval.
590
+ const anyBusy = status?.providers.some((provider) => provider.busy) === true;
591
+ useEffect(() => {
592
+ if (anyBusy)
593
+ startPolling();
594
+ else
595
+ stopPolling();
596
+ }, [anyBusy, startPolling, stopPolling]);
597
+ /** Run one action for one provider, reporting its own failure. */
598
+ const act = useCallback(async (provider, run) => {
599
+ setBusyAction(true);
600
+ setMessages((current) => {
601
+ const next = { ...current };
602
+ delete next[provider];
603
+ return next;
604
+ });
605
+ try {
606
+ await run();
607
+ }
608
+ catch (caught) {
609
+ if (mounted.current) {
610
+ setMessages((current) => ({ ...current, [provider]: { tone: 'error', text: t('actionFailed', { message: messageOf(caught) }) } }));
611
+ }
612
+ }
613
+ finally {
614
+ if (mounted.current)
615
+ setBusyAction(false);
616
+ }
617
+ }, [t]);
618
+ const login = useCallback((provider) => {
619
+ if (call === undefined)
620
+ return;
621
+ void act(provider, async () => {
622
+ const started = await call('login', { provider });
623
+ if (!mounted.current)
624
+ return;
625
+ setStatus(started.status);
626
+ // A loopback flow wants the browser opened; a blocked popup still leaves
627
+ // the link in the card, which is the fallback the user already has.
628
+ if (started.prompt.mode === 'open-url') {
629
+ window.open(started.prompt.url, '_blank', 'noopener');
630
+ }
631
+ });
632
+ }, [act, call]);
633
+ const cancel = useCallback((provider) => {
634
+ if (call === undefined)
635
+ return;
636
+ void act(provider, async () => {
637
+ const next = await call('cancel', { provider });
638
+ if (mounted.current)
639
+ setStatus(next);
640
+ });
641
+ }, [act, call]);
642
+ const logout = useCallback((provider, account) => {
643
+ if (call === undefined)
644
+ return;
645
+ void act(provider, async () => {
646
+ const next = await call('logout', { provider, account });
647
+ if (mounted.current)
648
+ setStatus(next);
649
+ });
650
+ }, [act, call]);
651
+ const setDefault = useCallback((provider, account) => {
652
+ if (call === undefined)
653
+ return;
654
+ void act(provider, async () => {
655
+ const next = await call('setDefault', { provider, account });
656
+ if (mounted.current)
657
+ setStatus(next);
658
+ });
659
+ }, [act, call]);
660
+ const refreshToken = useCallback((provider, account) => {
661
+ if (call === undefined)
662
+ return;
663
+ void act(provider, async () => {
664
+ const result = await call('refresh', { provider, account });
665
+ if (!mounted.current)
666
+ return;
667
+ setStatus(result.status);
668
+ setMessages((current) => ({
669
+ ...current,
670
+ [provider]: { tone: 'success', text: t('tokenRefreshed', { time: formatTime(result.expiresAt) }) },
671
+ }));
672
+ });
673
+ }, [act, call, t]);
674
+ const refreshModels = useCallback((provider) => {
675
+ if (call === undefined)
676
+ return;
677
+ void act(provider, async () => {
678
+ const next = await call('discover', { provider, force: true });
679
+ if (!mounted.current)
680
+ return;
681
+ setStatus(next);
682
+ setMessages((current) => ({ ...current, [provider]: { tone: 'success', text: t('modelsRefreshed') } }));
683
+ });
684
+ }, [act, call, t]);
685
+ const setModelVisible = useCallback((provider, id, visible) => {
686
+ if (call === undefined)
687
+ return;
688
+ void act(provider, async () => {
689
+ const next = await call('setModelVisible', { provider, id, visible });
690
+ if (mounted.current)
691
+ setStatus(next);
692
+ });
693
+ }, [act, call]);
694
+ const refreshUsage = useCallback((provider) => {
695
+ if (call === undefined)
696
+ return;
697
+ void act(provider, async () => {
698
+ const result = await call('usage', { provider, force: true });
699
+ if (mounted.current)
700
+ setStatus(result.status);
701
+ });
702
+ }, [act, call]);
703
+ const setModelOrder = useCallback((provider, ids) => {
704
+ if (call === undefined)
705
+ return;
706
+ void act(provider, async () => {
707
+ const next = await call('setModelOrder', { provider, ids: [...ids] });
708
+ if (mounted.current)
709
+ setStatus(next);
710
+ });
711
+ }, [act, call]);
712
+ const addModel = useCallback((provider, id) => {
713
+ if (call === undefined)
714
+ return;
715
+ void act(provider, async () => {
716
+ const next = await call('addModel', { provider, id });
717
+ if (mounted.current)
718
+ setStatus(next);
719
+ });
720
+ }, [act, call]);
721
+ const removeModel = useCallback((provider, id) => {
722
+ if (call === undefined)
723
+ return;
724
+ void act(provider, async () => {
725
+ const next = await call('removeModel', { provider, id });
726
+ if (mounted.current)
727
+ setStatus(next);
728
+ });
729
+ }, [act, call]);
730
+ const reload = useCallback((provider) => {
731
+ if (call === undefined)
732
+ return;
733
+ void act(provider, async () => {
734
+ const next = await call('reload', undefined);
735
+ if (mounted.current)
736
+ setStatus(next);
737
+ });
738
+ }, [act, call]);
739
+ if (call === undefined)
740
+ return _jsx("p", { style: styles.intro, children: t('unavailable') });
741
+ return (_jsxs("div", { className: "dsh-subscriptions-settings", style: styles.section, children: [_jsx("style", { children: subscriptionsCss }), _jsx("p", { style: styles.intro, children: t('intro') }), error !== undefined && _jsx("p", { className: "dsh-subscription-error", children: error }), status === undefined && _jsx("p", { style: styles.statusLine, children: t('loading') }), status?.providers.map((provider) => (_jsx(ProviderCard, { status: provider, t: t, busyAction: busyAction, ...messages[provider.provider] === undefined ? {} : { message: messages[provider.provider] }, onLogin: () => { login(provider.provider); }, onCancel: () => { cancel(provider.provider); }, onLogout: (account) => { logout(provider.provider, account); }, onSetDefault: (account) => { setDefault(provider.provider, account); }, onRefreshToken: (account) => { refreshToken(provider.provider, account); }, onRefreshModels: () => { refreshModels(provider.provider); }, onSetModelVisible: (id, visible) => { setModelVisible(provider.provider, id, visible); }, onSetModelOrder: (ids) => { setModelOrder(provider.provider, ids); }, onAddModel: (id) => { addModel(provider.provider, id); }, onRemoveModel: (id) => { removeModel(provider.provider, id); }, onRefreshUsage: () => { refreshUsage(provider.provider); }, onReload: () => { reload(provider.provider); } }, provider.provider)))] }));
742
+ }