@linxin666/dsh-usage 0.3.23 → 0.4.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 (38) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +7 -9
  3. package/README.zh.md +7 -9
  4. package/icon.svg +3 -0
  5. package/lib/client.js +621 -65
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +258 -271
  8. package/lib/types/client/UsageFootCard.d.ts +55 -0
  9. package/lib/types/client/UsageSectionCard.d.ts +3 -4
  10. package/lib/types/client/body-mutations.d.ts +45 -0
  11. package/lib/types/client/foot-card-mount.d.ts +34 -0
  12. package/lib/types/client/index.d.ts +21 -8
  13. package/lib/types/client/locales.d.ts +6 -4
  14. package/lib/types/core/provider-routes.d.ts +26 -0
  15. package/lib/types/core/types.d.ts +14 -1
  16. package/lib/types/host/access.d.ts +17 -0
  17. package/lib/types/host/http.d.ts +18 -1
  18. package/lib/types/host/pair-access.d.ts +34 -0
  19. package/lib/types/host/routes.d.ts +16 -7
  20. package/lib/types/host/usage-service.d.ts +32 -76
  21. package/lib/types/index.d.ts +14 -2
  22. package/package.json +20 -17
  23. package/src/client/UsageFootCard.tsx +300 -0
  24. package/src/client/UsageSectionCard.tsx +41 -23
  25. package/src/client/body-mutations.ts +133 -0
  26. package/src/client/foot-card-mount.tsx +112 -0
  27. package/src/client/index.ts +57 -16
  28. package/src/client/locales.ts +12 -8
  29. package/src/client/usage.module.css +188 -0
  30. package/src/core/adapters.ts +40 -6
  31. package/src/core/provider-routes.ts +37 -0
  32. package/src/core/types.ts +14 -1
  33. package/src/host/access.ts +21 -0
  34. package/src/host/http.ts +23 -1
  35. package/src/host/pair-access.ts +49 -0
  36. package/src/host/routes.ts +19 -10
  37. package/src/host/usage-service.ts +74 -218
  38. package/src/index.ts +59 -60
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-usage",
3
- "description": "Usage statistics plugin for the dsh web GUI: per-provider balance and coding-plan quota detection plus a live token usage ledger, with a dedicated pet bubble for the current provider",
4
- "version": "0.3.23",
3
+ "description": "Usage statistics plugin for the dsh web GUI: per-provider balance and coding-plan quota detection plus a live token usage ledger, with a compact price glance card above the sidebar's Settings row",
4
+ "version": "0.4.0",
5
+ "icon": "icon.svg",
5
6
  "type": "module",
6
7
  "engines": {
7
8
  "node": "^22.19.0 || >=24.0.0"
@@ -26,12 +27,13 @@
26
27
  "lib/**/*.d.ts",
27
28
  "lib/**/*.d.ts.map",
28
29
  "src",
29
- "cordis.patch.yml"
30
+ "cordis.patch.yml",
31
+ "icon.svg"
30
32
  ],
31
33
  "license": "Apache-2.0",
