@linxin666/dsh-usage 0.4.10 → 0.4.12

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.
@@ -1,34 +1,30 @@
1
1
  /**
2
- * The usage statistics settings section: three tabs (用量: today's usage,
3
- * balances, trend; 个人套餐: per-provider plan quota windows; Token 银行:
4
- * the whale-yuan voucher minted from every provider's ledger usage)
5
- * plus a compact settings row. Data comes from the host's loopback-fenced
6
- * /api/dsh-usage/overview document; polling runs only while the section is
7
- * mounted and the tab is visible.
2
+ * The usage statistics settings page: one family settings card whose header
3
+ * carries the plugin switch, whose body holds three tabs (用量: today's
4
+ * usage, balances, trend; 个人套餐: per-provider plan quota windows; Token
5
+ * 银行: the whale-yuan voucher minted from every provider's ledger usage)
6
+ * and, under them, the display rows. Data comes from the host's
7
+ * loopback-fenced /api/dsh-usage/overview document; polling runs only while
8
+ * the section is mounted and the tab is visible.
8
9
  * @module @linxin666/dsh-usage/client/UsageSectionCard
9
10
  */
10
11
 
11
12
  import { useEffect, useRef, useState, useSyncExternalStore, type ReactNode } from 'react'
12
- import type { ConfigForm, ConfigFormSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
13
13
  import type { UsageStoreInstance } from './usage-store.ts'
14
14
  import { t } from './locales.ts'
15
15
  import styles from './usage.module.css'
16
+ import cardCss from './settings-card.module.css'
16
17
  import { PeakBadge } from './PeakBadge.tsx'
18
+ import { SegmentGroup } from './SegmentGroup.tsx'
19
+ import { UsagePage } from './UsagePage.tsx'
20
+ import type { UsageSettingsCardFace } from './usage-settings-card.ts'
17
21
  import { isDeepSeekProviderRoute } from '../core/adapters.ts'
18
22
  import { deepseekPeriodAt } from '../core/pricing.ts'
19
23
  import { voucherData, drawVoucher, faceValue, formatDay, formatDenomination, loadVoucherArt } from './voucher.ts'
20
24
  import type { ObservedSpendView, ProviderSnapshotView, UsageDayView, UsageOverviewView, UsageProviderSummary, UsageTokenTotals, UsageWindowSummary } from '../core/types.ts'
21
25
  import { formatSpend, type SpendDisplay } from '../core/spend.ts'
22
26
 
23
- /** The settings fields this section edits (immediate-apply semantics). */
24
- export interface UsageSettings {
25
- enabled?: boolean
26
- pollIntervalSec?: number
27
- /** Currency the spend figures are displayed in; the ledger stays CNY. */
28
- spendCurrency?: 'CNY' | 'USD'
29
- /** CNY per USD, consulted only while the display currency is USD. */
30
- cnyPerUsd?: number
31
- }
27
+ export type { UsageSettings } from './usage-settings-card.ts'
32
28
 
33
29
  /** The registration-side face the section's slot entry injects. */
34
30
  export interface UsageSectionFace {
@@ -44,8 +40,8 @@ export interface UsageSectionFace {
44
40
  * is one small on-demand request.
45
41
  */
46
42
  loadDay: (date: string) => Promise<UsageDayView>
47
- /** The shared configuration form this section's settings row reads and writes. */
48
- settings: ConfigForm<UsageSettings>
43
+ /** The settings card over the usage namespace: the plugin switch and the display rows. */
44
+ settingsCard: UsageSettingsCardFace
49
45
  }
50
46
 
51
47
  export interface UsageSectionProps extends UsageSectionFace {
@@ -98,28 +94,28 @@ function isConfigured(provider: ProviderSnapshotView): boolean {
98
94
  function TotalsRow(props: { totals: UsageTokenTotals }): ReactNode {
99
95
  const { totals } = props
100
96
  return (
101
- <div className={styles.statRow}>
102
- <div className={styles.stat}>
97
+ <div className={styles.statRow} data-dsh-layout="usage-totals">
98
+ <div className={styles.stat} data-dsh-stat="total">
103
99
  <span className={styles.statValue}>{formatTokens(totals.inputTokens + totals.cacheReadTokens + totals.cacheWriteTokens + totals.outputTokens)}</span>
104
100
  <span className={styles.statLabel}>{t('usage.tokens.total')}</span>
105
101
  </div>
106
- <div className={styles.stat}>
102
+ <div className={styles.stat} data-dsh-stat="input">
107
103
  <span className={styles.statValue}>{formatTokens(totals.inputTokens + totals.cacheReadTokens + totals.cacheWriteTokens)}</span>
108
104
  <span className={styles.statLabel}>{t('usage.tokens.input')}</span>
109
105
  </div>
110
- <div className={styles.stat}>
106
+ <div className={styles.stat} data-dsh-stat="output">
111
107
  <span className={styles.statValue}>{formatTokens(totals.outputTokens)}</span>
112
108
  <span className={styles.statLabel}>{t('usage.tokens.output')}</span>
113
109
  </div>
114
- <div className={styles.stat}>
110
+ <div className={styles.stat} data-dsh-stat="cache-read">
115
111
  <span className={styles.statValue}>{formatTokens(totals.cacheReadTokens)}</span>
116
112
  <span className={styles.statLabel}>{t('usage.tokens.cacheRead')}</span>
117
113
  </div>
118
- <div className={styles.stat}>
114
+ <div className={styles.stat} data-dsh-stat="cache-write">
119
115
  <span className={styles.statValue}>{formatTokens(totals.cacheWriteTokens)}</span>
120
116
  <span className={styles.statLabel}>{t('usage.tokens.cacheWrite')}</span>
121
117
  </div>
122
- <div className={styles.stat}>
118
+ <div className={styles.stat} data-dsh-stat="calls">
123
119
  <span className={styles.statValue}>{formatTokens(totals.calls)}</span>
124
120
  <span className={styles.statLabel}>{t('usage.calls', { n: totals.calls })}</span>
125
121
  </div>
@@ -225,31 +221,26 @@ function DayPicker(props: {
225
221
 
226
222
  /** The section component; the slot merges the face into these props. */
227
223
  export function UsageSectionCard(props: UsageSectionProps): ReactNode {
228
- const { store, poll, refresh, loadDay, settings } = props
224
+ const { store, poll, refresh, loadDay, settingsCard } = props
229
225
  const ui = useSyncExternalStore(store.subscribe, store.getSnapshot)
230
- const settingsSnapshot = settings.getSnapshot()
231
- const settingsValue = settingsSnapshot.value ?? {}
232
- const [tab, setTab] = useState<'usage' | 'plans' | 'bank'>('usage')
226
+ // The card store republishes the Host's accepted value, so the poll below
227
+ // starts and stops with the switch without waiting for an unrelated render.
228
+ const card = useSyncExternalStore(settingsCard.store.subscribe, settingsCard.store.getSnapshot)
229
+ const [tab, setTab] = useState<UsageTab>('usage')
233
230
  // Undefined means "the default window of this tab": today on the usage tab,
234
231
  // the whole retained ledger in the Token bank. A date switches that tab to
235
232
  // one retained day instead.
236
233
  const [usageDay, setUsageDay] = useState<string | undefined>(undefined)
237
234
  const [bankDay, setBankDay] = useState<string | undefined>(undefined)
238
235
  const [refreshing, setRefreshing] = useState(false)
239
- // The enable checkbox writes through the shared form, so subscribing here
240
- // keeps the flag below live: the form republishes the Host's accepted value,
241
- // and the poll starts and stops with it instead of waiting for an unrelated
242
- // render.
243
- const [, bumpSettings] = useState(0)
244
- useEffect(() => settings.subscribe(() => bumpSettings((count) => count + 1)), [settings])
245
- const enabled = settingsValue.enabled ?? true
236
+ const enabled = card.enabled.text !== 'false'
246
237
 
247
238
  // Poll while mounted, enabled and visible; the overview is cheap (no probes —
248
239
  // the host's own cycle owns those) so 10 s keeps balances fresh-ish between
249
240
  // manual refreshes.
250
241
  useEffect(() => {
251
242
  // A disabled plugin deregisters its host routes, so the poll must stop with
252
- // it; the section re-enables by writing the flag back through the checkbox.
243
+ // it; the section re-enables by writing the flag back through the switch.
253
244
  if (!enabled) return undefined
254
245
  poll()
255
246
  let timer: number | undefined
@@ -310,21 +301,20 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
310
301
  }
311
302
  }
312
303
 
313
- // Disabled, failed and still-loading states keep the settings row mounted:
314
- // it owns the enable checkbox, so replacing the whole panel would leave the
315
- // user no way back from the UI.
304
+ // Disabled, failed and still-loading states keep the card mounted: its
305
+ // header owns the switch, so replacing the whole page would leave the user
306
+ // no way back from the UI.
316
307
  if (!enabled || ui.status === 'error' || snapshot === null) {
317
308
  return (
318
- <div className={styles.section} data-dsh-plugin="usage">
319
- <span className={styles.muted} data-dsh-part="status-line">
320
- {!enabled
321
- ? t('usage.disabled')
322
- : ui.status === 'error'
323
- ? t('usage.error', { error: ui.error ?? '' })
324
- : t('usage.loading')}
325
- </span>
326
- <SettingsRow settings={settings} snapshot={settingsSnapshot.status === 'ready' ? settingsSnapshot : undefined} value={settingsValue} />
327
- </div>
309
+ <UsagePage card={card} face={settingsCard} enabled={enabled}>
310
+ {enabled
311
+ ? (
312
+ <span className={styles.muted} data-dsh-part="status-line">
313
+ {ui.status === 'error' ? t('usage.error', { error: ui.error ?? '' }) : t('usage.loading')}
314
+ </span>
315
+ )
316
+ : null}
317
+ </UsagePage>
328
318
  )
329
319
  }
330
320
 
@@ -343,135 +333,146 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
343
333
  const planProviders = snapshot.providers.filter((provider) => isConfigured(provider) && (provider.planSupported === true || (provider.planSupported === undefined && provider.plan !== undefined)))
344
334
 
345
335
  return (
346
- <div className={styles.section} data-dsh-plugin="usage">
347
- <div className={styles.header} data-dsh-part="header">
348
- <span className={styles.currentProvider}>
349
- {currentProvider !== undefined
350
- ? `${currentProvider.displayName}${current.model !== undefined && current.model !== '' ? ' · ' + current.model : ''}`
351
- : t('usage.noData')}
352
- </span>
353
- <span style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
354
- <span className={styles.muted}>{t('usage.updated', { time: formatTime(snapshot.updatedAt) })}</span>
355
- <button type="button" className={styles.refreshBtn} onClick={onRefresh} disabled={refreshing}>
356
- {refreshing ? t('usage.refreshing') : t('usage.refresh')}
357
- </button>
358
- </span>
359
- </div>
336
+ <UsagePage card={card} face={settingsCard} enabled={enabled}>
337
+ <div className={styles.section}>
338
+ <div className={styles.header} data-dsh-part="header">
339
+ {/* The cards below already say when there is no usage; without a current route the slot stays empty. */}
340
+ <span className={styles.currentProvider}>
341
+ {currentProvider !== undefined
342
+ ? `${currentProvider.displayName}${current.model !== undefined && current.model !== '' ? ' · ' + current.model : ''}`
343
+ : null}
344
+ </span>
345
+ <span className={styles.headerActions}>
346
+ <span className={styles.muted}>{t('usage.updated', { time: formatTime(snapshot.updatedAt) })}</span>
347
+ <button type="button" className={styles.refreshBtn} onClick={onRefresh} disabled={refreshing}>
348
+ {refreshing ? t('usage.refreshing') : t('usage.refresh')}
349
+ </button>
350
+ </span>
351
+ </div>
352
+
353
+ <SegmentGroup className={styles.tabs} role="tablist" aria-label={t('usage.title')} data-dsh-part="tabs">
354
+ {USAGE_TABS.map((entry) => (
355
+ <button
356
+ key={entry}
357
+ type="button"
358
+ role="tab"
359
+ aria-selected={tab === entry}
360
+ className={`${cardCss.segment} ${styles.tab}`}
361
+ data-active={tab === entry ? '' : undefined}
362
+ onClick={() => setTab(entry)}
363
+ >
364
+ {t(`usage.tab.${entry}`)}
365
+ </button>
366
+ ))}
367
+ </SegmentGroup>
368
+
369
+ {tab === 'usage' && (
370
+ <>
371
+ <div className={styles.usageSummaryGrid} data-dsh-layout="summary-grid">
372
+ <div className={styles.card} data-dsh-part="today-card">
373
+ <span className={styles.cardTitle}>{isToday ? t('usage.today') : t('usage.day.title', { date: selectedDay ?? '' })}</span>
374
+ <DayPicker
375
+ part="usage"
376
+ value={selectedDay ?? ''}
377
+ options={dayOptions}
378
+ today={todayDate ?? ''}
379
+ onSelect={(date) => { setUsageDay(date === todayDate ? undefined : date) }}
380
+ />
381
+ {deepseekVisible && (
382
+ <PeakBadge peak={deepseekPeriod.peak} boundaryMs={deepseekPeriod.boundaryMs} variant="full" />
383
+ )}
384
+ {usageDayLoad?.status === 'error'
385
+ ? <span className={styles.errorLine}>{t('usage.day.error', { error: usageDayLoad.error ?? '' })}</span>
386
+ : day === undefined
387
+ ? <span className={styles.muted}>{t('usage.day.loading')}</span>
388
+ : day.totals.calls === 0
389
+ ? <span className={styles.muted}>{isToday ? t('usage.noData') : t('usage.day.empty')}</span>
390
+ : <TotalsRow totals={day.totals} />}
391
+ {(day?.totals.cost ?? 0) > 0 && (
392
+ <div className={styles.providerRow} data-dsh-part="today-cost">
393
+ <span className={styles.providerName}>{isToday ? t('usage.today.cost') : t('usage.day.cost')}</span>
394
+ <span className={styles.providerTokens}>{formatSpend(day?.totals.cost ?? 0, snapshot.display)}</span>
395
+ </div>
396
+ )}
397
+ {(day?.providers.length ?? 0) > 0 && (
398
+ <div data-dsh-part="provider-list">
399
+ {(day?.providers ?? []).map((row) => (
400
+ <div key={row.provider} className={styles.providerRow}>
401
+ <span className={styles.providerName}>
402
+ {snapshot.providers.find((provider) => provider.provider === row.provider)?.displayName ?? row.provider}
403
+ {current.provider === row.provider && <span className={styles.currentBadge}>{t('usage.current')}</span>}
404
+ </span>
405
+ <span className={styles.providerTokens}>{formatTokens(row.totals.inputTokens + row.totals.cacheReadTokens + row.totals.cacheWriteTokens + row.totals.outputTokens)} · {t('usage.calls', { n: row.totals.calls })}{row.totals.cost > 0 ? ` · ${formatSpend(row.totals.cost, snapshot.display)}` : ''}</span>
406
+ </div>
407
+ ))}
408
+ </div>
409
+ )}
410
+ </div>
360
411
 
361
- <div className={styles.tabs} role="tablist" data-dsh-part="tabs">
362
- <button type="button" role="tab" aria-selected={tab === 'usage'} className={tab === 'usage' ? `${styles.tab} ${styles.tabActive}` : styles.tab} onClick={() => setTab('usage')}>
363
- {t('usage.tab.usage')}
364
- </button>
365
- <button type="button" role="tab" aria-selected={tab === 'plans'} className={tab === 'plans' ? `${styles.tab} ${styles.tabActive}` : styles.tab} onClick={() => setTab('plans')}>
366
- {t('usage.tab.plans')}
367
- </button>
368
- <button type="button" role="tab" aria-selected={tab === 'bank'} className={tab === 'bank' ? `${styles.tab} ${styles.tabActive}` : styles.tab} onClick={() => setTab('bank')}>
369
- {t('usage.tab.bank')}
370
- </button>
371
- </div>
412
+ <div className={styles.card} data-dsh-part="balance-card">
413
+ <span className={styles.cardTitle}>{t('usage.balance')}</span>
414
+ {(() => {
415
+ const configured = snapshot.providers.filter(isConfigured)
416
+ // Include rows whose balance is deliberately not applicable
417
+ // (another origin's endpoint): the row itself explains why, which
418
+ // a single generic line cannot (#1688).
419
+ const rows = configured.filter((provider) => provider.balanceSupported === true || provider.balanceSupported === false || (provider.balanceSupported === undefined && provider.supported))
420
+ if (rows.length === 0) {
421
+ return <span className={styles.muted}>{configured.length === 0 ? t('usage.balance.noneConfigured') : t('usage.balance.unsupported')}</span>
422
+ }
423
+ return rows.map((provider) => (
424
+ <ProviderRow key={provider.provider} provider={provider} current={current.provider} />
425
+ ))
426
+ })()}
427
+ {snapshot.providers.some((provider) => isConfigured(provider) && provider.error !== undefined) && (
428
+ <span className={styles.errorLine}>
429
+ {snapshot.providers.filter((provider) => isConfigured(provider) && provider.error !== undefined).map((provider) => `${provider.displayName}: ${t('usage.provider.error', { error: provider.error ?? '' })}`).join(t('usage.errorListSeparator'))}
430
+ </span>
431
+ )}
432
+ </div>
433
+ </div>
434
+
435
+ <RangeCard range={snapshot.usage.range} providers={snapshot.providers} currentProvider={current.provider} />
436
+ </>
437
+ )}
438
+
439
+ {tab === 'plans' && (
440
+ planProviders.length === 0
441
+ ? <div className={styles.card}><span className={styles.muted}>{t('usage.plan.noneConfigured')}</span></div>
442
+ : planProviders.map((provider) => <PlanCard key={provider.provider} provider={provider} current={current.provider} />)
443
+ )}
372
444
 
373
- {tab === 'usage' && (
374
- <>
375
- <div className={styles.card} data-dsh-part="today-card">
376
- <span className={styles.cardTitle}>{isToday ? t('usage.today') : t('usage.day.title', { date: selectedDay ?? '' })}</span>
445
+ {tab === 'bank' && (
446
+ <>
377
447
  <DayPicker
378
- part="usage"
379
- value={selectedDay ?? ''}
448
+ part="bank"
449
+ value={bankSelection ?? BANK_ALL_DAYS}
380
450
  options={dayOptions}
381
451
  today={todayDate ?? ''}
382
- onSelect={(date) => { setUsageDay(date === todayDate ? undefined : date) }}
452
+ leading={{ value: BANK_ALL_DAYS, label: t('usage.bank.windowAll') }}
453
+ onSelect={(date) => { setBankDay(date === BANK_ALL_DAYS ? undefined : date) }}
383
454
  />
384
- {deepseekVisible && (
385
- <PeakBadge peak={deepseekPeriod.peak} boundaryMs={deepseekPeriod.boundaryMs} variant="full" />
386
- )}
387
- {usageDayLoad?.status === 'error'
388
- ? <span className={styles.errorLine}>{t('usage.day.error', { error: usageDayLoad.error ?? '' })}</span>
389
- : day === undefined
390
- ? <span className={styles.muted}>{t('usage.day.loading')}</span>
391
- : day.totals.calls === 0
392
- ? <span className={styles.muted}>{isToday ? t('usage.noData') : t('usage.day.empty')}</span>
393
- : <TotalsRow totals={day.totals} />}
394
- {(day?.totals.cost ?? 0) > 0 && (
395
- <div className={styles.providerRow} data-dsh-part="today-cost">
396
- <span className={styles.providerName}>{isToday ? t('usage.today.cost') : t('usage.day.cost')}</span>
397
- <span className={styles.providerTokens}>{formatSpend(day?.totals.cost ?? 0, snapshot.display)}</span>
398
- </div>
399
- )}
400
- {(day?.providers.length ?? 0) > 0 && (
401
- <div data-dsh-part="provider-list">
402
- {(day?.providers ?? []).map((row) => (
403
- <div key={row.provider} className={styles.providerRow}>
404
- <span className={styles.providerName}>
405
- {snapshot.providers.find((provider) => provider.provider === row.provider)?.displayName ?? row.provider}
406
- {current.provider === row.provider && <span className={styles.currentBadge}>{t('usage.current')}</span>}
407
- </span>
408
- <span className={styles.providerTokens}>{formatTokens(row.totals.inputTokens + row.totals.cacheReadTokens + row.totals.cacheWriteTokens + row.totals.outputTokens)} · {t('usage.calls', { n: row.totals.calls })}{row.totals.cost > 0 ? ` · ${formatSpend(row.totals.cost, snapshot.display)}` : ''}</span>
409
- </div>
410
- ))}
411
- </div>
412
- )}
413
- </div>
414
-
415
- <div className={styles.card} data-dsh-part="balance-card">
416
- <span className={styles.cardTitle}>{t('usage.balance')}</span>
417
- {(() => {
418
- const configured = snapshot.providers.filter(isConfigured)
419
- // Include rows whose balance is deliberately not applicable
420
- // (another origin's endpoint): the row itself explains why, which
421
- // a single generic line cannot (#1688).
422
- const rows = configured.filter((provider) => provider.balanceSupported === true || provider.balanceSupported === false || (provider.balanceSupported === undefined && provider.supported))
423
- if (rows.length === 0) {
424
- return <span className={styles.muted}>{configured.length === 0 ? t('usage.balance.noneConfigured') : t('usage.balance.unsupported')}</span>
425
- }
426
- return rows.map((provider) => (
427
- <ProviderRow key={provider.provider} provider={provider} current={current.provider} />
428
- ))
429
- })()}
430
- {snapshot.providers.some((provider) => isConfigured(provider) && provider.error !== undefined) && (
431
- <span className={styles.errorLine}>
432
- {snapshot.providers.filter((provider) => isConfigured(provider) && provider.error !== undefined).map((provider) => `${provider.displayName}: ${t('usage.provider.error', { error: provider.error ?? '' })}`).join(t('usage.errorListSeparator'))}
433
- </span>
434
- )}
435
- </div>
436
-
437
- <RangeCard range={snapshot.usage.range} providers={snapshot.providers} currentProvider={current.provider} />
438
-
439
- <SettingsRow settings={settings} snapshot={settingsSnapshot.status === 'ready' ? settingsSnapshot : undefined} value={settingsValue} />
440
- </>
441
- )}
442
-
443
- {tab === 'plans' && (
444
- planProviders.length === 0
445
- ? <div className={styles.card}><span className={styles.muted}>{t('usage.plan.noneConfigured')}</span></div>
446
- : planProviders.map((provider) => <PlanCard key={provider.provider} provider={provider} current={current.provider} />)
447
- )}
448
-
449
- {tab === 'bank' && (
450
- <>
451
- <DayPicker
452
- part="bank"
453
- value={bankSelection ?? BANK_ALL_DAYS}
454
- options={dayOptions}
455
- today={todayDate ?? ''}
456
- leading={{ value: BANK_ALL_DAYS, label: t('usage.bank.windowAll') }}
457
- onSelect={(date) => { setBankDay(date === BANK_ALL_DAYS ? undefined : date) }}
458
- />
459
- <VoucherCard
460
- window={bankWindow}
461
- // The observed-spend watch accrues from its own first balance
462
- // reading, so it describes the whole window only: a single day's
463
- // voucher falls back to that day's fold-time estimate.
464
- observedSpend={bankSelection === undefined ? snapshot.usage.observedSpend : undefined}
465
- loading={bankSelection !== undefined && bankDayLoad?.status === 'loading'}
466
- display={snapshot.display}
467
- {...(bankDayLoad?.status === 'error' ? { error: bankDayLoad.error ?? '' } : {})}
468
- />
469
- </>
470
- )}
471
- </div>
455
+ <VoucherCard
456
+ window={bankWindow}
457
+ // The observed-spend watch accrues from its own first balance
458
+ // reading, so it describes the whole window only: a single day's
459
+ // voucher falls back to that day's fold-time estimate.
460
+ observedSpend={bankSelection === undefined ? snapshot.usage.observedSpend : undefined}
461
+ loading={bankSelection !== undefined && bankDayLoad?.status === 'loading'}
462
+ display={snapshot.display}
463
+ {...(bankDayLoad?.status === 'error' ? { error: bankDayLoad.error ?? '' } : {})}
464
+ />
465
+ </>
466
+ )}
467
+ </div>
468
+ </UsagePage>
472
469
  )
473
470
  }
474
471
 
472
+ /** The statistics tabs, in their order on the page. */
473
+ const USAGE_TABS = ['usage', 'plans', 'bank'] as const
474
+ type UsageTab = (typeof USAGE_TABS)[number]
475
+
475
476
  /** How many model sub-bars render under one provider bar. */
476
477
  const CHART_MODEL_CAP = 3
477
478
 
@@ -492,7 +493,7 @@ function RangeCard(props: { range?: UsageOverviewView['usage']['range']; provide
492
493
  <span className={styles.cardTitle}>{t('usage.trend')}</span>
493
494
  {range.providers.length === 0 || grandTotal === 0
494
495
  ? <span className={styles.muted}>{t('usage.noData')}</span>
495
- : <div className={styles.chart} data-dsh-part="usage-chart">
496
+ : <div className={styles.chart} data-dsh-part="usage-chart" data-dsh-layout="trend-grid">
496
497
  {range.providers.map((row) => (
497
498
  <ChartProviderRow key={row.provider} row={row} name={nameOf(row.provider)} max={maxProvider} current={currentProvider === row.provider} />
498
499
  ))}
@@ -510,7 +511,7 @@ function ChartProviderRow(props: { row: UsageProviderSummary; name: string; max:
510
511
  const total = totalOf(row.totals)
511
512
  const maxModel = Math.max(1, ...row.models.slice(0, CHART_MODEL_CAP).map((model) => totalOf(model.totals)))
512
513
  return (
513
- <div className={styles.chartProvider}>
514
+ <div className={styles.chartProvider} data-dsh-layout="trend-provider">
514
515
  <span className={styles.chartHead}>
515
516
  <span className={styles.providerName}>
516
517
  {name}
@@ -680,101 +681,3 @@ function PlanCard(props: { provider: ProviderSnapshotView; current?: string }):
680
681
  </div>
681
682
  )
682
683
  }
683
-
684
- /**
685
- * The compact settings row. Both controls write through the shared form the
686
- * moment the user changes them, and the Host answers each write with a
687
- * boolean: a refused (or transport-failed) write is surfaced as a failed save,
688
- * because a value that did not land must never read as applied.
689
- */
690
- function SettingsRow(props: {
691
- settings: UsageSectionProps['settings']
692
- snapshot?: ConfigFormSnapshot<UsageSettings>
693
- value: UsageSettings
694
- }): ReactNode {
695
- const { settings, snapshot, value } = props
696
- const disabled = snapshot === undefined || !snapshot.writable
697
- const [failure, setFailure] = useState<string | undefined>(undefined)
698
-
699
- const write = (field: 'enabled' | 'pollIntervalSec' | 'spendCurrency' | 'cnyPerUsd', next: boolean | number | 'CNY' | 'USD'): void => {
700
- setFailure(undefined)
701
- let answer: Promise<boolean>
702
- try {
703
- answer = settings.set(field, next)
704
- } catch (error) {
705
- setFailure(error instanceof Error ? error.message : String(error))
706
- return
707
- }
708
- // false is the contract's refusal/skip answer (the Host rejected the value,
709
- // the entry is not writable, or the write was dropped); a rejecting
710
- // transport reports through the same failed-save surface.
711
- Promise.resolve(answer).then(
712
- (accepted) => { if (!accepted) setFailure('') },
713
- (error: unknown) => { setFailure(error instanceof Error ? error.message : String(error)) },
714
- )
715
- }
716
-
717
- return (
718
- <div className={styles.card} data-dsh-part="settings-row">
719
- <span className={styles.cardTitle}>{t('usage.config.title')}</span>
720
- <div className={styles.settingsGrid}>
721
- <label className={styles.settingItem}>
722
- <input
723
- type="checkbox"
724
- checked={value.enabled ?? true}
725
- disabled={disabled}
726
- onChange={(event) => { write('enabled', event.target.checked) }}
727
- />
728
- {t('usage.config.enabled')}
729
- </label>
730
- <label className={styles.settingItem}>
731
- {t('usage.config.pollIntervalSec')}
732
- <input
733
- type="number"
734
- min={30}
735
- max={3600}
736
- value={typeof value.pollIntervalSec === 'number' ? value.pollIntervalSec : 60}
737
- disabled={disabled}
738
- onChange={(event) => {
739
- const parsed = Number(event.target.value)
740
- if (Number.isFinite(parsed) && parsed >= 30 && parsed <= 3600) write('pollIntervalSec', Math.round(parsed))
741
- }}
742
- />
743
- </label>
744
- <label className={styles.settingItem}>
745
- {t('usage.config.spendCurrency')}
746
- <select
747
- value={value.spendCurrency === 'USD' ? 'USD' : 'CNY'}
748
- disabled={disabled}
749
- onChange={(event) => { write('spendCurrency', event.target.value === 'USD' ? 'USD' : 'CNY') }}
750
- >
751
- <option value="CNY">CNY</option>
752
- <option value="USD">USD</option>
753
- </select>
754
- </label>
755
- <label className={styles.settingItem}>
756
- {t('usage.config.cnyPerUsd')}
757
- <input
758
- type="number"
759
- min={0.01}
760
- max={1000}
761
- step={0.01}
762
- value={typeof value.cnyPerUsd === 'number' ? value.cnyPerUsd : 7.2}
763
- disabled={disabled}
764
- onChange={(event) => {
765
- const parsed = Number(event.target.value)
766
- // An unusable rate is simply not written: the stored value stays,
767
- // and rendering falls back to the documented default rate.
768
- if (Number.isFinite(parsed) && parsed >= 0.01 && parsed <= 1000) write('cnyPerUsd', Math.round(parsed * 100) / 100)
769
- }}
770
- />
771
- </label>
772
- </div>
773
- {failure !== undefined && (
774
- <span className={styles.errorLine} role="status">
775
- {t('usage.config.saveFailed')}{failure === '' ? '' : ' - ' + failure}
776
- </span>
777
- )}
778
- </div>
779
- )
780
- }
@@ -19,9 +19,11 @@ import type {} from '@deepseek-ai/dsh-client-ui-slots'
19
19
  import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
20
20
  import { createUsageStore, type UsageStoreInstance } from './usage-store.ts'
21
21
  import { UsageSectionCard, type UsageSectionFace, type UsageSettings } from './UsageSectionCard.tsx'
22
- import { mountUsageFootCard, openUsageSettings, settingsShellStore } from './foot-card-mount.tsx'
23
- import { NS, en, zh, t } from './locales.ts'
22
+ import { mountUsageFootCard } from './foot-card-mount.tsx'
23
+ import { NS, en, zh } from './locales.ts'
24
+ import { installUsageSection, openIntegratedUsageSettings } from './section-integration.ts'
24
25
  import { createServedEntryForm } from './settings-entry-form.ts'
26
+ import { UsageSettingsCardController } from './usage-settings-card.ts'
25
27
  import type { UsageDayResponse, UsageDayView, UsageOverviewView } from '../core/types.ts'
26
28
 
27
29
  /** The host usage API as the browser sees it (same-origin JSON endpoints). */
@@ -68,9 +70,6 @@ const USAGE_ENTRY_IDS: readonly string[] = [AGGREGATE_ENTRY_ID, 'usage', USAGE_S
68
70
  /** First-level nav position: directly below the Workshop section (order 150). */
69
71
  const SECTION_ORDER = 151
70
72
 
71
- /** The section's nav row id — what the shell's store selects when the card opens. */
72
- const USAGE_SECTION_ID = 'dsh-usage'
73
-
74
73
  /** Required services. */
75
74
  export const inject = ['slots', 'locale', 'connection', 'configForms', 'remote']
76
75
 
@@ -167,7 +166,13 @@ export function apply(ctx: ClientContext): void {
167
166
  // small local request the 10 s poll has no reason to repeat.
168
167
  const loadDay = (date: string): Promise<UsageDayView> => usageApi.day(date)
169
168
 
170
- const face = (): UsageSectionFace => ({ store, poll, refresh, loadDay, settings: settingsForm })
169
+ // The card form lives as long as the plugin, like the store: the page reads
170
+ // its last state on reopen, and a write in flight settles after the page closes.
171
+ const settingsCard = new UsageSettingsCardController(settingsForm)
172
+ ctx.effect(() => () => { settingsCard.dispose() }, 'dsh-usage: settings card')
173
+ const settingsCardFace = settingsCard.inject()
174
+
175
+ const face = (): UsageSectionFace => ({ store, poll, refresh, loadDay, settingsCard: settingsCardFace })
171
176
 
172
177
  // Sidebar foot card: the compact usage glance seated below the shell's
173
178
  // Settings row. It shares the section's store and poll path (the sequence
@@ -177,13 +182,7 @@ export function apply(ctx: ClientContext): void {
177
182
  store,
178
183
  poll,
179
184
  settings: settingsForm,
180
- onOpen: () => {
181
- openUsageSettings({
182
- id: USAGE_SECTION_ID,
183
- label: () => t('usage.title'),
184
- store: settingsShellStore(ctx.slots),
185
- })
186
- },
185
+ onOpen: () => { openIntegratedUsageSettings(ctx) },
187
186
  locale: ctx.locale,
188
187
  })
189
188
  ctx.effect(() => () => {
@@ -194,21 +193,11 @@ export function apply(ctx: ClientContext): void {
194
193
  }
195
194
  }, 'dsh-usage: sidebar foot card')
196
195
 
197
- ctx.slots.inject('settings.section', () => {
198
- try {
199
- const unregister = ctx.slots.register({
200
- name: 'settings.section',
201
- id: USAGE_SECTION_ID,
202
- order: SECTION_ORDER,
203
- label: () => ctx.locale.bind(NS)('usage.title'),
204
- locale: NS,
205
- inject: face,
206
- }, UsageSectionCard)
207
- return () => {
208
- unregister()
209
- }
210
- } catch {
211
- return () => {}
212
- }
213
- })
196
+ ctx.effect(() => installUsageSection(ctx, {
197
+ order: SECTION_ORDER,
198
+ label: () => ctx.locale.bind(NS)('usage.title'),
199
+ locale: NS,
200
+ inject: face,
201
+ component: UsageSectionCard,
202
+ }), 'dsh-usage: settings page')
214
203
  }