dsh-plugin-subscriptions 0.5.2 → 0.6.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 (60) hide show
  1. package/README.md +79 -5
  2. package/README.zh.md +78 -4
  3. package/lib/auth/rpc.d.ts +64 -13
  4. package/lib/auth/rpc.js +75 -10
  5. package/lib/auth/store.d.ts +75 -17
  6. package/lib/auth/store.js +148 -27
  7. package/lib/client/ImageGenerateToolview.d.ts +1 -1
  8. package/lib/client/SpeedSelect.d.ts +25 -2
  9. package/lib/client/SpeedSelect.js +10 -6
  10. package/lib/client/SubscriptionsSection.d.ts +83 -3
  11. package/lib/client/SubscriptionsSection.js +411 -62
  12. package/lib/client/VideoGenerateToolview.d.ts +1 -1
  13. package/lib/client/index.d.ts +1 -9
  14. package/lib/client/index.js +7 -4
  15. package/lib/client/locales.d.ts +46 -10
  16. package/lib/client/locales.js +46 -10
  17. package/lib/client.js +703 -132
  18. package/lib/client.js.map +1 -1
  19. package/lib/compat.d.ts +36 -0
  20. package/lib/compat.js +20 -0
  21. package/lib/index.d.ts +26 -1
  22. package/lib/index.js +2377 -309
  23. package/lib/model-defaults.d.ts +23 -0
  24. package/lib/model-defaults.js +237 -0
  25. package/lib/providers/accounts.d.ts +102 -0
  26. package/lib/providers/accounts.js +123 -0
  27. package/lib/providers/claude.d.ts +46 -7
  28. package/lib/providers/claude.js +125 -34
  29. package/lib/providers/codex.d.ts +45 -3
  30. package/lib/providers/codex.js +152 -26
  31. package/lib/providers/common.d.ts +87 -6
  32. package/lib/providers/common.js +185 -22
  33. package/lib/providers/copilot.d.ts +32 -3
  34. package/lib/providers/copilot.js +111 -19
  35. package/lib/providers/grok.d.ts +45 -4
  36. package/lib/providers/grok.js +136 -20
  37. package/lib/providers/pool-family.d.ts +56 -0
  38. package/lib/providers/pool-family.js +45 -0
  39. package/lib/providers/pool-health.d.ts +74 -0
  40. package/lib/providers/pool-health.js +148 -0
  41. package/lib/providers/pool-usage.d.ts +78 -0
  42. package/lib/providers/pool-usage.js +185 -0
  43. package/lib/providers/pool.d.ts +107 -0
  44. package/lib/providers/pool.js +371 -0
  45. package/lib/providers/rate-limit.d.ts +192 -0
  46. package/lib/providers/rate-limit.js +338 -0
  47. package/lib/tools/image-generate.d.ts +3 -3
  48. package/lib/tools/image-generate.js +2 -1
  49. package/lib/tools/video-generate.d.ts +2 -2
  50. package/lib/tools/video-generate.js +2 -1
  51. package/lib/tools/x-search.d.ts +2 -2
  52. package/lib/tools/x-search.js +2 -1
  53. package/lib/translate/anthropic.js +5 -4
  54. package/lib/translate/chat-completions.js +5 -4
  55. package/lib/translate/responses.js +5 -4
  56. package/package.json +21 -21
  57. package/lib/providers/antigravity.d.ts +0 -90
  58. package/lib/providers/antigravity.js +0 -392
  59. package/lib/translate/antigravity.d.ts +0 -110
  60. package/lib/translate/antigravity.js +0 -303
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  /**
3
3
  * Subscriptions settings section: one card per subscription provider with an
4
4
  * OAuth login/logout flow driven by the node half's `/subscriptions-auth` RPC
@@ -19,6 +19,17 @@ import { en } from './locales.js';
19
19
  const SUBSCRIPTIONS_AUTH_CHANNEL = '/subscriptions-auth';
20
20
  /** Poll cadence while a provider login attempt is busy. */
21
21
  const POLL_INTERVAL_MS = 2000;
22
+ /**
23
+ * Model count above which the expanded default-effort list also offers a name
24
+ * filter; below it the list is short enough to scan.
25
+ */
26
+ const MODEL_FILTER_THRESHOLD = 8;
27
+ /**
28
+ * Height cap of the expanded default-effort list, in px. Past it the list
29
+ * scrolls internally so a provider with dozens of models cannot stretch the
30
+ * card (roughly 7 rows, which keeps the next card's header on screen).
31
+ */
32
+ const MODEL_LIST_MAX_HEIGHT = 260;
22
33
  /** Card display metadata, in page order (names are brand names, not translated). */