32
34
  "dsh": {
33
35
  "engines": {
34
- "dsh": ">=0.1.5-rc.1"
36
+ "dsh": ">=0.1.7-rc.1"
35
37
  },
36
38
  "bundle": {
37
39
  "patch": "./cordis.patch.yml"
@@ -51,26 +53,27 @@
51
53
  "schemastery": "^3.18.0"
52
54
  },
53
55
  "peerDependencies": {
56
+ "@deepseek-ai/dsh": ">=0.1.7-rc.1",
54
57
  "react": "^18.2.0"
55
58
  },
56
59
  "devDependencies": {
57
- "@deepseek-ai/cordis": "^4.0.2",
58
- "@deepseek-ai/dsh-credentials": "^0.1.5-rc.1",
59
- "@deepseek-ai/dsh-client-connection": "^0.1.5-rc.1",
60
- "@deepseek-ai/dsh-client-locale": "^0.1.5-rc.1",
61
- "@deepseek-ai/dsh-client-store": "^0.1.5-rc.1",
62
- "@deepseek-ai/dsh-client-ui-renderer": "^0.1.5-rc.1",
63
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.5-rc.1",
64
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.1",
65
- "@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.1",
66
- "@deepseek-ai/dsh-llm": "^0.1.5-rc.1",
67
- "@deepseek-ai/dsh-session": "^0.1.5-rc.1",
68
- "@deepseek-ai/dsh-settings": "^0.1.5-rc.1",
69
- "@linxin666/dsh-pet": "0.3.23",
60
+ "@deepseek-ai/cordis": "^4.0.4",
61
+ "@deepseek-ai/dsh-credentials": "^0.1.7-rc.1",
62
+ "@deepseek-ai/dsh-client-connection": "^0.1.7-rc.1",
63
+ "@deepseek-ai/dsh-client-locale": "^0.1.7-rc.1",
64
+ "@deepseek-ai/dsh-client-store": "^0.1.7-rc.1",
65
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.1.7-rc.1",
66
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.7-rc.1",
67
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.7-rc.1",
68
+ "@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.1",
69
+ "@deepseek-ai/dsh-llm": "^0.1.7-rc.1",
70
+ "@deepseek-ai/dsh-session": "^0.1.7-rc.1",
71
+ "@deepseek-ai/dsh-settings": "^0.1.7-rc.1",
70
72
  "@testing-library/dom": "^10.4.1",
71
73
  "@testing-library/react": "^16.3.2",
72
74
  "@types/node": "^22.20.0",
73
75
  "@types/react": "~18.3.1",
76
+ "@types/react-dom": "^18.3.5",
74
77
  "jsdom": "29.1.1",
75
78
  "react": "^18.2.0",
76
79
  "react-dom": "^18.2.0",
@@ -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'
@@ -22,7 +22,6 @@ import type { ObservedSpendView, ProviderSnapshotView, UsageOverviewView, UsageP
22
22
  export interface UsageSettings {
23
23
  enabled?: boolean
24
24
  pollIntervalSec?: number
25
- bubbleMode?: string
26
25
  }
27
26
 
28
27
  /** The registration-side face the section's slot entry injects. */
@@ -33,8 +32,8 @@ export interface UsageSectionFace {
33
32
  poll: () => void
34
33
  /** Force a host probe cycle now (resolves with the fresh overview). */
35
34
  refresh: () => void
36
- /** Whether a forced refresh is in flight (component-local state mirrors it). */
37
- settings: SettingsScope<UsageSettings>
35
+ /** The shared configuration form this section's settings row reads and writes. */
36
+ settings: ConfigForm<UsageSettings>
38
37
  }
39
38
 
40
39
  export interface UsageSectionProps extends UsageSectionFace {
@@ -154,9 +153,10 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
154
153
  const settingsValue = settingsSnapshot.value ?? {}
155
154
  const [tab, setTab] = useState<'usage' | 'plans' | 'bank'>('usage')
156
155
  const [refreshing, setRefreshing] = useState(false)
157
- // The enable checkbox writes through the settings scope, so subscribing here
158
- // keeps the flag below live: the poll starts and stops with it instead of
159
- // 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.
160
160
  const [, bumpSettings] = useState(0)
161
161
  useEffect(() => settings.subscribe(() => bumpSettings((count) => count + 1)), [settings])
162
162
  const enabled = settingsValue.enabled ?? true
@@ -534,14 +534,39 @@ function PlanCard(props: { provider: ProviderSnapshotView; current?: string }):
534
534
  )
535
535
  }
536
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
+ */
537
543
  function SettingsRow(props: {
538
544
  settings: UsageSectionProps['settings']
539
- snapshot?: { writable: boolean }
545
+ snapshot?: ConfigFormSnapshot<UsageSettings>
540
546
  value: UsageSettings
541
547
  }): ReactNode {
542
548
  const { settings, snapshot, value } = props
543
549
  const disabled = snapshot === undefined || !snapshot.writable
544
- const bubbleMode = typeof value.bubbleMode === 'string' && ['always', 'change', 'off'].includes(value.bubbleMode) ? value.bubbleMode : 'always'
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
+
545
570
  return (
546
571
  <div className={styles.card} data-dsh-part="settings-row">
547
572
  <span className={styles.cardTitle}>{t('usage.config.title')}</span>
@@ -551,7 +576,7 @@ function SettingsRow(props: {
551
576
  type="checkbox"
552
577
  checked={value.enabled ?? true}
553
578
  disabled={disabled}
554
- onChange={(event) => { void settings.set('enabled', event.target.checked) }}
579
+ onChange={(event) => { write('enabled', event.target.checked) }}
555
580
  />
556
581
  {t('usage.config.enabled')}
557
582
  </label>
@@ -565,23 +590,16 @@ function SettingsRow(props: {
565
590
  disabled={disabled}
566
591
  onChange={(event) => {
567
592
  const parsed = Number(event.target.value)
568
- 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))
569
594
  }}
570
595
  />
571
596
  </label>
572
- <label className={styles.settingItem}>
573
- {t('usage.config.bubbleMode')}
574
- <select
575
- value={bubbleMode}
576
- disabled={disabled}
577
- onChange={(event) => { void settings.set('bubbleMode', event.target.value) }}
578
- >
579
- <option value="always">{t('usage.config.bubbleMode.always')}</option>
580
- <option value="change">{t('usage.config.bubbleMode.change')}</option>
581
- <option value="off">{t('usage.config.bubbleMode.off')}</option>
582
- </select>
583
- </label>
584
597
  </div>
598
+ {failure !== undefined && (
599
+ <span className={styles.errorLine} role="status">
600
+ {t('usage.config.saveFailed')}{failure === '' ? '' : ' - ' + failure}
601
+ </span>
602
+ )}
585
603
  </div>
586
604
  )
587
605
  }
@@ -0,0 +1,133 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/client/body-mutations.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * One `document.body` MutationObserver per page, shared by every family
4
+ * plugin bundle.
5
+ *
6
+ * Family plugins that inject at the DOM level must notice when the shell
7
+ * re-renders around their injected node: the sidebar entry rows
8
+ * (sidebar-entry-core), the center-column panel containers
9
+ * (panel-mount-core), and the aggregate shell's column shims. Each consumer
10
+ * used to install its OWN `MutationObserver` on `document.body` with
11
+ * `{ childList: true, subtree: true }`, so a page carrying N family plugins
12
+ * paid N native observers and N callback invocations for EVERY mutation batch
13
+ * the app produced — chat token streaming alone produces many per second, and
14
+ * the count grows with the number of installed plugins.
15
+ *
16
+ * This module keeps exactly ONE body observer per page. The registry lives on
17
+ * `globalThis` under a `Symbol.for` key, so the per-package generated copies
18
+ * of this file (scripts/sync-shared.mjs) and separately bundled plugins all
19
+ * reach the same hub at runtime instead of one hub per module instance.
20
+ * Record subscribers receive the mutations accumulated since the last flush.
21
+ * Consumers that only re-check their DOM use subscribeBodyInvalidations:
22
+ * when all subscribers use that path, the hub retains no mutation records
23
+ * (or detached subtrees) while a background page's animation frames pause.
24
+ * Both paths run at most once per frame. The last subscriber disconnects the
25
+ * observer, cancels the pending frame and releases its records.
26
+ *
27
+ * Failure policy: without a DOM or `MutationObserver` the subscription is a
28
+ * no-op disposer (the same silence the per-consumer observers had), and when
29
+ * `requestAnimationFrame` is unavailable the flush runs synchronously so a
30
+ * subscriber is never silently dropped. A throwing subscriber cannot stop the
31
+ * others.
32
+ */
33
+
34
+ /** The page-wide hub: one observer plus its subscribers and pending records. */
35
+ interface BodyMutationHub {
36
+ observer: MutationObserver
37
+ subscribers: Set<(records: MutationRecord[]) => void>
38
+ pending: MutationRecord[]
39
+ scheduled: boolean
40
+ frame?: number
41
+ }
42
+
43
+ /** Cross-bundle registry key; `Symbol.for` so every module copy agrees. */
44
+ const HUB_KEY = Symbol.for('dsh-web.body-mutation-hub')
45
+ const INVALIDATION_ONLY = Symbol.for('dsh-web.body-mutation-invalidation')
46
+ type Subscriber = ((records: MutationRecord[]) => void) & { [INVALIDATION_ONLY]?: true }
47
+
48
+ function needsRecords(subscribers: Set<(records: MutationRecord[]) => void>): boolean {
49
+ for (const listener of subscribers) {
50
+ if (!(listener as Subscriber)[INVALIDATION_ONLY]) return true
51
+ }
52
+ return false
53
+ }
54
+
55
+ /**
56
+ * Subscribe to a coalesced DOM re-check without retaining mutation records.
57
+ * The marked wrapper also works with an older hub, which delivers records
58
+ * that it simply ignores until a page reload picks up the updated hub.
59
+ */
60
+ export function subscribeBodyInvalidations(subscriber: () => void): () => void {
61
+ const listener: Subscriber = () => { subscriber() }
62
+ listener[INVALIDATION_ONLY] = true
63
+ return subscribeBodyMutations(listener)
64
+ }
65
+
66
+ /**
67
+ * Subscribe to body-level childList mutations.
68
+ * @param subscriber - called at most once per animation frame with the records
69
+ * collected since the previous flush; must be safe to run repeatedly.
70
+ * @returns the disposer removing this subscriber (and the observer when it was
71
+ * the last one).
72
+ */
73
+ export function subscribeBodyMutations(subscriber: (records: MutationRecord[]) => void): () => void {
74
+ if (typeof globalThis === 'undefined' || typeof document === 'undefined') return () => {}
75
+ if (typeof MutationObserver !== 'function') return () => {}
76
+ const registry = globalThis as unknown as Record<symbol, unknown>
77
+ let hub = registry[HUB_KEY] as BodyMutationHub | undefined
78
+ if (hub === undefined) {
79
+ const subscribers = new Set<(records: MutationRecord[]) => void>()
80
+ const created: BodyMutationHub = {
81
+ observer: undefined as unknown as MutationObserver,
82
+ subscribers,
83
+ pending: [],
84
+ scheduled: false,
85
+ }
86
+ const flush = (): void => {
87
+ created.frame = undefined
88
+ created.scheduled = false
89
+ const batch = created.pending
90
+ created.pending = []
91
+ for (const listener of [...subscribers]) {
92
+ if (!subscribers.has(listener)) continue
93
+ try {
94
+ listener(batch)
95
+ } catch {
96
+ // One subscriber must never break the others; the next flush retries it.
97
+ }
98
+ }
99
+ }
100
+ const schedule = (): void => {
101
+ if (created.scheduled) return
102
+ created.scheduled = true
103
+ if (typeof requestAnimationFrame === 'function') created.frame = requestAnimationFrame(flush)
104
+ else flush()
105
+ }
106
+ created.observer = new MutationObserver((records) => {
107
+ if (needsRecords(subscribers)) {
108
+ for (const record of records) created.pending.push(record)
109
+ }
110
+ schedule()
111
+ })
112
+ created.observer.observe(document.body ?? document.documentElement, { childList: true, subtree: true })
113
+ registry[HUB_KEY] = created
114
+ hub = created
115
+ }
116
+ const active = hub
117
+ active.subscribers.add(subscriber)
118
+ let subscribed = true
119
+ return () => {
120
+ if (!subscribed) return
121
+ subscribed = false
122
+ active.subscribers.delete(subscriber)
123
+ if (!needsRecords(active.subscribers)) active.pending = []
124
+ if (active.subscribers.size === 0 && registry[HUB_KEY] === active) {
125
+ active.observer.disconnect()
126
+ if (active.frame !== undefined && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(active.frame)
127
+ active.frame = undefined
128
+ active.pending = []
129
+ active.scheduled = false
130
+ delete registry[HUB_KEY]
131
+ }
132
+ }
133
+ }