@linxin666/dsh-usage 0.3.24 → 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.
@@ -48,6 +48,7 @@ export const zh = {
48
48
  'usage.config.title': '设置',
49
49
  'usage.config.enabled': '启用插件',
50
50
  'usage.config.pollIntervalSec': '轮询间隔(秒)',
51
+ 'usage.config.saveFailed': '保存失败:设置未被接受。',
51
52
  'usage.bank.title': '鲸元券',
52
53
  'usage.bank.hint': '官方 API 每消耗 100 万 tokens 铸造 1 鲸元;保存或分享这张票券。',
53
54
  'usage.bank.noUsage': '暂无 DeepSeek 官方用量数据(统计自插件启用起)',
@@ -58,15 +59,11 @@ export const zh = {
58
59
  'usage.bank.save': '保存图片',
59
60
  'usage.bank.share': '分享',
60
61
  'usage.bank.drawError': '票券生成失败:{error}',
61
- 'usage.sidebar.entry.label': '用量',
62
- 'usage.sidebar.entry.tooltip': '用量面板:套餐配额与余额',
63
- 'usage.sidebar.toggle.collapse': '折叠',
64
- 'usage.sidebar.toggle.expand': '展开',
65
- 'usage.sidebar.empty': '没有已配置的套餐或余额数据。',
66
- 'usage.sidebar.error': '加载失败:{error}',
67
- 'usage.sidebar.loading': '正在加载用量数据…',
68
- 'usage.sidebar.balanceLeft': '剩余 {balance}',
69
- 'usage.sidebar.todayUsage': '今日 {tokens} tokens',
62
+ 'usage.foot.cost': '今日消费',
63
+ 'usage.foot.noData': '今日暂无用量',
64
+ 'usage.foot.open': '打开使用统计设置',
65
+ 'usage.foot.collapse': '收起用量卡片',
66
+ 'usage.foot.expand': '展开用量卡片',
70
67
  }
71
68
 
72
69
  /** English mirror; every zh key present. */
@@ -110,6 +107,7 @@ export const en: Record<UsageKey, string> = {
110
107
  'usage.config.title': 'Settings',
111
108
  'usage.config.enabled': 'Enable plugin',
112
109
  'usage.config.pollIntervalSec': 'Poll interval (seconds)',
110
+ 'usage.config.saveFailed': 'Save failed: the deployment did not accept the setting.',
113
111
  'usage.bank.title': 'Whale-yuan voucher',
114
112
  'usage.bank.hint': 'Every 1,000,000 tokens spent on the official API mint one whale yuan; save or share the note.',
115
113
  'usage.bank.noUsage': 'No official DeepSeek usage yet (counting starts when the plugin is enabled)',
@@ -120,15 +118,11 @@ export const en: Record<UsageKey, string> = {
120
118
  'usage.bank.save': 'Save image',
121
119
  'usage.bank.share': 'Share',
122
120
  'usage.bank.drawError': 'Failed to render the voucher: {error}',
123
- 'usage.sidebar.entry.label': 'Usage',
124
- 'usage.sidebar.entry.tooltip': 'Usage panel: plan quotas and balances',
125
- 'usage.sidebar.toggle.collapse': 'Collapse',
126
- 'usage.sidebar.toggle.expand': 'Expand',
127
- 'usage.sidebar.empty': 'No plan or balance data configured.',
128
- 'usage.sidebar.error': 'Loading failed: {error}',
129
- 'usage.sidebar.loading': 'Loading usage data…',
130
- 'usage.sidebar.balanceLeft': '{balance} left',
131
- 'usage.sidebar.todayUsage': '{tokens} tokens today',
121
+ 'usage.foot.cost': 'Today spend',
122
+ 'usage.foot.noData': 'No usage yet today',
123
+ 'usage.foot.open': 'Open usage statistics settings',
124
+ 'usage.foot.collapse': 'Collapse the usage card',
125
+ 'usage.foot.expand': 'Expand the usage card',
132
126
  }
133
127
 
134
128
  export type UsageKey = keyof typeof zh
@@ -384,82 +384,71 @@
384
384
 
385
385
 
386
386
  /* ---------------------------------------------------------------------------
387
- Sidebar usage panel (issue #1592): the entry row reuses the shared
388
- sidebar-entry core (its class names are entry / entryIcon / entryLabel), and
389
- the collapsed rail rules mirror the skill center's so the row becomes an
390
- icon in the 56px rail. Tokens only; no framework styles.
387
+ Sidebar foot card: the compact usage glance the mount seats as the shell
388
+ foot area's last child, directly below the Settings row. The chrome is an
389
+ outer div; the content is one transparent main button (the whole card opens
390
+ the usage settings section) with a sibling corner chevron toggling the
391
+ collapsed strip — sibling buttons, since nesting buttons is invalid HTML.
392
+ Quiet card on the same currentColor mixes as the section cards; hidden in
393
+ the 56px rail (the frame carries data-sidebar-collapsed).
391
394
  --------------------------------------------------------------------------- */
392
395
 
393
- .entry {
396
+ /* Seated above the Settings row as a native sidebar row: the shell's row
397
+ radius and hover fill, with none of the card chrome that made it read as a
398
+ pill pasted onto the foot. Only the expanded body keeps a faint surface to
399
+ hold its four lines together; the collapsed strip is a plain 36px row. */
400
+ .footCard {
401
+ position: relative;
394
402
  box-sizing: border-box;
395
- display: flex;
396
- align-items: center;
397
- gap: 8px;
398
403
  width: 100%;
399
- height: 36px;
400
- padding: 0 10px;
401
- background: transparent;
404
+ margin: 2px 0 4px;
402
405
  border: none;
403
- border-radius: 8px;
404
- color: var(--dsw-alias-label-secondary);
405
- cursor: pointer;
406
- font-size: 13px;
407
- white-space: nowrap;
406
+ border-radius: 12px;
407
+ background: color-mix(in srgb, currentColor 4%, transparent);
408
+ color: inherit;
409
+ transition: background-color 120ms ease;
408
410
  }
409
411
 
410
- .entry:hover {
412
+ .footCard:hover {
411
413
  background: var(--dsw-alias-interactive-bg-hover);
412
- color: var(--dsw-alias-label-primary);
413
- }
414
-
415
- .entryIcon {
416
- display: inline-flex;
417
- align-items: center;
418
- justify-content: center;
419
- width: 24px;
420
- height: 24px;
421
- flex: none;
422
414
  }
423
415
 
424
- .entryIcon svg {
425
- display: block;
426
- width: 18px;
427
- height: 18px;
428
- }
429
-
430
- .entryLabel {
431
- overflow: hidden;
432
- text-overflow: ellipsis;
416
+ .footCardCollapsed {
417
+ background: transparent;
433
418
  }
434
419
 
435
- /* Composite row (the core's trailing-actions structure): the main button
436
- keeps the old row's hit area and the action buttons seat at the right
437
- edge. */
438
- .entryMain {
420
+ .footMain {
439
421
  appearance: none;
422
+ box-sizing: border-box;
440
423
  display: flex;
441
- align-items: center;
442
- gap: 8px;
443
- flex: 1;
444
- min-width: 0;
445
- height: 100%;
446
- padding: 0;
447
- background: transparent;
424
+ flex-direction: column;
425
+ gap: 4px;
426
+ width: 100%;
427
+ padding: 8px;
448
428
  border: none;
429
+ border-radius: 12px;
430
+ background: transparent;
449
431
  color: inherit;
450
432
  font: inherit;
451
433
  text-align: left;
452
434
  cursor: pointer;
453
435
  }
454
436
 
455
- .entryAction {
437
+ .footMain:focus-visible {
438
+ outline: none;
439
+ box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 45%, transparent);
440
+ }
441
+
442
+ .footToggle {
456
443
  appearance: none;
444
+ position: absolute;
445
+ top: 6px;
446
+ right: 6px;
457
447
  display: inline-flex;
458
448
  align-items: center;
459
449
  justify-content: center;
460
- width: 22px;
461
- height: 22px;
462
- flex: none;
450
+ width: 20px;
451
+ height: 20px;
463
452
  padding: 0;
464
453
  border: none;
465
454
  border-radius: 6px;
@@ -470,166 +459,113 @@
470
459
  transition: opacity 120ms ease, background-color 120ms ease;
471
460
  }
472
461
 
473
- .entry:hover .entryAction {
462
+ .footCard:hover .footToggle {
474
463
  opacity: 0.8;
475
464
  }
476
465
 
477
- .entryAction:hover {
466
+ .footToggle:hover {
478
467
  opacity: 1;
479
468
  background: color-mix(in srgb, currentColor 10%, transparent);
480
469
  }
481
470
 
482
- .entryAction:focus-visible,
483
- .entryMain:focus-visible {
471
+ .footToggle:focus-visible {
484
472
  outline: none;
485
473
  box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 45%, transparent);
486
474
  }
487
475
 
488
- .entryAction:disabled {
489
- cursor: default;
490
- opacity: 0.4;
491
- }
492
-
493
- .entryAction svg {
476
+ .footToggle svg {
494
477
  display: block;
495
- width: 14px;
496
- height: 14px;
497
- }
498
-
499
- /* Refresh cooldown: the spinning glyph marks the in-flight probe cycle. */
500
- .entryAction:disabled svg {
501
- animation: usageEntrySpin 900ms linear infinite;
502
478
  }
503
479
 
504
- @keyframes usageEntrySpin {
505
- to {
506
- transform: rotate(360deg);
507
- }
480
+ .footHead {
481
+ display: flex;
482
+ align-items: baseline;
483
+ justify-content: space-between;
484
+ gap: 8px;
508
485
  }
509
486
 
510
- :global([data-dsh-frame][data-sidebar-collapsed]) .entry,
511
- :global([data-sidebar-collapsed]) .entry {
512
- justify-content: center;
513
- width: 36px;
514
- height: 36px;
515
- padding: 0;
516
- margin: 0 auto 12px;
517
- border-radius: 50%;
487
+ .footTitle {
488
+ display: inline-flex;
489
+ align-items: center;
490
+ gap: 6px;
491
+ min-width: 0;
492
+ font-size: 12px;
493
+ opacity: 0.65;
494
+ white-space: nowrap;
518
495
  }
519
496
 
520
- :global([data-dsh-frame][data-sidebar-collapsed]) .entryMain,
521
- :global([data-sidebar-collapsed]) .entryMain {
497
+ .footTitle svg {
498
+ display: block;
522
499
  flex: none;
523
- width: 100%;
524
- justify-content: center;
525
- }
526
-
527
- :global([data-dsh-frame][data-sidebar-collapsed]) .entryAction,
528
- :global([data-sidebar-collapsed]) .entryAction {
529
- display: none;
530
- }
531
-
532
- :global([data-dsh-frame][data-sidebar-collapsed]) .sidebarSummary,
533
- :global([data-sidebar-collapsed]) .sidebarSummary {
534
- display: none;
535
- }
536
-
537
- :global([data-dsh-frame][data-sidebar-collapsed]) .entryLabel,
538
- :global([data-sidebar-collapsed]) .entryLabel {
539
- display: none;
540
500
  }
541
501
 
542
- /* Collapsed strip: the current session provider's today usage in one
543
- quiet line under the entry row (hidden in the 56px rail). */
544
- .sidebarSummary {
545
- display: flex;
546
- align-items: baseline;
547
- gap: 6px;
548
- margin: 0 4px 12px;
549
- padding: 0 10px;
550
- font-size: 11px;
551
- color: inherit;
552
- opacity: 0.75;
502
+ /* The headline value leaves room for the corner toggle. */
503
+ .footValue {
504
+ margin-right: 18px;
505
+ font-size: 15px;
506
+ font-weight: 600;
507
+ font-variant-numeric: tabular-nums;
553
508
  white-space: nowrap;
554
- overflow: hidden;
555
509
  }
556
510
 
557
- .sidebarSummaryName {
558
- flex: none;
559
- max-width: 40%;
511
+ .footLine {
512
+ font-size: 11px;
513
+ opacity: 0.7;
514
+ font-variant-numeric: tabular-nums;
560
515
  overflow: hidden;
561
516
  text-overflow: ellipsis;
562
- font-weight: 600;
517
+ white-space: nowrap;
563
518
  }
564
519
 
565
- .sidebarSummaryFacts {
566
- overflow: hidden;
567
- text-overflow: ellipsis;
520
+ .footMeta {
521
+ font-size: 10px;
522
+ opacity: 0.5;
568
523
  font-variant-numeric: tabular-nums;
569
524
  }
570
525
 
571
- /* Expanded panel: a quiet card stack directly under the entry row. */
572
- .sidebarPanel {
573
- display: flex;
574
- flex-direction: column;
575
- gap: 8px;
576
- margin: 0 4px 12px;
577
- padding: 10px 10px 12px;
578
- border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
579
- border-radius: 10px;
580
- color: inherit;
581
- background: color-mix(in srgb, currentColor 4%, transparent);
526
+ /* Collapsed strip: tighter chrome, one horizontal line, room for the toggle. */
527
+ .footCardCollapsed .footMain {
528
+ min-height: 36px;
529
+ padding: 7px 8px;
582
530
  }
583
531
 
584
- .sidebarProvider {
532
+ .footStrip {
585
533
  display: flex;
586
- flex-direction: column;
587
- gap: 4px;
588
- }
589
-
590
- .sidebarProviderHead {
591
- display: flex;
592
- align-items: baseline;
593
- justify-content: space-between;
594
- gap: 8px;
534
+ align-items: center;
535
+ gap: 6px;
536
+ padding-right: 20px;
595
537
  font-size: 12px;
596
- }
597
-
598
- .sidebarProviderName {
599
- overflow: hidden;
600
- text-overflow: ellipsis;
601
538
  white-space: nowrap;
539
+ overflow: hidden;
602
540
  }
603
541
 
604
- .sidebarBalance {
542
+ .footStrip svg {
543
+ display: block;
605
544
  flex: none;
606
- opacity: 0.75;
545
+ opacity: 0.65;
607
546
  }
608
547
 
609
- .sidebarWindowRow {
610
- display: flex;
611
- align-items: center;
612
- gap: 6px;
613
- font-size: 11px;
614
- opacity: 0.85;
548
+ .footStripProvider {
549
+ flex: none;
550
+ max-width: 45%;
551
+ overflow: hidden;
552
+ text-overflow: ellipsis;
553
+ font-weight: 500;
615
554
  }
616
555
 
617
- .sidebarBar {
618
- flex: 1;
619
- height: 4px;
620
- border-radius: 2px;
621
- background: color-mix(in srgb, currentColor 14%, transparent);
556
+ .footStripLabel {
557
+ opacity: 0.65;
622
558
  overflow: hidden;
559
+ text-overflow: ellipsis;
623
560
  }
624
561
 
625
- .sidebarBarFill {
626
- display: block;
627
- height: 100%;
628
- border-radius: 2px;
629
- background: currentColor;
562
+ .footStripValue {
563
+ margin-left: auto;
564
+ font-weight: 600;
565
+ font-variant-numeric: tabular-nums;
630
566
  }
631
567
 
632
- .sidebarMuted {
633
- font-size: 11px;
634
- opacity: 0.6;
568
+ :global([data-dsh-frame][data-sidebar-collapsed]) .footCard,
569
+ :global([data-sidebar-collapsed]) .footCard {
570
+ display: none;
635
571
  }
package/src/host/http.ts CHANGED
@@ -2,7 +2,9 @@
2
2
  /**
3
3
  * Shared JSON body/response helpers for the host route families: one strict
4
4
  * bounded body reader, one lenient bounded body reader, one JSON object
5
- * narrow, and one JSON writer. Previously these were copy-pasted across the
5
+ * narrow, one JSON writer, and the outbound request-init helper that keeps a
6
+ * host fetch body decodable (withIdentityEncoding). Previously the readers
7
+ * were copy-pasted across the
6
8
  * package route files (routes.ts, update-routes.ts, mobile-api.ts, and each
7
9
  * family's route module) with drifting contracts: body caps ranging 4 KiB to
8
10
  * 1 MiB and four distinct overflow behaviors (reject, undefined, null, throw).
@@ -88,6 +90,26 @@ export function asJsonObject(value: unknown): Record<string, unknown> | undefine
88
90
  return isJsonObject(value) ? value : undefined
89
91
  }
90
92
 
93
+ /**
94
+ * Request init that asks a remote origin for an uncompressed body.
95
+ *
96
+ * The DSH host boots `@deepseek-ai/dsh-http-proxy`, whose top-level import of
97
+ * the npm `undici` copy replaces the legacy `undici.globalDispatcher.1` slot
98
+ * Node's built-in `fetch()` reads. That cross-major wrapper drops the
99
+ * `content-encoding` header and automatic decompression, so a plain fetch
100
+ * resolves to raw gzip/brotli/zstd bytes — compressed noise no JSON or text
101
+ * consumer can read. Every host fetch that parses a remote body must request
102
+ * identity encoding. Caller headers survive; `accept-encoding` is forced to
103
+ * `identity` because a caller value has no decoder behind it in this host.
104
+ * @param init - the caller's request init (signal, headers, method, ...).
105
+ * @returns a copy with `accept-encoding: identity` merged in.
106
+ */
107
+ export function withIdentityEncoding(init: RequestInit = {}): RequestInit {
108
+ const headers = new Headers(init.headers)
109
+ headers.set('accept-encoding', 'identity')
110
+ return { ...init, headers }
111
+ }
112
+
91
113
  /**
92
114
  * Write one JSON response. Default headers are the family defaults
93
115
  * (content-type and referrer-policy); caller headers are appended or
@@ -14,6 +14,7 @@ import { credentialKey, credentialRef } from '@deepseek-ai/dsh-credentials'
14
14
  import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
15
15
  import type { TokenUsage } from '@deepseek-ai/dsh-llm'
16
16
  import { dshHome } from '../dsh-home.ts'
17
+ import { withIdentityEncoding } from './http.ts'
17
18
  import { adapterFor, isDeepSeekProviderRoute, providerErrorMessage } from '../core/adapters.ts'
18
19
  import type { BalanceParse, PlanParse } from '../core/adapters.ts'
19
20
  import { foldAliasRoutes } from '../core/provider-routes.ts'
@@ -164,8 +165,9 @@ export class UsageService {
164
165
 
165
166
  /**
166
167
  * Stop timers and flush pending ledger writes. The returned promise
167
- * resolves after the final flush lands, so a successor instance (quick
168
- * disable → enable) can serialize its first load behind it.
168
+ * resolves after the final flush lands, so a successor instance (the Host
169
+ * reloads the profile row on a config change) can serialize its first load
170
+ * behind it.
169
171
  */
170
172
  stop(): Promise<void> {
171
173
  this.disposed = true
@@ -175,7 +177,7 @@ export class UsageService {
175
177
  return this.flushLedger()
176
178
  }
177
179
 
178
- /** Re-apply options live (settings change); retention shrink prunes now. */
180
+ /** Re-apply options to this running instance; retention shrink prunes now. */
179
181
  applyOptions(options: UsageServiceOptions): void {
180
182
  this.options = options
181
183
  this.pruneIfNeeded()
@@ -596,7 +598,10 @@ export class UsageService {
596
598
  if (half === undefined) return undefined
597
599
  try {
598
600
  const spec = half.build({ apiKey: credential.key as string, ...(credential.accountId !== undefined ? { accountId: credential.accountId } : {}) })
599
- const response = await fetch(spec.url, { headers: spec.headers, signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) })
601
+ const response = await fetch(spec.url, withIdentityEncoding({
602
+ headers: spec.headers,
603
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
604
+ }))
600
605
  const body: unknown = await response.json().catch(() => undefined)
601
606
  if (!response.ok) throw new Error(providerErrorMessage(response.status, body))
602
607
  const parsed = half.parse(response.status, body)
package/src/index.ts CHANGED
@@ -8,6 +8,12 @@ import { makeUsageOverviewRoute, makeUsageRefreshRoute } from './host/routes.ts'
8
8
 
9
9
  export const name = 'dsh-usage'
10
10
  export const inject = ['webServer']
11
+ /**
12
+ * Family settings namespace this row serves. Since 0.1.7 the settings surface
13
+ * addresses one form per profile entry id and the Host generates this row's
14
+ * page from the Config schema below, so the namespace is only the alias the
15
+ * family settings bridge (dsh-web-settings) resolves onto this row's entry id.
16
+ */
11
17
  export const USAGE_SETTINGS_NAMESPACE = 'dsh-usage' as SettingsNamespace
12
18
 
13
19
  export interface Config {
@@ -36,74 +42,72 @@ export function resolveConfig(config?: Config): ResolvedConfig {
36
42
  }
37
43
  }
38
44
 
45
+ /**
46
+ * Tail of the process-wide flush chain; undefined while no predecessor's final
47
+ * ledger flush is still in flight. The Host reloads this profile row when its
48
+ * configuration changes, so a successor activation is a dispose + apply pair in
49
+ * the same process: its first ledger load must serialize behind its
50
+ * predecessor's final write (and behind the reloads before that one).
51
+ */
52
+ let pendingStop: Promise<unknown> | undefined
53
+
54
+ /**
55
+ * Host body. The plugin's own Config schema above is the settings document the
56
+ * 0.1.7 Host serves for this row; the effective config arrives here as the
57
+ * `config` argument the Loader resolved and the Host reloads the row when the
58
+ * user saves a change, so nothing in this body observes or writes settings.
59
+ * @param ctx - host root context.
60
+ * @param config - the row's resolved configuration.
61
+ */
39
62
  export const apply = mountOnce('@linxin666/dsh-usage', (ctx: Context, config?: Config): void => {
40
- let source: () => Config = () => config ?? {}
63
+ const value = resolveConfig(config)
64
+ // A disabled row mounts nothing: no probe cycle, no ledger, no routes. The
65
+ // settings page owns the enable switch, and re-enabling reloads this row.
66
+ if (!value.enabled) return
67
+
68
+ let live = true
41
69
  let service: UsageService | undefined
42
- let pendingStop: Promise<void> | undefined
43
70
  let disposeRoutes: (() => void) | undefined
44
71
 
45
- const rearm = (): void => {
46
- const value = resolveConfig(source())
47
- if (!value.enabled) {
48
- pendingStop = service?.stop()
49
- service = undefined
50
- disposeRoutes?.()
51
- disposeRoutes = undefined
52
- return
53
- }
54
- if (service === undefined) {
55
- const next = new UsageService(ctx, value)
56
- service = next
57
- const begin = (): void => {
58
- // A disable may have landed while the predecessor's final flush was
59
- // still pending; only start if this instance is still the live one.
60
- if (service !== next) return
61
- next.start()
62
- const disposers = [makeUsageOverviewRoute(ctx, next), makeUsageRefreshRoute(ctx, next)]
63
- .map((route) => ctx.webServer.register(route))
64
- disposeRoutes = () => {
65
- for (const dispose of disposers) {
66
- try {
67
- dispose()
68
- } catch {
69
- // Route fiber already gone during shutdown.
70
- }
71
- }
72
+ const begin = (): void => {
73
+ // The fiber may have gone down while the predecessor's final flush was
74
+ // still pending; only start while this activation is the live one.
75
+ if (!live) return
76
+ const next = new UsageService(ctx, value)
77
+ service = next
78
+ next.start()
79
+ const disposers = [makeUsageOverviewRoute(ctx, next), makeUsageRefreshRoute(ctx, next)]
80
+ .map((route) => ctx.webServer.register(route))
81
+ disposeRoutes = () => {
82
+ for (const dispose of disposers) {
83
+ try {
84
+ dispose()
85
+ } catch {
86
+ // Route fiber already gone during shutdown.
72
87
  }
73
88
  }
74
- // Serialize the start behind a just-stopped predecessor: its final
75
- // ledger flush must land on disk before this instance loads the file.
76
- if (pendingStop !== undefined) pendingStop.then(begin, begin)
77
- else begin()
78
- } else {
79
- service.applyOptions(value)
80
89
  }
81
90
  }
82
91
 
83
- ctx.inject(['settings'], (settingsCtx) => {
84
- try {
85
- if (typeof settingsCtx.settings?.installSection === 'function') {
86
- settingsCtx.settings.installSection(ctx, USAGE_SETTINGS_NAMESPACE, Config, config ?? {}, {
87
- setSource: (next) => { source = next; rearm() },
88
- onChange: rearm,
89
- })
90
- } else if (typeof settingsCtx.settings?.register === 'function') {
91
- const scope = settingsCtx.settings.register(USAGE_SETTINGS_NAMESPACE, Config, { base: config ?? {} })
92
- source = () => scope?.get?.() ?? (config ?? {})
93
- scope?.watch?.(() => { rearm() })
94
- rearm()
95
- }
96
- } catch {
97
- // Defensive fallback against settings registration differences
98
- }
99
- })
100
-
101
92
  ctx.effect(() => {
102
- rearm()
93
+ if (pendingStop === undefined) {
94
+ // No predecessor's flush is in flight: start now.
95
+ begin()
96
+ } else {
97
+ // Serialize the start behind every predecessor: their final writes must
98
+ // land on disk before this instance loads the file.
99
+ pendingStop = pendingStop.then(begin, begin).then(() => undefined)
100
+ }
103
101
  return () => {
102
+ live = false
104
103
  disposeRoutes?.()
105
- void service?.stop()
104
+ disposeRoutes = undefined
105
+ const stopping = service?.stop()
106
106
  service = undefined
107
+ // A successor waits for this instance's final flush, which itself waited
108
+ // for the predecessor's start: a row disposed before it ever started
109
+ // keeps the earlier link instead of dropping it.
110
+ if (stopping !== undefined) pendingStop = (pendingStop ?? Promise.resolve()).then(() => stopping)
107
111
  }
108
112
  }, 'dsh-usage: runtime')
109
113
  })
@@ -1,34 +0,0 @@
1
- /**
2
- * Sidebar usage panel (issue #1592): the usage body seated directly under the
3
- * sidebar entry row. The row carries the panel's controls (refresh, collapse
4
- * chevron), so this component renders only the content: expanded, plan
5
- * providers show their quota windows and balance-only providers their
6
- * remaining amount; collapsed, a one-line strip shows the current session
7
- * provider's today usage (tokens, calls, and cost when priced) from the same
8
- * /api/dsh-usage/overview document the settings section renders. The open
9
- * state lives in the mount (persisted to localStorage) and arrives through
10
- * the open face; polling runs at 10s expanded, 30s collapsed, and pauses
11
- * while the page is hidden.
12
- * @module @linxin666/dsh-usage/client/UsageSidebarPanel
13
- */
14
- import { type ReactNode } from 'react';
15
- import type { UsageStoreInstance } from './usage-store.ts';
16
- /** Expanded-state face the owning mount supplies (subscribe + current value). */
17
- export interface OpenStateSource {
18
- subscribe(listener: () => void): () => void;
19
- isOpen(): boolean;
20
- }
21
- /** Props of the sidebar panel (the apply body supplies the store and poller). */
22
- export interface UsageSidebarPanelProps {
23
- store: UsageStoreInstance;
24
- /** Fetch one overview now. */
25
- poll: () => void;
26
- /** Expanded state owned by the mount; false renders nothing and pauses polling. */
27
- open: OpenStateSource;
28
- }
29
- /**
30
- * Render the sidebar usage panel body.
31
- * @param props - store, the poll callback, and the mount's open-state face.
32
- * @returns the panel content, or null while collapsed.
33
- */
34
- export declare function UsageSidebarPanel(props: UsageSidebarPanelProps): ReactNode;