@linxin666/dsh-usage 0.3.24 → 0.4.1

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.
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Sidebar foot card: the compact usage glance seated below the shell sidebar's
3
+ * Settings row. It is deliberately a GLANCE, not the panel the 2026-09-18
4
+ * removal took out. Two states, persisted across reloads:
5
+ *
6
+ * - expanded (default): a price-first headline (today's estimated spend,
7
+ * falling back to today's tokens when nothing priced was recorded), a
8
+ * tokens/calls line, up to two configured-provider balances, and an
9
+ * updated-at footer;
10
+ * - collapsed: a one-line strip with the gauge glyph, the spending
11
+ * provider's name, the label and the headline value.
12
+ *
13
+ * The card body is one button that opens the settings panel on the usage
14
+ * section, so detail lives in exactly one place; the corner chevron toggles
15
+ * the collapse state (sibling buttons — buttons inside a button are invalid
16
+ * HTML).
17
+ *
18
+ * Data comes from the same shared store the settings section reads; the card
19
+ * runs its own relaxed poll loop (30 s, visible-tab only) because the sidebar
20
+ * foot is permanently mounted. The card disappears while the plugin is
21
+ * disabled (settings flag) or while the host serves no usage routes (a 404
22
+ * means the host half is off), matching the section's own gating.
23
+ * @module @linxin666/dsh-usage/client/UsageFootCard
24
+ */
25
+
26
+ import { useEffect, useState, useSyncExternalStore, type ReactNode } from 'react'
27
+ import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client'
28
+ import { t } from './locales.ts'
29
+ import styles from './usage.module.css'
30
+ import { formatTokens, type UsageSettings } from './UsageSectionCard.tsx'
31
+ import { totalTokens } from '../core/ledger.ts'
32
+ import type { BalanceView, ProviderSnapshotView, UsageOverviewView } from '../core/types.ts'
33
+ import type { UsageStoreInstance } from './usage-store.ts'
34
+
35
+ /** Props the mount forwards into the React tree. */
36
+ export interface UsageFootCardProps {
37
+ /** The shared overview store (section and card read the same snapshot). */
38
+ store: UsageStoreInstance
39
+ /** Fetch one overview now. */
40
+ poll: () => void
41
+ /** Card-body click: open the settings panel on the usage section. */
42
+ onOpen: () => void
43
+ /** The shared settings form; the card hides while the plugin is disabled. */
44
+ settings: ConfigForm<UsageSettings>
45
+ /** Locale-change source; the plain copy re-renders on a language switch. */
46
+ locale?: { subscribe(listener: () => void): () => void }
47
+ }
48
+
49
+ /** Poll cadence of the permanently seated card; the section's 10 s loop stays its own. */
50
+ export const FOOT_CARD_POLL_MS = 30_000
51
+
52
+ /** localStorage key holding the collapsed flag ('1' = collapsed strip). */
53
+ export const FOOT_CARD_COLLAPSED_KEY = 'dsh-usage.foot-card.collapsed'
54
+
55
+ /** At most this many provider balances render before a +N overflow marker. */
56
+ const BALANCE_CAP = 2
57
+
58
+ /** Read the persisted collapsed flag (absent = expanded). */
59
+ export function readFootCardCollapsed(): boolean {
60
+ try {
61
+ return window.localStorage.getItem(FOOT_CARD_COLLAPSED_KEY) === '1'
62
+ } catch {
63
+ // Storage can be unavailable (privacy modes); the card stays expanded.
64
+ return false
65
+ }
66
+ }
67
+
68
+ /** Persist the collapsed flag; storage failures keep the session state. */
69
+ function writeFootCardCollapsed(collapsed: boolean): void {
70
+ try {
71
+ window.localStorage.setItem(FOOT_CARD_COLLAPSED_KEY, collapsed ? '1' : '0')
72
+ } catch {
73
+ // Storage unavailable: the state still flips for this session.
74
+ }
75
+ }
76
+
77
+ /** Inline gauge glyph, 14px beside the card title. */
78
+ function GaugeIcon(): ReactNode {
79
+ return (
80
+ <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
81
+ <path d="M2.5 12.5a7 7 0 0 1 11 -4.3" />
82
+ <path d="M8 12.5V8.2l2.9-2.1" />
83
+ <circle cx="8" cy="12.5" r="1" />
84
+ </svg>
85
+ )
86
+ }
87
+
88
+ /** Disclosure chevrons: down collapses the expanded card, up expands the strip. */
89
+ function ChevronIcon(props: { collapsed: boolean }): ReactNode {
90
+ return props.collapsed
91
+ ? (
92
+ <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
93
+ <path d="m4 10 4-4 4 4" />
94
+ </svg>
95
+ )
96
+ : (
97
+ <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
98
+ <path d="m4 6 4 4 4-4" />
99
+ </svg>
100
+ )
101
+ }
102
+
103
+ /** A provider row worth showing a balance for: configured, with a probed balance. */
104
+ function balanceRow(provider: ProviderSnapshotView): boolean {
105
+ const supported = provider.balanceSupported === true || (provider.balanceSupported === undefined && provider.supported)
106
+ return provider.credential !== 'none' && supported && provider.balance !== undefined
107
+ }
108
+
109
+ /** Currency-symbol prefix for the priced balances (CNY/USD), else the ISO code. */
110
+ function formatBalance(balance: BalanceView): string {
111
+ const currency = balance.currency.toUpperCase()
112
+ if (currency === 'CNY') return '¥' + balance.totalBalance
113
+ if (currency === 'USD') return '$' + balance.totalBalance
114
+ return currency + ' ' + balance.totalBalance
115
+ }
116
+
117
+ function formatClock(ms: number): string {
118
+ try {
119
+ return new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
120
+ } catch {
121
+ return ''
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Display name of the provider owning today's priced spend: the day's highest
127
+ * cost row, else the current session route, resolved through the provider
128
+ * snapshot (an older host without the day rows falls back to the current
129
+ * route). Undefined when there is no route to name.
130
+ */
131
+ function spendProvider(snapshot: UsageOverviewView): string | undefined {
132
+ let top: UsageOverviewView['usage']['today']['providers'][number] | undefined
133
+ for (const row of snapshot.usage.today.providers) {
134
+ if (row.totals.cost <= 0) continue
135
+ if (top === undefined || row.totals.cost > top.totals.cost) top = row
136
+ }
137
+ const route = top?.provider ?? snapshot.current.provider
138
+ if (route === undefined) return undefined
139
+ return snapshot.providers.find((entry) => entry.provider === route)?.displayName
140
+ ?? (route === snapshot.current.provider ? snapshot.current.displayName : undefined)
141
+ ?? route
142
+ }
143
+
144
+ /**
145
+ * The headline triple: today's priced spend, else today's tokens, else zero.
146
+ * tokenFallback tells the sub-line to keep only the call count instead of
147
+ * repeating the total the headline already shows; provider names the spender
148
+ * beside the collapsed cost headline.
149
+ */
150
+ function headline(snapshot: UsageOverviewView): { label: string; value: string; tokenFallback: boolean; provider?: string } {
151
+ const totals = snapshot.usage.today.totals
152
+ if (totals.cost > 0) return { label: t('usage.foot.cost'), value: '¥' + totals.cost.toFixed(2), tokenFallback: false, provider: spendProvider(snapshot) }
153
+ if (totals.calls > 0) return { label: t('usage.today'), value: formatTokens(totalTokens(totals)) + ' tokens', tokenFallback: true }
154
+ return { label: t('usage.foot.cost'), value: '¥0.00', tokenFallback: false }
155
+ }
156
+
157
+ /** The tokens/calls sub-line under the headline; a quiet no-data line on an empty day. */
158
+ function usageLine(snapshot: UsageOverviewView, tokenFallback: boolean): string {
159
+ const totals = snapshot.usage.today.totals
160
+ if (totals.calls === 0) return t('usage.foot.noData')
161
+ const calls = t('usage.calls', { n: totals.calls })
162
+ return tokenFallback ? calls : formatTokens(totalTokens(totals)) + ' tokens · ' + calls
163
+ }
164
+
165
+ /** The balance line: up to BALANCE_CAP providers plus a +N overflow marker. */
166
+ function balanceLine(snapshot: UsageOverviewView): string | undefined {
167
+ const rows = snapshot.providers.filter(balanceRow)
168
+ if (rows.length === 0) return undefined
169
+ const shown = rows.slice(0, BALANCE_CAP).map((provider) => provider.displayName + ' ' + formatBalance(provider.balance!))
170
+ return shown.join(' · ') + (rows.length > BALANCE_CAP ? ' +' + String(rows.length - BALANCE_CAP) : '')
171
+ }
172
+
173
+ /** Card chrome classes for the current collapse state. */
174
+ function cardClass(collapsed: boolean): string {
175
+ return collapsed ? styles.footCard + ' ' + styles.footCardCollapsed : styles.footCard
176
+ }
177
+
178
+ /**
179
+ * Render the foot card content for the current store snapshot.
180
+ * @param props - store, poller, settings form and open callback from the mount.
181
+ * @returns the card, or null while disabled / host-off.
182
+ */
183
+ export function UsageFootCard(props: UsageFootCardProps): ReactNode {
184
+ const { store, poll, onOpen, settings, locale } = props
185
+ const ui = useSyncExternalStore(store.subscribe, store.getSnapshot)
186
+ // Settings writes and locale switches both re-render the card's copy/visibility.
187
+ const [, bump] = useState(0)
188
+ useEffect(() => settings.subscribe(() => bump((count) => count + 1)), [settings])
189
+ useEffect(() => locale?.subscribe(() => bump((count) => count + 1)), [locale])
190
+ const enabled = settings.getSnapshot().value?.enabled ?? true
191
+
192
+ // The collapse choice is one localStorage flag, read at mount and persisted
193
+ // on every flip (the old sidebar strip used the same contract shape).
194
+ const [collapsed, setCollapsed] = useState(readFootCardCollapsed)
195
+ const toggleCollapsed = (): void => {
196
+ setCollapsed((current) => {
197
+ writeFootCardCollapsed(!current)
198
+ return !current
199
+ })
200
+ }
201
+
202
+ // The permanently seated card pays a relaxed cadence; a hidden tab pauses.
203
+ useEffect(() => {
204
+ if (!enabled) return undefined
205
+ poll()
206
+ let timer: number | undefined
207
+ const start = (): void => {
208
+ if (timer === undefined && document.visibilityState === 'visible') timer = window.setInterval(poll, FOOT_CARD_POLL_MS)
209
+ }
210
+ const onVisibility = (): void => {
211
+ if (document.visibilityState === 'visible') {
212
+ poll()
213
+ start()
214
+ } else if (timer !== undefined) {
215
+ window.clearInterval(timer)
216
+ timer = undefined
217
+ }
218
+ }
219
+ start()
220
+ document.addEventListener('visibilitychange', onVisibility)
221
+ return () => {
222
+ if (timer !== undefined) window.clearInterval(timer)
223
+ document.removeEventListener('visibilitychange', onVisibility)
224
+ }
225
+ }, [poll, enabled])
226
+
227
+ if (!enabled) return null
228
+ const snapshot = ui.snapshot
229
+ // A 404 means the host half serves no usage routes (plugin off): the card
230
+ // bows out instead of pinning a permanent error to the sidebar foot.
231
+ if (snapshot === null && ui.status === 'error' && /failed: 404/.test(ui.error ?? '')) return null
232
+
233
+ const toggle = (
234
+ <button
235
+ type="button"
236
+ className={styles.footToggle}
237
+ data-dsh-part="foot-card-toggle"
238
+ aria-label={t(collapsed ? 'usage.foot.expand' : 'usage.foot.collapse')}
239
+ title={t(collapsed ? 'usage.foot.expand' : 'usage.foot.collapse')}
240
+ onClick={toggleCollapsed}
241
+ >
242
+ <ChevronIcon collapsed={collapsed} />
243
+ </button>
244
+ )
245
+
246
+ if (snapshot === null) {
247
+ const quiet = ui.status === 'error' ? t('usage.error', { error: ui.error ?? '' }) : t('usage.loading')
248
+ return (
249
+ <div className={cardClass(collapsed)} data-dsh-plugin="usage" data-dsh-part="foot-card">
250
+ <button type="button" className={styles.footMain} data-dsh-part="foot-card-main" aria-label={t('usage.foot.open')} title={t('usage.foot.open')} onClick={onOpen}>
251
+ {collapsed
252
+ ? <span className={styles.footStrip} data-dsh-part="foot-card-strip"><GaugeIcon /><span className={styles.footStripValue}>—</span></span>
253
+ : (
254
+ <>
255
+ <span className={styles.footHead}>
256
+ <span className={styles.footTitle}><GaugeIcon />{t('usage.title')}</span>
257
+ </span>
258
+ <span className={styles.footLine}>{quiet}</span>
259
+ </>
260
+ )}
261
+ </button>
262
+ {toggle}
263
+ </div>
264
+ )
265
+ }
266
+
267
+ const head = headline(snapshot)
268
+ const balances = balanceLine(snapshot)
269
+
270
+ if (collapsed) {
271
+ return (
272
+ <div className={cardClass(true)} data-dsh-plugin="usage" data-dsh-part="foot-card">
273
+ <button type="button" className={styles.footMain} data-dsh-part="foot-card-main" aria-label={t('usage.foot.open')} title={t('usage.foot.open')} onClick={onOpen}>
274
+ <span className={styles.footStrip} data-dsh-part="foot-card-strip">
275
+ <GaugeIcon />
276
+ {head.provider !== undefined && <span className={styles.footStripProvider}>{head.provider}</span>}
277
+ <span className={styles.footStripLabel}>{head.label}</span>
278
+ <span className={styles.footStripValue}>{head.value}</span>
279
+ </span>
280
+ </button>
281
+ {toggle}
282
+ </div>
283
+ )
284
+ }
285
+
286
+ return (
287
+ <div className={cardClass(false)} data-dsh-plugin="usage" data-dsh-part="foot-card">
288
+ <button type="button" className={styles.footMain} data-dsh-part="foot-card-main" aria-label={t('usage.foot.open')} title={t('usage.foot.open')} onClick={onOpen}>
289
+ <span className={styles.footHead}>
290
+ <span className={styles.footTitle}><GaugeIcon />{head.label}</span>
291
+ <span className={styles.footValue}>{head.value}</span>
292
+ </span>
293
+ <span className={styles.footLine} data-dsh-part="foot-card-usage">{usageLine(snapshot, head.tokenFallback)}</span>
294
+ {balances !== undefined && <span className={styles.footLine} data-dsh-part="foot-card-balances">{balances}</span>}
295
+ <span className={styles.footMeta}>{t('usage.updated', { time: formatClock(snapshot.updatedAt) })}</span>
296
+ </button>
297
+ {toggle}
298
+ </div>
299
+ )
300
+ }
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { useEffect, useRef, useState, useSyncExternalStore, type ReactNode } from 'react'
12
- import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
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'
@@ -32,8 +32,8 @@ export interface UsageSectionFace {
32
32
  poll: () => void
33
33
  /** Force a host probe cycle now (resolves with the fresh overview). */
34
34
  refresh: () => void
35
- /** Whether a forced refresh is in flight (component-local state mirrors it). */
36
- settings: SettingsScope<UsageSettings>
35
+ /** The shared configuration form this section's settings row reads and writes. */
36
+ settings: ConfigForm<UsageSettings>
37
37
  }
38
38
 
39
39
  export interface UsageSectionProps extends UsageSectionFace {
@@ -153,9 +153,10 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
153
153
  const settingsValue = settingsSnapshot.value ?? {}
154
154
  const [tab, setTab] = useState<'usage' | 'plans' | 'bank'>('usage')
155
155
  const [refreshing, setRefreshing] = useState(false)
156
- // The enable checkbox writes through the settings scope, so subscribing here
157
- // keeps the flag below live: the poll starts and stops with it instead of
158
- // waiting for an unrelated render.
156
+ // The enable checkbox writes through the shared form, so subscribing here
157
+ // keeps the flag below live: the form republishes the Host's accepted value,
158
+ // and the poll starts and stops with it instead of waiting for an unrelated
159
+ // render.
159
160
  const [, bumpSettings] = useState(0)
160
161
  useEffect(() => settings.subscribe(() => bumpSettings((count) => count + 1)), [settings])
161
162
  const enabled = settingsValue.enabled ?? true
@@ -533,13 +534,39 @@ function PlanCard(props: { provider: ProviderSnapshotView; current?: string }):
533
534
  )
534
535
  }
535
536
 
537
+ /**
538
+ * The compact settings row. Both controls write through the shared form the
539
+ * moment the user changes them, and the Host answers each write with a
540
+ * boolean: a refused (or transport-failed) write is surfaced as a failed save,
541
+ * because a value that did not land must never read as applied.
542
+ */
536
543
  function SettingsRow(props: {
537
544
  settings: UsageSectionProps['settings']
538
- snapshot?: { writable: boolean }
545
+ snapshot?: ConfigFormSnapshot<UsageSettings>
539
546
  value: UsageSettings
540
547
  }): ReactNode {
541
548
  const { settings, snapshot, value } = props
542
549
  const disabled = snapshot === undefined || !snapshot.writable
550
+ const [failure, setFailure] = useState<string | undefined>(undefined)
551
+
552
+ const write = (field: 'enabled' | 'pollIntervalSec', next: boolean | number): void => {
553
+ setFailure(undefined)
554
+ let answer: Promise<boolean>
555
+ try {
556
+ answer = settings.set(field, next)
557
+ } catch (error) {
558
+ setFailure(error instanceof Error ? error.message : String(error))
559
+ return
560
+ }
561
+ // false is the contract's refusal/skip answer (the Host rejected the value,
562
+ // the entry is not writable, or the write was dropped); a rejecting
563
+ // transport reports through the same failed-save surface.
564
+ Promise.resolve(answer).then(
565
+ (accepted) => { if (!accepted) setFailure('') },
566
+ (error: unknown) => { setFailure(error instanceof Error ? error.message : String(error)) },
567
+ )
568
+ }
569
+
543
570
  return (
544
571
  <div className={styles.card} data-dsh-part="settings-row">
545
572
  <span className={styles.cardTitle}>{t('usage.config.title')}</span>
@@ -549,7 +576,7 @@ function SettingsRow(props: {
549
576
  type="checkbox"
550
577
  checked={value.enabled ?? true}
551
578
  disabled={disabled}
552
- onChange={(event) => { void settings.set('enabled', event.target.checked) }}
579
+ onChange={(event) => { write('enabled', event.target.checked) }}
553
580
  />
554
581
  {t('usage.config.enabled')}
555
582
  </label>
@@ -563,11 +590,16 @@ function SettingsRow(props: {
563
590
  disabled={disabled}
564
591
  onChange={(event) => {
565
592
  const parsed = Number(event.target.value)
566
- if (Number.isFinite(parsed) && parsed >= 30 && parsed <= 3600) void settings.set('pollIntervalSec', Math.round(parsed))
593
+ if (Number.isFinite(parsed) && parsed >= 30 && parsed <= 3600) write('pollIntervalSec', Math.round(parsed))
567
594
  }}
568
595
  />
569
596
  </label>
570
597
  </div>
598
+ {failure !== undefined && (
599
+ <span className={styles.errorLine} role="status">
600
+ {t('usage.config.saveFailed')}{failure === '' ? '' : ' - ' + failure}
601
+ </span>
602
+ )}
571
603
  </div>
572
604
  )
573
605
  }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Sidebar foot card mounting.
3
+ *
4
+ * The foot's only slot (sidebar.footer.action) stacks ABOVE the Settings row
5
+ * and cannot host a block, so the card container is inserted into the shell's
6
+ * foot area directly above the Settings seat — keeping Settings the foot's
7
+ * terminal row instead of hanging a card off the sidebar floor. It self-heals
8
+ * with the page-wide body-mutation hub: a React re-render that displaces the
9
+ * container re-seats it on the next frame, and a whole-pane rebuild is noticed
10
+ * by the same body-level watcher the family entry rows use.
11
+ *
12
+ * The container is a plain div carrying its own React root, so it can never
13
+ * disturb the shell's reconciliation.
14
+ * @module @linxin666/dsh-usage/client/foot-card-mount
15
+ */
16
+
17
+ import { createElement } from 'react'
18
+ import { createRoot, type Root } from 'react-dom/client'
19
+ import { subscribeBodyInvalidations } from './body-mutations.ts'
20
+ import { UsageFootCard, type UsageFootCardProps } from './UsageFootCard.tsx'
21
+
22
+ /** Stable data attribute identifying the injected card container. */
23
+ export const FOOT_CARD_SELECTOR = '[data-dsh-usage-foot-card]'
24
+
25
+ /** The shell sidebar's foot area (footer actions + the Settings row), when mounted. */
26
+ function footArea(): HTMLElement | undefined {
27
+ const column = document.querySelector<HTMLElement>('[data-pane="sidebar"], [class*="sidebarCol"]')
28
+ return column?.querySelector<HTMLElement>('[class*="footArea"]') ?? undefined
29
+ }
30
+
31
+ /**
32
+ * Open the settings panel on the usage section. The shell owns the panel's
33
+ * open state and exposes no service for it, so the card replays the user's
34
+ * own path: activate the sidebar Settings trigger, then pick the nav row
35
+ * carrying the section's (localized) label. Every step degrades silently —
36
+ * with the panel already open only the nav pick runs, and a missing row
37
+ * leaves the panel on its default section.
38
+ * @param label - the usage section's nav label in the active locale.
39
+ */
40
+ export function openUsageSettings(label: () => string): void {
41
+ /** Click the nav row carrying the usage label inside the open panel. */
42
+ const pick = (panel: Element): void => {
43
+ const wanted = label()
44
+ for (const row of panel.querySelectorAll<HTMLButtonElement>('nav button')) {
45
+ if (row.textContent !== null && row.textContent.includes(wanted)) {
46
+ row.click()
47
+ return
48
+ }
49
+ }
50
+ // Panel open but the usage row is gone (plugin toggled off): stay put.
51
+ }
52
+ // A panel that is already open must never be toggled shut by the trigger;
53
+ // only the nav pick runs.
54
+ const open = document.querySelector('[role="dialog"]')
55
+ if (open !== null) {
56
+ pick(open)
57
+ return
58
+ }
59
+ const column = document.querySelector<HTMLElement>('[data-pane="sidebar"], [class*="sidebarCol"]')
60
+ const trigger = column?.querySelector<HTMLButtonElement>('[class*="settingsArea"] button')
61
+ trigger?.click()
62
+ let tries = 0
63
+ const attempt = (): void => {
64
+ const panel = document.querySelector('[role="dialog"]')
65
+ if (panel !== null) {
66
+ pick(panel)
67
+ return
68
+ }
69
+ tries += 1
70
+ // The panel mounts on the shell's next frames; wait briefly, then give up.
71
+ if (tries <= 20) window.setTimeout(attempt, 50)
72
+ }
73
+ window.setTimeout(attempt, 0)
74
+ }
75
+
76
+ /**
77
+ * Mount the sidebar foot card directly above the Settings row.
78
+ * @param props - the store/poll/settings/open inputs of the apply body.
79
+ * @returns disposer removing the container and its observers.
80
+ */
81
+ export function mountUsageFootCard(props: UsageFootCardProps): () => void {
82
+ // DOM-level idempotency: whatever path mounted a card before this call (a
83
+ // duplicated apply, an HMR re-injection), never mount a second one.
84
+ if (typeof document !== 'undefined' && document.querySelector(FOOT_CARD_SELECTOR) !== null) {
85
+ return () => {}
86
+ }
87
+ const container = document.createElement('div')
88
+ container.setAttribute('data-dsh-usage-foot-card', '')
89
+ const root: Root = createRoot(container)
90
+ root.render(createElement(UsageFootCard, props))
91
+
92
+ /** Keep the container directly above the Settings seat inside the foot area. */
93
+ const place = (): void => {
94
+ const foot = footArea()
95
+ if (foot === undefined) return
96
+ const settings = foot.querySelector<HTMLElement>('[class*="settingsArea"]')
97
+ if (settings !== null) {
98
+ if (container.nextElementSibling !== settings) foot.insertBefore(container, settings)
99
+ return
100
+ }
101
+ // No Settings seat to anchor against (shell change): fall back to the tail.
102
+ if (foot.lastElementChild !== container) foot.append(container)
103
+ }
104
+ place()
105
+ const unsubscribeBody = subscribeBodyInvalidations(place)
106
+
107
+ return () => {
108
+ unsubscribeBody()
109
+ root.unmount()
110
+ container.remove()
111
+ }
112
+ }
@@ -1,25 +1,26 @@
1
1
  /**
2
2
  * dsh-usage browser half — seats the first-level 使用统计 settings section
3
- * (below the Workshop entry) and polls the host overview only while the
4
- * section is open. All provider probing and credential handling happens in
5
- * the host half; this bundle only renders the overview document.
3
+ * (below the Workshop entry) plus the compact usage glance card below the
4
+ * sidebar's Settings row. The section polls the host overview only while it
5
+ * is open; the foot card runs its own relaxed loop because the sidebar foot
6
+ * is permanently mounted. All provider probing and credential handling
7
+ * happens in the host half; this bundle only renders the overview document.
6
8
  * @module @linxin666/dsh-usage/client
7
9
  */
8
10
 
9
11
  import type { Context as ClientContext } from '@deepseek-ai/cordis'
10
- import type { SettingsScope, SettingsScopeSpec } from '@deepseek-ai/dsh-client-ui-settings/client'
12
+ import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client'
11
13
  // Type-only: pulls the locale plugin's Context merge (ctx.locale).
12
14
  import type {} from '@deepseek-ai/dsh-client-locale/client'
13
- // Type-only: pulls the settings-surface Context merge (ctx.settingsScope).
15
+ // Type-only: pulls the shared-forms Context merge (ctx.configForms).
14
16
  import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
15
17
  import type {} from '@deepseek-ai/dsh-client-ui-slots'
16
18
  // Type-only: pulls the ctx.slots merge (the renderer owns the slot registry since 0.1.2).
17
19
  import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
18
20
  import { createUsageStore, type UsageStoreInstance } from './usage-store.ts'
19
- import { mountSidebarEntry } from './sidebar-entry.ts'
20
- import { mountUsagePanel } from './sidebar-panel-mount.tsx'
21
21
  import { UsageSectionCard, type UsageSectionFace, type UsageSettings } from './UsageSectionCard.tsx'
22
- import { NS, en, zh } from './locales.ts'
22
+ import { mountUsageFootCard, openUsageSettings } from './foot-card-mount.tsx'
23
+ import { NS, en, zh, t } from './locales.ts'
23
24
  import type { UsageOverviewView } from '../core/types.ts'
24
25
 
25
26
  /** The host usage API as the browser sees it (same-origin JSON endpoints). */
@@ -45,27 +46,39 @@ const usageApi: UsageHttpApi = {
45
46
  refresh: () => usageFetch('/api/dsh-usage/refresh', 'POST'),
46
47
  }
47
48
 
48
- /** Settings namespace the section edits (the host plugin registers it). */
49
+ /** Settings namespace the section edits (dsh-web-settings maps it onto this row's profile entry id). */
49
50
  const USAGE_SETTINGS_NS = 'dsh-usage'
50
51
 
51
52
  /** First-level nav position: directly below the Workshop section (order 150). */
52
53
  const SECTION_ORDER = 151
53
54
 
54
55
  /** Required services. */
55
- export const inject = ['slots', 'locale', 'connection', 'settingsScope', 'remote']
56
+ export const inject = ['slots', 'locale', 'connection', 'configForms', 'remote']
56
57
 
57
58
  export type { UsageSectionProps, UsageSectionFace } from './UsageSectionCard.tsx'
58
59
  export type { UsageUiState } from './usage-store.ts'
59
60
  export type { UsageSettings }
60
61
 
62
+ /**
63
+ * One settings namespace a family card binds. The 0.1.7 client exports no spec
64
+ * type (the form controller takes it privately), so the binder's input shape is
65
+ * restated here.
66
+ */
67
+ export interface UsageSettingsBindSpec<T> {
68
+ /** Settings namespace registered by the owning host plugin. */
69
+ namespace: string
70
+ /** Narrow one wire section; undefined keeps the last accepted value. */
71
+ decode?: (section: unknown) => T | undefined
72
+ }
73
+
61
74
  declare module '@deepseek-ai/cordis' {
62
75
  interface Context {
63
76
  /**
64
- * Optional rc.6 compatibility binder provided by dsh-web-settings;
65
- * absent when that group plugin is not installed, so callers fall back to
66
- * the official settings scope.
77
+ * Optional family settings binder provided by dsh-web-settings; absent when
78
+ * that group plugin is not installed, so callers fall back to the native
79
+ * per-entry forms (`ctx.configForms`).
67
80
  */
68
- webUiSettings?: { bind<S>(spec: SettingsScopeSpec<S>): SettingsScope<S> }
81
+ webUiSettings?: { bind<S>(spec: UsageSettingsBindSpec<S>): ConfigForm<S> }
69
82
  }
70
83
  }
71
84
 
@@ -84,8 +97,16 @@ export function apply(ctx: ClientContext): void {
84
97
  }
85
98
  }, 'dsh-usage: dictionaries')
86
99
 
87
- const binder = ctx.get('webUiSettings') ?? ctx.settingsScope
88
- const settingsScope = binder.bind<UsageSettings>({ namespace: USAGE_SETTINGS_NS })
100
+ // The family binder is what maps this namespace onto the row's profile entry
101
+ // id and hands back the native form. Without the group plugin the client can
102
+ // only address an entry id it already knows: the namespace itself is one when
103
+ // the profile keeps the family spelling. Any other entry id leaves the form
104
+ // unavailable, which renders the row's controls disabled — the Host's own
105
+ // generated page for the row still edits the same Config.
106
+ const binder = ctx.get('webUiSettings')
107
+ const settingsForm = binder !== undefined
108
+ ? binder.bind<UsageSettings>({ namespace: USAGE_SETTINGS_NS })
109
+ : ctx.configForms.get<UsageSettings>(USAGE_SETTINGS_NS)
89
110
 
90
111
  // One store instance per apply body; the section mounts and unmounts with
91
112
  // the settings page, and the store survives between visits so the last
@@ -122,27 +143,26 @@ export function apply(ctx: ClientContext): void {
122
143
  })
123
144
  }
124
145
 
125
- const face = (): UsageSectionFace => ({ store, poll, refresh, settings: settingsScope })
126
-
127
- // Sidebar surface (issue #1592): the entry row seats the panel's controls
128
- // (refresh, collapse chevron); the collapsible panel directly under it reads
129
- // the same overview document. The mount owns the persisted open state; the
130
- // entry row mirrors it as highlight and chevron direction.
131
- const panel = mountUsagePanel({ store, poll })
132
- const disposeEntry = mountSidebarEntry({
133
- onToggle: () => { panel.toggle() },
134
- onRefresh: () => { refresh() },
135
- isOpen: () => panel.isOpen(),
136
- subscribeOpen: (listener) => panel.subscribe(listener),
137
- }, ctx.locale)
146
+ const face = (): UsageSectionFace => ({ store, poll, refresh, settings: settingsForm })
147
+
148
+ // Sidebar foot card: the compact usage glance seated below the shell's
149
+ // Settings row. It shares the section's store and poll path (the sequence
150
+ // guard absorbs interleaved calls), runs its own relaxed poll loop, and
151
+ // opens the settings panel on the usage section when clicked.
152
+ const disposeFootCard = mountUsageFootCard({
153
+ store,
154
+ poll,
155
+ settings: settingsForm,
156
+ onOpen: () => { openUsageSettings(() => t('usage.title')) },
157
+ locale: ctx.locale,
158
+ })
138
159
  ctx.effect(() => () => {
139
160
  try {
140
- disposeEntry()
161
+ disposeFootCard()
141
162
  } catch {
142
- // Entry row already gone (teardown race).
163
+ // Card container already gone (teardown race).
143
164
  }
144
- panel.dispose()
145
- }, 'dsh-usage: sidebar panel')
165
+ }, 'dsh-usage: sidebar foot card')
146
166
 
147
167
  ctx.slots.inject('settings.section', () => {
148
168
  try {