23
34
  const PROVIDERS = [
24
35
  { id: 'codex', name: 'Codex (ChatGPT)' },
@@ -54,6 +65,16 @@ export async function callSubscriptionsAuth(rpc, endpoint, payload) {
54
65
  function messageOf(error) {
55
66
  return error instanceof Error ? error.message : String(error);
56
67
  }
68
+ /** Copy a keyed map without the entries whose key is not in `live`. */
69
+ function dropStale(map, live) {
70
+ const stale = Object.keys(map).filter(key => !live.has(key));
71
+ if (stale.length === 0)
72
+ return map;
73
+ const next = { ...map };
74
+ for (const key of stale)
75
+ delete next[key];
76
+ return next;
77
+ }
57
78
  /**
58
79
  * English-dictionary fallback for a missing inject `t` (standalone renders);
59
80
  * the slot inject always supplies the locale-bound one.
@@ -114,11 +135,63 @@ const styles = {
114
135
  display: 'flex', justifyContent: 'space-between', gap: 8,
115
136
  fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-label-tertiary)',
116
137
  },
138
+ accountRow: {
139
+ display: 'flex', flexDirection: 'column', gap: 6,
140
+ border: '1px solid var(--dsw-alias-border-l2)', borderRadius: 8,
141
+ padding: '8px 10px', marginTop: 4,
142
+ },
143
+ accountHeader: { display: 'flex', alignItems: 'center', gap: 8 },
144
+ accountName: { fontSize: 13, lineHeight: '20px', color: 'var(--dsw-alias-label-primary)', userSelect: 'all' },
145
+ starButton: {
146
+ border: 'none', background: 'transparent', padding: 0,
147
+ font: 'inherit', fontSize: 14, lineHeight: '20px', cursor: 'pointer',
148
+ color: 'var(--dsw-alias-state-warn-label)',
149
+ },
117
150
  usageTrack: {
118
151
  height: 6, borderRadius: 3, overflow: 'hidden',
119
152
  background: 'var(--dsw-alias-bg-layer-1)', border: '1px solid var(--dsw-alias-border-l2)',
120
153
  },
121
154
  usageFill: { height: '100%', borderRadius: 3 },
155
+ defaultEffort: {
156
+ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 4,
157
+ borderTop: '1px solid var(--dsw-alias-border-l2)', paddingTop: 8,
158
+ },
159
+ /** The always-visible disclosure header: title, summary, chevron. */
160
+ defaultEffortToggle: {
161
+ boxSizing: 'border-box', display: 'flex', alignItems: 'center', gap: 8,
162
+ width: '100%', padding: 0, border: 'none', background: 'transparent',
163
+ font: 'inherit', textAlign: 'left', cursor: 'pointer',
164
+ },
165
+ defaultEffortChevron: {
166
+ marginLeft: 'auto', flexShrink: 0, fontSize: 10, lineHeight: '18px',
167
+ color: 'var(--dsw-alias-label-tertiary)',
168
+ },
169
+ /** Body of the expanded disclosure: bounded height so a long catalog scrolls. */
170
+ defaultEffortList: {
171
+ display: 'flex', flexDirection: 'column', gap: 6,
172
+ maxHeight: MODEL_LIST_MAX_HEIGHT, overflowY: 'auto', paddingRight: 2,
173
+ },
174
+ defaultEffortRow: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 },
175
+ defaultEffortName: {
176
+ fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-label-primary)',
177
+ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
178
+ },
179
+ defaultEffortSaving: {
180
+ marginLeft: 'auto', flexShrink: 0,
181
+ fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-label-tertiary)',
182
+ },
183
+ defaultEffortSelect: {
184
+ maxWidth: 220, flexShrink: 0, height: 28, boxSizing: 'border-box',
185
+ border: '1px solid var(--dsw-alias-border-l2)', borderRadius: 8,
186
+ padding: '0 8px', font: 'inherit', fontSize: 12, lineHeight: '18px',
187
+ background: 'var(--dsw-alias-bg-layer-1)', color: 'var(--dsw-alias-label-primary)',
188
+ },
189
+ defaultEffortFilter: {
190
+ height: 28, width: '100%', boxSizing: 'border-box',
191
+ border: '1px solid var(--dsw-alias-border-l2)', borderRadius: 8,
192
+ padding: '0 8px', font: 'inherit', fontSize: 12, lineHeight: '18px',
193
+ background: 'var(--dsw-alias-bg-layer-1)', color: 'var(--dsw-alias-label-primary)',
194
+ },
122
195
  manual: { marginTop: 4, fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-label-secondary)' },
123
196
  manualRow: { display: 'flex', gap: 8, marginTop: 6 },
124
197
  manualInput: {
@@ -171,10 +244,20 @@ const styles = {
171
244
  function dotColor(status) {
172
245
  if (status?.busy === true)
173
246
  return 'var(--dsw-alias-state-warn-label)';
174
- if (status?.loggedIn === true)
247
+ if ((status?.accounts.length ?? 0) > 0)
175
248
  return 'var(--dsw-alias-state-success-primary)';
176
249
  return 'var(--dsw-alias-label-dimmed)';
177
250
  }
251
+ /**
252
+ * Whether a provider has at least one connected account. Multi-account made
253
+ * "logged in" a property of the account list rather than a flag, so every
254
+ * logged-in test goes through this one predicate.
255
+ * @param status - the provider's last reported state, possibly absent.
256
+ * @returns true when the provider serves at least one account.
257
+ */
258
+ function hasAccount(status) {
259
+ return (status?.accounts.length ?? 0) > 0;
260
+ }
178
261
  /**
179
262
  * One-line status text for one provider state.
180
263
  * @param t - section translate.
@@ -186,20 +269,8 @@ function statusText(t, status) {
186
269
  return t('checking');
187
270
  if (status.busy)
188
271
  return t('loginInProgress');
189
- if (status.loggedIn) {
190
- const params = {};
191
- if (status.account !== undefined)
192
- params.account = status.account;
193
- if (status.expiresAt !== undefined)
194
- params.date = new Date(status.expiresAt).toLocaleString();
195
- if (params.account !== undefined && params.date !== undefined)
196
- return t('loggedInAccountExpires', params);
197
- if (params.account !== undefined)
198
- return t('loggedInAccount', params);
199
- if (params.date !== undefined)
200
- return t('loggedInExpires', params);
201
- return t('loggedIn');
202
- }
272
+ if (status.accounts.length > 0)
273
+ return t('loggedInCount', { count: status.accounts.length });
203
274
  return t('notLoggedIn');
204
275
  }
205
276
  /**
@@ -240,6 +311,65 @@ function messageColor(tone) {
240
311
  ? 'var(--dsw-alias-state-error-primary)'
241
312
  : 'var(--dsw-alias-state-success-primary)';
242
313
  }
314
+ /**
315
+ * Derive one provider's default-effort section from its catalog and filter.
316
+ * Pure so the collapsed-header counts and the filter stay testable without a
317
+ * DOM: rows come only from models that advertise levels, the count of the rest
318
+ * rides as one line, and the filter matches display name or model id.
319
+ * @param models - the provider's catalog models, or undefined while loading.
320
+ * @param filter - the raw filter input (trimmed and lowercased here).
321
+ * @returns the section's rows and header counts.
322
+ */
323
+ export function deriveModelDefaultsView(models, filter) {
324
+ const all = models ?? [];
325
+ const withEfforts = all.filter(model => model.efforts.length > 0);
326
+ const query = filter.trim().toLowerCase();
327
+ const shown = query === ''
328
+ ? withEfforts
329
+ : withEfforts.filter(model => model.name.toLowerCase().includes(query)
330
+ || model.id.toLowerCase().includes(query));
331
+ return {
332
+ shown,
333
+ total: withEfforts.length,
334
+ overridden: withEfforts.filter(model => model.configured !== undefined).length,
335
+ withoutEfforts: all.length - withEfforts.length,
336
+ showFilter: withEfforts.length > MODEL_FILTER_THRESHOLD,
337
+ };
338
+ }
339
+ /**
340
+ * Whether the default-effort catalog needs (re)fetching.
341
+ *
342
+ * Fetching is gated on an *attempt* signature rather than on the payload
343
+ * being empty: an empty answer is a legitimate result (a narrowed
344
+ * `config.providers`, or a catalog that is momentarily unavailable), and
345
+ * treating it as "not loaded yet" re-ran this effect forever. The signature
346
+ * also covers the accounts, so logging a second provider in refetches
347
+ * instead of leaving that card on the previous answer.
348
+ * @param input - the decision inputs.
349
+ * @returns true when the caller should start a fetch.
350
+ */
351
+ export function shouldFetchModelDefaults(input) {
352
+ if (input.failed)
353
+ return false;
354
+ if (input.loggedIn.length === 0)
355
+ return false;
356
+ // Only an open disclosure pays for the per-model live resolve.
357
+ if (!input.open.some(provider => input.loggedIn.includes(provider)))
358
+ return false;
359
+ return input.loadedFor !== input.signature;
360
+ }
361
+ /**
362
+ * Stable signature of the accounts a catalog answer depends on. A change
363
+ * means a previous answer is stale (an account arrived or left), so the next
364
+ * open disclosure refetches.
365
+ * @param statuses - the per-provider status snapshot.
366
+ * @returns a signature string, stable across renders with equal accounts.
367
+ */
368
+ export function modelDefaultsSignature(statuses) {
369
+ return PROVIDERS
370
+ .map(({ id }) => `${id}:${(statuses[id]?.accounts ?? []).map(account => account.key).sort().join(',')}`)
371
+ .join('|');
372
+ }
243
373
  /**
244
374
  * The Subscriptions settings page component.
245
375
  * @param props - the slot inject face ({@link SubscriptionsSectionInjected}).
@@ -256,12 +386,13 @@ export function SubscriptionsSection(props) {
256
386
  /** Pending device-flow codes (copilot), shown while the attempt polls. */
257
387
  const [deviceCodes, setDeviceCodes] = useState({});
258
388
  const [copiedCode, setCopiedCode] = useState(undefined);
389
+ /** Usage snapshots keyed `${provider}:${accountKey}` — every account tracks its own windows. */
259
390
  const [usages, setUsages] = useState({});
260
391
  const [usageErrors, setUsageErrors] = useState({});
261
392
  const [usageLoading, setUsageLoading] = useState({});
262
393
  const mountedRef = useRef(true);
263
394
  const pollersRef = useRef(new Map());
264
- /** Providers with a `usage` call in flight; guards the auto-fetch effect against re-entry. */
395
+ /** Accounts with a `usage` call in flight; guards the auto-fetch effect against re-entry. */
265
396
  const usageInflightRef = useRef(new Set());
266
397
  /** Proxy config as last answered by `proxyGet`/`proxySet`. */
267
398
  const [proxy, setProxy] = useState(undefined);
@@ -278,6 +409,24 @@ export function SubscriptionsSection(props) {
278
409
  const [proxyTesting, setProxyTesting] = useState(false);
279
410
  const [proxyMessage, setProxyMessage] = useState(undefined);
280
411
  const [proxyTestResult, setProxyTestResult] = useState(undefined);
412
+ /** Per-model default-effort picker state as answered by `modelDefaults`. */
413
+ const [modelDefaults, setModelDefaults] = useState({});
414
+ const [modelDefaultsLoading, setModelDefaultsLoading] = useState(false);
415
+ const [modelDefaultsLoadError, setModelDefaultsLoadError] = useState(undefined);
416
+ /** One set in flight: the `${provider}/${model}` key. */
417
+ const [modelDefaultsSaving, setModelDefaultsSaving] = useState(undefined);
418
+ /** Per-model save failures, keyed `${provider}/${model}`. */
419
+ const [modelDefaultsSaveErrors, setModelDefaultsSaveErrors] = useState({});
420
+ /** Providers whose default-effort disclosure is open (collapsed by default). */
421
+ const [modelDefaultsOpen, setModelDefaultsOpen] = useState({});
422
+ /** Per-provider name filter of the expanded list. */
423
+ const [modelDefaultsFilters, setModelDefaultsFilters] = useState({});
424
+ /** Account signature the last completed catalog fetch was answered for. */
425
+ const [modelDefaultsLoadedFor, setModelDefaultsLoadedFor] = useState(undefined);
426
+ /** Optimistic in-flight selections, keyed `${provider}/${model}` ('' = follow provider). */
427
+ const [modelDefaultsPending, setModelDefaultsPending] = useState({});
428
+ /** Guard the catalog effect against concurrent loads. */
429
+ const modelDefaultsInflightRef = useRef(false);
281
430
  const setProviderError = useCallback((provider, message) => {
282
431
  if (!mountedRef.current)
283
432
  return;
@@ -315,7 +464,7 @@ export function SubscriptionsSection(props) {
315
464
  setStatuses(response.providers);
316
465
  for (const { id } of PROVIDERS) {
317
466
  const status = response.providers[id];
318
- if (status.loggedIn || !status.busy) {
467
+ if (status.accounts.length > 0 || !status.busy) {
319
468
  stopPolling(id);
320
469
  // The attempt settled (success, timeout, or cancel): drop the code card.
321
470
  setDeviceCodes((prev) => {
@@ -355,64 +504,198 @@ export function SubscriptionsSection(props) {
355
504
  pollersRef.current.clear();
356
505
  };
357
506
  }, [refresh, startPolling]);
358
- const loadUsage = useCallback(async (provider) => {
359
- if (rpc === undefined || usageInflightRef.current.has(provider))
507
+ const loadUsage = useCallback(async (provider, account, force = false) => {
508
+ const key = `${provider}:${account}`;
509
+ if (rpc === undefined || usageInflightRef.current.has(key))
360
510
  return;
361
- usageInflightRef.current.add(provider);
362
- setUsageLoading(prev => ({ ...prev, [provider]: true }));
511
+ usageInflightRef.current.add(key);
512
+ setUsageLoading(prev => ({ ...prev, [key]: true }));
363
513
  try {
364
- const usage = await callSubscriptionsAuth(rpc, 'usage', { provider });
514
+ const usage = await callSubscriptionsAuth(rpc, 'usage', { provider, account, ...force ? { force: true } : {} });
365
515
  if (!mountedRef.current)
366
516
  return;
367
- setUsages(prev => ({ ...prev, [provider]: usage }));
517
+ setUsages(prev => ({ ...prev, [key]: usage }));
368
518
  setUsageErrors((prev) => {
369
519
  const next = { ...prev };
370
- delete next[provider];
520
+ delete next[key];
371
521
  return next;
372
522
  });
373
523
  }
374
524
  catch (error) {
375
525
  if (mountedRef.current)
376
- setUsageErrors(prev => ({ ...prev, [provider]: messageOf(error) }));
526
+ setUsageErrors(prev => ({ ...prev, [key]: messageOf(error) }));
377
527
  }
378
528
  finally {
379
- usageInflightRef.current.delete(provider);
529
+ usageInflightRef.current.delete(key);
380
530
  if (mountedRef.current)
381
- setUsageLoading(prev => ({ ...prev, [provider]: false }));
531
+ setUsageLoading(prev => ({ ...prev, [key]: false }));
382
532
  }
383
533
  }, [rpc]);
384
- // Fetch usage once a provider is logged in; drop the cached snapshot on
385
- // logout so a re-login refetches. A failed lookup does not auto-retry — the
386
- // per-card Refresh button is the retry path.
534
+ // Fetch usage once an account is logged in; drop the snapshots of accounts
535
+ // that vanished so a re-login refetches. A failed lookup does not auto-retry
536
+ // — the per-account Refresh button is the retry path.
387
537
  useEffect(() => {
538
+ const live = new Set();
388
539
  for (const { id } of PROVIDERS) {
389
- const status = statuses[id];
390
- if (status === undefined)
391
- continue;
392
- if (status.loggedIn) {
393
- if (usages[id] === undefined && usageErrors[id] === undefined)
394
- void loadUsage(id);
540
+ for (const account of statuses[id]?.accounts ?? []) {
541
+ const key = `${id}:${account.key}`;
542
+ live.add(key);
543
+ if (usages[key] === undefined && usageErrors[key] === undefined)
544
+ void loadUsage(id, account.key);
395
545
  }
396
- else if (usages[id] !== undefined || usageErrors[id] !== undefined) {
397
- setUsages((prev) => {
398
- const next = { ...prev };
399
- delete next[id];
400
- return next;
401
- });
402
- setUsageErrors((prev) => {
546
+ }
547
+ setUsages(prev => dropStale(prev, live));
548
+ setUsageErrors(prev => dropStale(prev, live));
549
+ }, [statuses, usages, usageErrors, loadUsage]);
550
+ const loadModelDefaultsData = useCallback(async (signature) => {
551
+ if (rpc === undefined || modelDefaultsInflightRef.current)
552
+ return;
553
+ modelDefaultsInflightRef.current = true;
554
+ setModelDefaultsLoading(true);
555
+ try {
556
+ const catalog = await callSubscriptionsAuth(rpc, 'modelDefaults', {});
557
+ if (!mountedRef.current)
558
+ return;
559
+ const next = {};
560
+ for (const entry of catalog)
561
+ next[entry.provider] = entry;
562
+ setModelDefaults(next);
563
+ setModelDefaultsLoadError(undefined);
564
+ // Latch the answered signature, empty answer included: an empty catalog
565
+ // is a result, not a missing load, and re-deriving "loaded" from the
566
+ // payload re-triggered this fetch on every render.
567
+ setModelDefaultsLoadedFor(signature);
568
+ // The shown state is now authoritative; stale per-row failures would
569
+ // otherwise linger next to rows that are correct again.
570
+ setModelDefaultsSaveErrors({});
571
+ }
572
+ catch (error) {
573
+ if (mountedRef.current)
574
+ setModelDefaultsLoadError(messageOf(error));
575
+ }
576
+ finally {
577
+ modelDefaultsInflightRef.current = false;
578
+ if (mountedRef.current)
579
+ setModelDefaultsLoading(false);
580
+ }
581
+ }, [rpc]);
582
+ // Fetch the default-effort catalogs only once a card's list is expanded: the
583
+ // node half resolves live model info per model, so a collapsed page must not
584
+ // pay for it. One fetch covers every logged-in provider (the node half
585
+ // answers them together), and the levels follow the picker's catalog union
586
+ // across that provider's accounts. The account signature drives refetching,
587
+ // so connecting another provider or account does not leave an open card on
588
+ // the previous answer. Everything resets once the last account logs out.
589
+ useEffect(() => {
590
+ const loggedIn = PROVIDERS.filter(({ id }) => hasAccount(statuses[id])).map(({ id }) => id);
591
+ const signature = modelDefaultsSignature(statuses);
592
+ if (loggedIn.length > 0) {
593
+ const open = PROVIDERS.filter(({ id }) => modelDefaultsOpen[id] === true).map(({ id }) => id);
594
+ if (shouldFetchModelDefaults({
595
+ loggedIn,
596
+ open,
597
+ loadedFor: modelDefaultsLoadedFor,
598
+ signature,
599
+ failed: modelDefaultsLoadError !== undefined,
600
+ })) {
601
+ void loadModelDefaultsData(signature);
602
+ }
603
+ }
604
+ else if (modelDefaultsLoadedFor !== undefined
605
+ || Object.keys(modelDefaults).length > 0
606
+ || Object.keys(modelDefaultsOpen).length > 0) {
607
+ setModelDefaults({});
608
+ setModelDefaultsSaveErrors({});
609
+ setModelDefaultsLoadError(undefined);
610
+ setModelDefaultsLoadedFor(undefined);
611
+ setModelDefaultsLoading(false);
612
+ setModelDefaultsOpen({});
613
+ setModelDefaultsFilters({});
614
+ }
615
+ }, [
616
+ statuses,
617
+ modelDefaults,
618
+ modelDefaultsOpen,
619
+ modelDefaultsLoadError,
620
+ modelDefaultsLoadedFor,
621
+ loadModelDefaultsData,
622
+ ]);
623
+ /** Open or close one provider's default-effort disclosure. */
624
+ const toggleModelDefaults = useCallback((provider) => {
625
+ setModelDefaultsOpen(prev => ({ ...prev, [provider]: prev[provider] !== true }));
626
+ }, []);
627
+ const setModelDefault = useCallback(async (provider, model, effort) => {
628
+ if (rpc === undefined)
629
+ return;
630
+ const key = `${provider}/${model}`;
631
+ setModelDefaultsSaving(key);
632
+ // Hold the picked level locally for the duration of the save: the select
633
+ // is controlled by server state, which only updates after the round trip,
634
+ // so without this the row visibly snaps back to "Follow provider" (greyed
635
+ // out) mid-save and reads as a rejected change.
636
+ setModelDefaultsPending(prev => ({ ...prev, [key]: effort ?? '' }));
637
+ setModelDefaultsSaveErrors((prev) => {
638
+ const next = { ...prev };
639
+ delete next[key];
640
+ return next;
641
+ });
642
+ try {
643
+ await callSubscriptionsAuth(rpc, 'setModelDefault', {
644
+ provider,
645
+ model,
646
+ ...(effort === undefined ? {} : { effort }),
647
+ });
648
+ if (!mountedRef.current)
649
+ return;
650
+ setModelDefaults((prev) => {
651
+ const section = prev[provider];
652
+ if (section === undefined)
653
+ return prev;
654
+ return {
655
+ ...prev,
656
+ [provider]: {
657
+ ...section,
658
+ models: section.models.map((entry) => {
659
+ if (entry.id !== model)
660
+ return entry;
661
+ if (effort !== undefined)
662
+ return { ...entry, configured: effort };
663
+ // Cleared: drop the key rather than keep the stale level, or the
664
+ // select would snap back and the header would keep counting it.
665
+ const { configured: _cleared, ...rest } = entry;
666
+ return rest;
667
+ }),
668
+ },
669
+ };
670
+ });
671
+ }
672
+ catch (error) {
673
+ if (mountedRef.current)
674
+ setModelDefaultsSaveErrors((prev) => ({ ...prev, [key]: messageOf(error) }));
675
+ }
676
+ finally {
677
+ if (mountedRef.current) {
678
+ setModelDefaultsSaving(current => current === key ? undefined : current);
679
+ // Drop the optimistic value: on success the server state now carries
680
+ // it, on failure the row must fall back to the real stored level
681
+ // rather than keep showing a change that did not land.
682
+ setModelDefaultsPending((prev) => {
403
683
  const next = { ...prev };
404
- delete next[id];
684
+ delete next[key];
405
685
  return next;
406
686
  });
407
687
  }
408
688
  }
409
- }, [statuses, usages, usageErrors, loadUsage]);
410
- const login = useCallback(async (provider) => {
689
+ }, [rpc]);
690
+ const login = useCallback(async (provider, method) => {
411
691
  if (rpc === undefined)
412
692
  return;
413
693
  setProviderError(provider, undefined);
414
694
  try {
415
- const response = await callSubscriptionsAuth(rpc, 'login', { provider });
695
+ const response = await callSubscriptionsAuth(rpc, 'login', {
696
+ provider,
697
+ ...method === undefined ? {} : { method },
698
+ });
416
699
  if (typeof response.authorizeUrl === 'string' && response.authorizeUrl === '') {
417
700
  // Instant login (e.g. imported from Claude Code credentials)
418
701
  await refresh();
@@ -424,7 +707,10 @@ export function SubscriptionsSection(props) {
424
707
  if (!mountedRef.current)
425
708
  return;
426
709
  // Optimistic busy so Cancel and the manual fallback appear before the first poll tick.
427
- setStatuses(prev => ({ ...prev, [provider]: { ...prev[provider], busy: true, loggedIn: false } }));
710
+ setStatuses(prev => ({
711
+ ...prev,
712
+ [provider]: { accounts: prev[provider]?.accounts ?? [], ...prev[provider], busy: true },
713
+ }));
428
714
  if (typeof response.userCode === 'string' && response.userCode.length > 0) {
429
715
  // Device flow: show the code card instead of opening the page blind —
430
716
  // the user copies the code first, then opens the verification page.
@@ -468,20 +754,32 @@ export function SubscriptionsSection(props) {
468
754
  }
469
755
  await refresh();
470
756
  }, [rpc, manualDrafts, setProviderError, refresh]);
471
- const logout = useCallback(async (provider, name) => {
757
+ const logout = useCallback(async (provider, account, display, name) => {
472
758
  if (rpc === undefined)
473
759
  return;
474
- if (!window.confirm(t('logoutConfirm', { provider: name })))
760
+ if (!window.confirm(t('logoutAccountConfirm', { provider: name, account: display })))
475
761
  return;
476
762
  setProviderError(provider, undefined);
477
763
  try {
478
- await callSubscriptionsAuth(rpc, 'logout', { provider });
764
+ await callSubscriptionsAuth(rpc, 'logout', { provider, account });
479
765
  }
480
766
  catch (error) {
481
767
  setProviderError(provider, messageOf(error));
482
768
  }
483
769
  await refresh();
484
770
  }, [rpc, t, setProviderError, refresh]);
771
+ const setDefault = useCallback(async (provider, account) => {
772
+ if (rpc === undefined)
773
+ return;
774
+ setProviderError(provider, undefined);
775
+ try {
776
+ await callSubscriptionsAuth(rpc, 'setDefault', { provider, account });
777
+ }
778
+ catch (error) {
779
+ setProviderError(provider, messageOf(error));
780
+ }
781
+ await refresh();
782
+ }, [rpc, setProviderError, refresh]);
485
783
  const copyDeviceCode = useCallback((provider, userCode) => {
486
784
  void navigator.clipboard?.writeText(userCode).then(() => {
487
785
  if (!mountedRef.current)
@@ -593,16 +891,67 @@ export function SubscriptionsSection(props) {
593
891
  const status = statuses[id];
594
892
  const busy = status?.busy === true;
595
893
  const deviceCode = deviceCodes[id];
596
- const usage = usages[id];
597
- const usageError = usageErrors[id];
598
- // Providers without a usage endpoint answer supported:false — no block.
599
- const showUsage = status?.loggedIn === true && usage?.supported !== false
600
- && (usage !== undefined || usageError !== undefined || usageLoading[id] === true);
601
- return (_jsxs("div", { style: styles.card, children: [_jsxs("div", { style: styles.cardHeader, children: [_jsx("span", { style: { ...styles.dot, background: dotColor(status) } }), _jsx("span", { style: styles.name, children: name })] }), _jsx("p", { style: styles.statusLine, children: statusText(t, status) }), status?.detail !== undefined && status.detail !== '' && (_jsx("p", { style: styles.statusLine, children: status.detail })), errors[id] !== undefined && _jsx("p", { style: styles.errorLine, children: errors[id] }), _jsxs("div", { style: styles.actions, children: [!busy && status?.loggedIn !== true && (_jsx("button", { type: "button", style: styles.button, onClick: () => { void login(id); }, children: t('login') })), busy && (_jsx("button", { type: "button", style: styles.button, onClick: () => { void cancel(id); }, children: t('cancel') })), status?.loggedIn === true && (_jsx("button", { type: "button", style: styles.button, onClick: () => { void logout(id, name); }, children: t('logout') }))] }), showUsage && (_jsxs("div", { style: styles.usage, children: [_jsxs("div", { style: styles.usageHeader, children: [_jsx("span", { style: styles.usageTitle, children: t('usageTitle') }), usage?.plan !== undefined && (_jsx("span", { style: styles.usagePlan, children: t('usagePlan', { plan: usage.plan }) })), _jsx("button", { type: "button", style: { ...styles.usageRefresh, ...usageLoading[id] === true ? { opacity: 0.5, cursor: 'default' } : {} }, disabled: usageLoading[id] === true, onClick: () => { void loadUsage(id); }, children: t('usageRefresh') })] }), usage === undefined && usageError === undefined && (_jsx("p", { style: styles.statusLine, children: t('usageLoading') })), usageError !== undefined && (_jsx("p", { style: styles.errorLine, children: t('usageError', { message: usageError }) })), usage?.windows !== undefined && usage.windows.length === 0 && (_jsx("p", { style: styles.statusLine, children: t('usageEmpty') })), (usage?.windows ?? []).map((window, index) => {
602
- const percent = Math.min(100, Math.max(0, window.usedPercent));
603
- return (_jsxs("div", { style: styles.usageRow, children: [_jsxs("div", { style: styles.usageMeta, children: [_jsx("span", { children: usageWindowLabel(t, window) }), _jsxs("span", { children: [`${String(Math.round(percent))}%`, window.resetsAt !== undefined
604
- && ` · ${t('usageResets', { date: new Date(window.resetsAt).toLocaleString() })}`] })] }), _jsx("div", { style: styles.usageTrack, children: _jsx("div", { style: { ...styles.usageFill, width: `${String(percent)}%`, background: usageBarColor(percent) } }) })] }, index));
605
- })] })), busy && deviceCode !== undefined && (_jsxs("div", { style: styles.deviceCode, children: [_jsx("span", { style: styles.statusLine, children: t('deviceCodePrompt') }), _jsx("span", { style: styles.deviceCodeText, children: deviceCode.userCode }), _jsxs("div", { style: styles.actions, children: [_jsx("button", { type: "button", style: styles.button, onClick: () => { copyDeviceCode(id, deviceCode.userCode); }, children: copiedCode === id ? t('deviceCodeCopied') : t('deviceCodeCopy') }), _jsx("button", { type: "button", style: styles.button, onClick: () => { window.open(deviceCode.verificationUrl, '_blank', 'noopener'); }, children: t('deviceCodeOpenPage') })] })] })), busy && deviceCode === undefined && (_jsxs("details", { style: styles.manual, children: [_jsx("summary", { children: t('manualSummary') }), _jsxs("div", { style: styles.manualRow, children: [_jsx("input", { style: styles.manualInput, value: manualDrafts[id], placeholder: t('manualPlaceholder'), onChange: event => setManualDrafts(prev => ({ ...prev, [id]: event.target.value })) }), _jsx("button", { type: "button", style: styles.button, onClick: () => { void submitManual(id); }, children: t('submit') })] })] }))] }, id));
894
+ const accounts = status?.accounts ?? [];
895
+ return (_jsxs("div", { style: styles.card, children: [_jsxs("div", { style: styles.cardHeader, children: [_jsx("span", { style: { ...styles.dot, background: dotColor(status) } }), _jsx("span", { style: styles.name, children: name })] }), _jsx("p", { style: styles.statusLine, children: statusText(t, status) }), status?.detail !== undefined && status.detail !== '' && (_jsx("p", { style: styles.statusLine, children: status.detail })), errors[id] !== undefined && _jsx("p", { style: styles.errorLine, children: errors[id] }), accounts.map((account) => {
896
+ const usageKey = `${id}:${account.key}`;
897
+ const usage = usages[usageKey];
898
+ const usageError = usageErrors[usageKey];
899
+ const display = account.account ?? account.key;
900
+ // Providers without a usage endpoint answer supported:false — no block.
901
+ const showUsage = usage?.supported !== false
902
+ && (usage !== undefined || usageError !== undefined || usageLoading[usageKey] === true);
903
+ return (_jsxs("div", { style: styles.accountRow, children: [_jsxs("div", { style: styles.accountHeader, children: [_jsx("button", { type: "button", style: styles.starButton, title: account.isDefault ? t('defaultBadge') : t('setDefault'), onClick: () => {
904
+ if (!account.isDefault)
905
+ void setDefault(id, account.key);
906
+ }, children: account.isDefault ? '★' : '☆' }), _jsx("span", { style: styles.accountName, children: display }), account.plan !== undefined && (_jsx("span", { style: styles.usagePlan, children: account.plan })), account.expiresAt !== undefined && (_jsx("span", { style: styles.statusLine, children: t('accountExpires', { date: new Date(account.expiresAt).toLocaleString() }) })), _jsx("button", { type: "button", style: { ...styles.button, marginLeft: 'auto', flexShrink: 0 }, onClick: () => { void logout(id, account.key, display, name); }, children: t('logout') })] }), showUsage && (_jsxs("div", { style: styles.usage, children: [_jsxs("div", { style: styles.usageHeader, children: [_jsx("span", { style: styles.usageTitle, children: t('usageTitle') }), usage?.plan !== undefined && (_jsx("span", { style: styles.usagePlan, children: t('usagePlan', { plan: usage.plan }) })), _jsx("button", { type: "button", style: { ...styles.usageRefresh, ...usageLoading[usageKey] === true ? { opacity: 0.5, cursor: 'default' } : {} }, disabled: usageLoading[usageKey] === true, onClick: () => { void loadUsage(id, account.key, true); }, children: t('usageRefresh') })] }), usage === undefined && usageError === undefined && (_jsx("p", { style: styles.statusLine, children: t('usageLoading') })), usageError !== undefined && (_jsx("p", { style: styles.errorLine, children: t('usageError', { message: usageError }) })), usage?.windows !== undefined && usage.windows.length === 0 && (_jsx("p", { style: styles.statusLine, children: t('usageEmpty') })), (usage?.windows ?? []).map((window, index) => {
907
+ const percent = Math.min(100, Math.max(0, window.usedPercent));
908
+ return (_jsxs("div", { style: styles.usageRow, children: [_jsxs("div", { style: styles.usageMeta, children: [_jsx("span", { children: usageWindowLabel(t, window) }), _jsxs("span", { children: [`${String(Math.round(percent))}%`, window.resetsAt !== undefined
909
+ && ` · ${t('usageResets', { date: new Date(window.resetsAt).toLocaleString() })}`] })] }), _jsx("div", { style: styles.usageTrack, children: _jsx("div", { style: { ...styles.usageFill, width: `${String(percent)}%`, background: usageBarColor(percent) } }) })] }, index));
910
+ })] }))] }, account.key));
911
+ }), _jsxs("div", { style: styles.actions, children: [!busy && accounts.length === 0 && (_jsx("button", { type: "button", style: styles.button, onClick: () => { void login(id); }, children: t('login') })), !busy && accounts.length > 0 && id === 'claude' && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", style: styles.button, onClick: () => { void login(id, 'oauth'); }, children: t('addAccountOAuth') }), _jsx("button", { type: "button", style: styles.button, onClick: () => { void login(id, 'keychain'); }, children: t('addAccountKeychain') })] })), !busy && accounts.length > 0 && id !== 'claude' && (_jsx("button", { type: "button", style: styles.button, onClick: () => { void login(id); }, children: t('addAccount') })), busy && (_jsx("button", { type: "button", style: styles.button, onClick: () => { void cancel(id); }, children: t('cancel') }))] }), !busy && accounts.length > 0 && (_jsx("p", { style: styles.statusLine, children: t('addAccountHint') })), accounts.length > 0 && (() => {
912
+ // Collapsed by default: providers with a large catalog (Copilot
913
+ // lists dozens of models) must not push the page down. The
914
+ // header carries the summary so the collapsed state still says
915
+ // how many models are overridden. The section is per provider,
916
+ // not per account: the override keys off the model id, which the
917
+ // pool shares across a provider's accounts.
918
+ const open = modelDefaultsOpen[id] === true;
919
+ const catalog = modelDefaults[id];
920
+ const filter = modelDefaultsFilters[id] ?? '';
921
+ const view = deriveModelDefaultsView(catalog?.models, filter);
922
+ const saveErrors = Object.entries(modelDefaultsSaveErrors).filter(([key]) => key.startsWith(`${id}/`));
923
+ return (_jsxs("div", { style: styles.defaultEffort, children: [_jsxs("button", { type: "button", style: styles.defaultEffortToggle, "aria-expanded": open, onClick: () => { toggleModelDefaults(id); }, children: [_jsx("span", { style: styles.usageTitle, children: t('modelDefaultsTitle') }), _jsx("span", { style: styles.usagePlan, children: catalog === undefined
924
+ ? (modelDefaultsLoading ? t('modelDefaultsLoading') : '')
925
+ : view.total === 0
926
+ ? t('modelDefaultsSummaryEmpty')
927
+ : view.overridden === 0
928
+ ? t('modelDefaultsSummaryNone', { total: view.total })
929
+ : t('modelDefaultsSummary', { total: view.total, configured: view.overridden }) }), _jsx("span", { style: styles.defaultEffortChevron, "aria-hidden": "true", children: open ? '▲' : '▼' })] }), saveErrors.map(([key, message]) => (
930
+ // Named: the failing row may be scrolled out of the
931
+ // bounded list, and several anonymous "Save failed" lines
932
+ // cannot be told apart.
933
+ _jsx("p", { style: styles.errorLine, role: "alert", children: t('modelDefaultsSaveFailedNamed', {
934
+ model: catalog?.models.find(entry => `${id}/${entry.id}` === key)?.name
935
+ ?? key.slice(id.length + 1),
936
+ message,
937
+ }) }, key))), open && (_jsxs(_Fragment, { children: [_jsx("p", { style: styles.statusLine, children: t('modelDefaultsHint') }), modelDefaultsLoadError !== undefined && (_jsxs(_Fragment, { children: [_jsx("p", { style: styles.errorLine, children: t('modelDefaultsLoadFailed', { message: modelDefaultsLoadError }) }), _jsx("div", { style: styles.actions, children: _jsx("button", { type: "button", style: styles.button, onClick: () => {
938
+ // Clearing the latch lets the effect pick the
939
+ // fetch back up on the next render.
940
+ setModelDefaultsLoadError(undefined);
941
+ }, children: t('modelDefaultsRetry') }) })] })), modelDefaultsLoadError === undefined && catalog === undefined && (_jsx("p", { style: styles.statusLine, children: t('modelDefaultsLoading') })), view.showFilter && (_jsx("input", { style: styles.defaultEffortFilter, value: filter, placeholder: t('modelDefaultsFilterPlaceholder'), "aria-label": t('modelDefaultsFilterPlaceholder'), onChange: (event) => {
942
+ setModelDefaultsFilters(prev => ({ ...prev, [id]: event.target.value }));
943
+ } })), view.shown.length > 0 && (_jsx("div", { style: styles.defaultEffortList, children: view.shown.map((model) => {
944
+ const rowKey = `${id}/${model.id}`;
945
+ const saving = modelDefaultsSaving === rowKey;
946
+ // Ids carry the provider: a model id alone repeats
947
+ // across cards, and duplicate ids break the label
948
+ // association the select relies on.
949
+ const labelId = `dsh-model-default-${id}-${model.id}`;
950
+ return (_jsxs("div", { style: styles.defaultEffortRow, children: [_jsx("span", { id: labelId, style: styles.defaultEffortName, title: model.id, children: model.name }), saving && (_jsx("span", { style: styles.defaultEffortSaving, children: t('modelDefaultsSaving') })), _jsxs("select", { style: styles.defaultEffortSelect, "aria-labelledby": labelId, value: modelDefaultsPending[rowKey] ?? model.configured ?? '', disabled: saving, onChange: (event) => {
951
+ void setModelDefault(id, model.id, event.target.value === '' ? undefined : event.target.value);
952
+ }, children: [_jsx("option", { value: "", children: t('modelDefaultsFollowProvider') }), model.efforts.map(effort => (_jsx("option", { value: effort.id, children: effort.name }, effort.id)))] })] }, model.id));
953
+ }) })), catalog !== undefined && view.shown.length === 0 && filter.trim() !== '' && (_jsx("p", { style: styles.statusLine, children: t('modelDefaultsFilterEmpty', { query: filter.trim() }) })), view.withoutEfforts > 0 && (_jsx("p", { style: styles.statusLine, children: t('modelDefaultsNoLevels', { count: view.withoutEfforts }) }))] }))] }));
954
+ })(), busy && deviceCode !== undefined && (_jsxs("div", { style: styles.deviceCode, children: [_jsx("span", { style: styles.statusLine, children: t('deviceCodePrompt') }), _jsx("span", { style: styles.deviceCodeText, children: deviceCode.userCode }), _jsxs("div", { style: styles.actions, children: [_jsx("button", { type: "button", style: styles.button, onClick: () => { copyDeviceCode(id, deviceCode.userCode); }, children: copiedCode === id ? t('deviceCodeCopied') : t('deviceCodeCopy') }), _jsx("button", { type: "button", style: styles.button, onClick: () => { window.open(deviceCode.verificationUrl, '_blank', 'noopener'); }, children: t('deviceCodeOpenPage') })] })] })), busy && deviceCode === undefined && (_jsxs("details", { style: styles.manual, children: [_jsx("summary", { children: t('manualSummary') }), _jsxs("div", { style: styles.manualRow, children: [_jsx("input", { style: styles.manualInput, value: manualDrafts[id], placeholder: t('manualPlaceholder'), onChange: event => setManualDrafts(prev => ({ ...prev, [id]: event.target.value })) }), _jsx("button", { type: "button", style: styles.button, onClick: () => { void submitManual(id); }, children: t('submit') })] })] }))] }, id));
606
955
  }), proxyOpen && (_jsx("div", { style: styles.modalOverlay, onClick: () => setProxyOpen(false), children: _jsxs("div", { style: styles.modal, onClick: event => event.stopPropagation(), children: [_jsxs("div", { style: styles.modalHeader, children: [_jsx("span", { style: styles.modalTitle, children: t('proxyDialogTitle') }), _jsx("button", { type: "button", style: { ...styles.button, marginLeft: 'auto' }, onClick: () => setProxyOpen(false), children: t('proxyDialogClose') })] }), _jsxs("label", { style: styles.proxyCheck, children: [_jsx("input", { type: "checkbox", checked: proxyEnabled, onChange: event => setProxyEnabled(event.target.checked) }), _jsx("span", { children: t('proxyEnabled') })] }), _jsxs("label", { style: styles.proxyField, children: [_jsx("span", { style: styles.proxyLabel, children: t('proxyUrl') }), _jsx("input", { style: styles.proxyInput, value: proxyUrl, placeholder: t('proxyUrlPlaceholder'), onChange: event => setProxyUrl(event.target.value) }), _jsx("p", { style: styles.proxyHint, children: t('proxyUrlHint') })] }), _jsxs("label", { style: styles.proxyField, children: [_jsx("span", { style: styles.proxyLabel, children: t('proxyUsername') }), _jsx("input", { style: styles.proxyInput, value: proxyUsername, placeholder: t('proxyUsernamePlaceholder'), onChange: event => setProxyUsername(event.target.value) })] }), _jsxs("div", { style: styles.proxyField, children: [_jsx("span", { style: styles.proxyLabel, children: t('proxyPassword') }), _jsx("input", { type: "password", style: styles.proxyInput, value: proxyPassword, placeholder: t('proxyPasswordPlaceholder'), onChange: event => setProxyPassword(event.target.value) }), _jsxs("label", { style: styles.proxyCheck, children: [_jsx("input", { type: "checkbox", checked: proxyClearPassword, onChange: event => setProxyClearPassword(event.target.checked) }), _jsx("span", { children: t('proxyClearPassword') })] })] }), _jsxs("label", { style: styles.proxyField, children: [_jsx("span", { style: styles.proxyLabel, children: t('proxyBypass') }), _jsx("input", { style: styles.proxyInput, value: proxyBypass, placeholder: t('proxyBypassPlaceholder'), onChange: event => setProxyBypass(event.target.value) }), _jsx("p", { style: styles.proxyHint, children: t('proxyBypassHint') })] }), _jsx("p", { style: styles.proxyHint, children: t('proxyNote') }), proxyMessage !== undefined && (_jsx("p", { style: { ...styles.proxyMessage, color: messageColor(proxyMessage.tone) }, children: proxyMessage.text })), proxyTestResult !== undefined && (_jsx("p", { style: {
607
956
  ...styles.proxyMessage,
608
957
  color: proxyTestResult.ok