From 972ba8c98f4309dbc9f1f81a783ca3750a74415f Mon Sep 17 00:00:00 2001 From: Xiao Liu Date: Tue, 1 Sep 2026 19:13:46 +0800 Subject: [PATCH 1/2] =?UTF-8?q?refactor(desktop):=20move=20Settings=20?= =?UTF-8?q?=E2=86=92=20Usage=20into=20a=20features/usage=20slice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the Usage settings surface out of the frozen legacy renderer zone into a feature slice `src/renderer/features/usage/` (ports + services-context + ui), so net-new Usage work (the #2015/#4164 pricing tab) is no longer blocked by the tooltip and the read-only pricing tab). - `features/usage/` owns the surface: narrow `UsageServices` ports (`loadUsageStats`, `updateUsageSettings` — `UsageSettings` in and out, never the whole `AppSettings`); a persistent `UsageFeatureScope` holding the single tagged `{ range, value }` snapshot + reload ticket + Host-generation invalidation + load-failure toast; and a disposable `UsageSettingsView`. - `settings/usage-settings-page.tsx` is a thin legacy shim (`UsageScopeMount` + `UsageSettingsPage`). `settings-surface.tsx` mounts the scope ABOVE its loading/error gate and passes a `host:epoch` `targetKey` as a prop; the scope clears and fences in place on a target change (no React `key`). The Host-change handler also calls the scope's imperative `fenceTarget()` synchronously, so an in-flight old-Host load cannot land in the window before the re-render. - Regenerate renderer-architecture.json and the astryx surface inventory. Extraction-only; the Desktop adapter/composition wiring and the editable pricing tab remain follow-ups. Refs #4425. Generated-by: Claude Code --- apps/desktop/renderer-architecture.json | 32 +- .../__tests__/usage-settings-view.test.ts | 549 +++++++++++++++ .../src/renderer/features/usage/README.md | 94 +++ .../features/usage/controller/action-guard.ts | 66 ++ .../controller/optimistic-settings-draft.ts | 182 +++++ .../src/renderer/features/usage/index.ts | 25 + .../src/renderer/features/usage/ports.ts | 42 ++ .../features/usage/services-context.tsx | 180 +++++ .../usage/ui/metric-card.tsx} | 15 +- .../features/usage/ui/usage-settings-view.tsx | 498 ++++++++++++++ .../features/usage/ui/usage-stats-table.tsx | 134 ++++ .../renderer/settings/settings-surface.tsx | 121 +--- .../renderer/settings/usage-settings-page.tsx | 649 ++---------------- docs/astryx-surface-file-inventory.md | 9 +- docs/astryx-surface-file-inventory.paths | 5 +- 15 files changed, 1880 insertions(+), 721 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/usage-settings-view.test.ts create mode 100644 apps/desktop/src/renderer/features/usage/README.md create mode 100644 apps/desktop/src/renderer/features/usage/controller/action-guard.ts create mode 100644 apps/desktop/src/renderer/features/usage/controller/optimistic-settings-draft.ts create mode 100644 apps/desktop/src/renderer/features/usage/index.ts create mode 100644 apps/desktop/src/renderer/features/usage/ports.ts create mode 100644 apps/desktop/src/renderer/features/usage/services-context.tsx rename apps/desktop/src/renderer/{settings/settings-metric-card.tsx => features/usage/ui/metric-card.tsx} (67%) create mode 100644 apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx create mode 100644 apps/desktop/src/renderer/features/usage/ui/usage-stats-table.tsx diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 57999f4148..0f5e62d423 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -181,7 +181,6 @@ "src/renderer/settings/runtime-host-ssh-terminal-dialog.tsx", "src/renderer/settings/settings-error-copy.ts", "src/renderer/settings/settings-expandable-row.tsx", - "src/renderer/settings/settings-metric-card.tsx", "src/renderer/settings/settings-modal.tsx", "src/renderer/settings/settings-nav.ts", "src/renderer/settings/settings-request-authority.ts", @@ -3891,17 +3890,6 @@ "react": 1 } }, - "src/renderer/settings/settings-metric-card.tsx": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "@maka/ui": 1 - } - }, "src/renderer/settings/settings-modal.tsx": { "bridgePaths": {}, "environmentCapabilities": {}, @@ -4085,11 +4073,11 @@ "window.removeEventListener": 1 }, "hookCalls": { - "useEffect": 10, + "useEffect": 9, "useMediaQuery": 1, "useMountedRef": 1, - "useRef": 10, - "useState": 12, + "useRef": 8, + "useState": 11, "useToast": 1, "useUiLocale": 2 }, @@ -4247,29 +4235,17 @@ "bridgePaths": {}, "environmentCapabilities": {}, "hookCalls": { - "useActionGuard": 1, - "useOptimisticSettingsDraft": 1, - "useState": 1, - "useToast": 1, "useUiLocale": 1 }, "lifecycleMethods": {}, "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "../../shared/runtime-host-identity.js": 1, - "../locales/settings-usage-copy": 1, + "../features/usage": 2, "./settings-error-copy": 1, - "./settings-metric-card": 1, "./settings-section": 1, - "./use-action-guard": 1, - "./use-optimistic-settings-draft": 1, - "@astryxdesign/core": 1, "@maka/core/settings": 1, - "@maka/core/ui-locale": 1, - "@maka/core/usage-ledger-merge": 1, "@maka/ui": 1, - "@maka/ui/icons": 1, "react": 1 } }, diff --git a/apps/desktop/src/main/__tests__/usage-settings-view.test.ts b/apps/desktop/src/main/__tests__/usage-settings-view.test.ts new file mode 100644 index 0000000000..15323e86d3 --- /dev/null +++ b/apps/desktop/src/main/__tests__/usage-settings-view.test.ts @@ -0,0 +1,549 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { strict as assert } from 'node:assert'; +import { afterEach, describe, it } from 'node:test'; +import { parseHTML } from 'linkedom'; +import { act, createElement, createRef, type ReactNode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { AstryxLocaleProvider, LocaleProvider, ToastProvider } from '@maka/ui'; +import { EMPTY_USAGE_PROVENANCE } from '@maka/core/usage-ledger-merge'; +import { + createDefaultSettings, + mergeSettings, + type AppSettings, + type UsageRange, + type UsageStats, +} from '@maka/core/settings'; +import { + UsageFeatureScope, + UsageSettingsView, + type UsageScopeHandle, + type UsageServices, +} from '../../renderer/features/usage/index.js'; + + +function statsWithRequests(totalRequests: number): UsageStats { + return { + summary: { + totalRequests, + totalCostUsd: 0, + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheTokens: 0, + cacheMiss: 0, + cacheRead: 0, + cacheCreation: 0, + reasoning: 0, + }, + logs: [], + byProvider: [], + byModel: [], + byTool: [], + pricing: [], + provenance: EMPTY_USAGE_PROVENANCE, + }; +} + +interface Deferred { + promise: Promise; + resolve(value: T): void; + reject(error: unknown): void; +} +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +const originalGlobals = { + document: globalThis.document, + window: globalThis.window, + matchMedia: globalThis.matchMedia, + HTMLElement: globalThis.HTMLElement, + getComputedStyle: globalThis.getComputedStyle, + requestAnimationFrame: globalThis.requestAnimationFrame, + cancelAnimationFrame: globalThis.cancelAnimationFrame, + CSS: (globalThis as { CSS?: unknown }).CSS, + IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT, +}; +afterEach(() => Object.assign(globalThis, originalGlobals)); + +/** Install a linkedom DOM + the browser globals React DOM needs, return the root. */ +function setupDom(): { container: HTMLElement; root: Root } { + const { document, window } = parseHTML('
'); + const matchMedia = (media: string) => ({ + matches: false, + media, + onchange: null, + addListener() {}, + removeListener() {}, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent: () => false, + }); + Object.assign(window, { matchMedia, scrollTo: () => {} }); + Object.assign(globalThis, { + document, + window, + matchMedia, + HTMLElement: window.HTMLElement, + getComputedStyle: () => ({ color: 'currentColor' }) as CSSStyleDeclaration, + requestAnimationFrame: (cb: FrameRequestCallback) => setTimeout(cb, 0), + cancelAnimationFrame: (handle: number) => clearTimeout(handle), + CSS: { supports: () => false, escape: (v: string) => v }, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.querySelector('#root'); + assert.ok(container); + return { container, root: createRoot(container) }; +} + +/** + * Mounts the persistent `UsageFeatureScope` (given `targetKey`, as the settings + * surface derives it from `host:epoch`) with the view gated by `active` — the + * shape the real surface produces once the scope sits above the loading/error + * gate. So a section change or a Skeleton/Banner state is `active` toggling + * (the view unmounts, the scope does not), and a Host change is `targetKey` + * changing as a prop (no React `key`, so the scope resets in place rather than + * remounting the surface). + */ +function tree(opts: { + active: boolean; + settings: AppSettings; + targetKey: string; + services: UsageServices; +}): ReactNode { + return createElement(LocaleProvider, { + locale: 'en' as const, + children: createElement(AstryxLocaleProvider, { + children: createElement(ToastProvider, { + children: createElement(UsageFeatureScope, { + targetKey: opts.targetKey, + services: opts.services, + loadErrorTitle: 'load failed', + describeError: (error: unknown) => String(error), + children: opts.active + ? createElement(UsageSettingsView, { + settings: opts.settings.usage, + describeError: (error: unknown) => String(error), + }) + : null, + }), + }), + }), + }); +} + +const flush = async () => { + await Promise.resolve(); + await Promise.resolve(); +}; + +describe('Usage feature scope', () => { + it('re-displays the last snapshot immediately when returning to the section, then refreshes', async () => { + const { container, root } = setupDom(); + const base: AppSettings = mergeSettings(createDefaultSettings(), { + usage: { range: '24h', activeTab: 'providers' }, + }); + const loads = new Map>(); + const services: UsageServices = { + loadUsageStats: (range) => { + const d = deferred(); + loads.set(range, d); + return d.promise; + }, + updateUsageSettings: async (patch) => mergeSettings(base, { usage: patch }).usage, + }; + + // Load 24h → 111 while on the Usage section. + await act(async () => { + root.render(tree({ active: true, settings: base, targetKey: 'hostA:1', services })); + await Promise.resolve(); + }); + await act(async () => { + loads.get('24h')!.resolve(statsWithRequests(111)); + await flush(); + }); + assert.match(container.textContent ?? '', /111/, '24h totals should render'); + + // Leave the Usage section: the view unmounts, the scope stays mounted. + await act(async () => { + root.render(tree({ active: false, settings: base, targetKey: 'hostA:1', services })); + await Promise.resolve(); + }); + assert.doesNotMatch(container.textContent ?? '', /111/, 'the view should be gone while away'); + + // Return: the held snapshot must show immediately (before any new load + // resolves), i.e. stale-while-revalidate rather than a blank re-fetch. + await act(async () => { + root.render(tree({ active: true, settings: base, targetKey: 'hostA:1', services })); + await flush(); + }); + assert.match( + container.textContent ?? '', + /111/, + 'returning must re-display the retained snapshot immediately', + ); + + // The background refresh (triggered on remount) lands and updates the view. + await act(async () => { + loads.get('24h')!.resolve(statsWithRequests(222)); + await flush(); + }); + assert.match(container.textContent ?? '', /222/, 'the background refresh should update totals'); + + await act(async () => root.unmount()); + }); + + it('never shows the previous range while a new range loads, and drops a failed range', async () => { + const { container, root } = setupDom(); + const base: AppSettings = mergeSettings(createDefaultSettings(), { + usage: { range: '24h', activeTab: 'providers' }, + }); + const loads = new Map>(); + const services: UsageServices = { + loadUsageStats: (range) => { + const d = deferred(); + loads.set(range, d); + return d.promise; + }, + updateUsageSettings: async (patch) => mergeSettings(base, { usage: patch }).usage, + }; + + await act(async () => { + root.render(tree({ active: true, settings: base, targetKey: 'hostA:1', services })); + await Promise.resolve(); + }); + await act(async () => { + loads.get('24h')!.resolve(statsWithRequests(111)); + await flush(); + }); + assert.match(container.textContent ?? '', /111/, '24h totals should render'); + + // Switch persisted range to 7d; its load is pending — the 24h number must + // disappear immediately (a single tagged snapshot, not a per-range cache). + const sevenDay = mergeSettings(base, { usage: { range: '7d' } }); + await act(async () => { + root.render(tree({ active: true, settings: sevenDay, targetKey: 'hostA:1', services })); + await Promise.resolve(); + }); + assert.doesNotMatch( + container.textContent ?? '', + /111/, + 'the previous range total must not persist while 7d is loading', + ); + + // The 7d load fails — the stale 24h total must not reappear. + await act(async () => { + loads.get('7d')!.reject(new Error('boom')); + await flush(); + }); + assert.doesNotMatch( + container.textContent ?? '', + /111/, + 'a failed range load must not fall back to the previous range', + ); + + await act(async () => root.unmount()); + }); + + it('discards the previous Host generation snapshot when targetKey changes', async () => { + const { container, root } = setupDom(); + const base: AppSettings = mergeSettings(createDefaultSettings(), { + usage: { range: '24h', activeTab: 'providers' }, + }); + const loads = new Map>(); + let generation = 1; + const services: UsageServices = { + loadUsageStats: (range) => { + const d = deferred(); + loads.set(`${generation}:${range}`, d); + return d.promise; + }, + updateUsageSettings: async (patch) => mergeSettings(base, { usage: patch }).usage, + }; + + await act(async () => { + root.render(tree({ active: true, settings: base, targetKey: 'hostA:1', services })); + await Promise.resolve(); + }); + await act(async () => { + loads.get('1:24h')!.resolve(statsWithRequests(111)); + await flush(); + }); + assert.match(container.textContent ?? '', /111/, 'generation 1 totals should render'); + + // Host generation bumps (same host, new epoch) → `targetKey` changes as a + // prop and the scope resets in place (no remount), so the previous + // generation's snapshot is gone at once. + generation = 2; + await act(async () => { + root.render(tree({ active: true, settings: base, targetKey: 'hostA:2', services })); + await flush(); + }); + assert.doesNotMatch( + container.textContent ?? '', + /111/, + 'a Host generation change must discard the previous snapshot immediately', + ); + + await act(async () => root.unmount()); + }); + + it('accepts a load that resolves while the view is unmounted, visible on return', async () => { + const { container, root } = setupDom(); + const base: AppSettings = mergeSettings(createDefaultSettings(), { + usage: { range: '24h', activeTab: 'providers' }, + }); + const loads = new Map>(); + const services: UsageServices = { + loadUsageStats: (range) => { + const d = deferred(); + loads.set(range, d); + return d.promise; + }, + updateUsageSettings: async (patch) => mergeSettings(base, { usage: patch }).usage, + }; + + // Mount on Usage → a 24h load is in flight (not resolved yet). + await act(async () => { + root.render(tree({ active: true, settings: base, targetKey: 'hostA:1', services })); + await Promise.resolve(); + }); + + // Leave the section before the load resolves — the view unmounts. + await act(async () => { + root.render(tree({ active: false, settings: base, targetKey: 'hostA:1', services })); + await Promise.resolve(); + }); + + // The in-flight load resolves while the view is unmounted; the persistent + // scope must still accept it (no unmounted-view drop). + await act(async () => { + loads.get('24h')!.resolve(statsWithRequests(333)); + await flush(); + }); + + // Returning shows the result the scope received while unmounted. + await act(async () => { + root.render(tree({ active: true, settings: base, targetKey: 'hostA:1', services })); + await flush(); + }); + assert.match( + container.textContent ?? '', + /333/, + 'a load completing while unmounted must be visible on return', + ); + + await act(async () => root.unmount()); + }); + + it('fences a late load from a superseded Host generation', async () => { + const { container, root } = setupDom(); + const base: AppSettings = mergeSettings(createDefaultSettings(), { + usage: { range: '24h', activeTab: 'providers' }, + }); + const loads = new Map>(); + let generation = 1; + const services: UsageServices = { + loadUsageStats: (range) => { + const d = deferred(); + loads.set(`${generation}:${range}`, d); + return d.promise; + }, + updateUsageSettings: async (patch) => mergeSettings(base, { usage: patch }).usage, + }; + + // Generation 1's load is in flight (not resolved yet). + await act(async () => { + root.render(tree({ active: true, settings: base, targetKey: 'hostA:1', services })); + await Promise.resolve(); + }); + + // Host generation bumps to 2 before generation 1's load resolves; the scope + // resets and fences the in-flight generation-1 load in place. + generation = 2; + await act(async () => { + root.render(tree({ active: true, settings: base, targetKey: 'hostA:2', services })); + await flush(); + }); + + // The superseded generation-1 load resolves late — it must not land. + await act(async () => { + loads.get('1:24h')!.resolve(statsWithRequests(111)); + await flush(); + }); + assert.doesNotMatch( + container.textContent ?? '', + /111/, + 'a superseded Host generation load must be fenced, not shown', + ); + + // Generation 2's load resolves and is shown. + await act(async () => { + loads.get('2:24h')!.resolve(statsWithRequests(222)); + await flush(); + }); + assert.match(container.textContent ?? '', /222/, 'the current generation load lands'); + + await act(async () => root.unmount()); + }); + + it('keeps the snapshot while the loading gate shows a skeleton', async () => { + const { container, root } = setupDom(); + const base: AppSettings = mergeSettings(createDefaultSettings(), { + usage: { range: '24h', activeTab: 'providers' }, + }); + const loads = new Map>(); + const services: UsageServices = { + loadUsageStats: (range) => { + const d = deferred(); + loads.set(range, d); + return d.promise; + }, + updateUsageSettings: async (patch) => mergeSettings(base, { usage: patch }).usage, + }; + + // Mirrors the real surface: the scope sits ABOVE the loading/error gate, and + // `gated` swaps the view for a skeleton the way the gate does. The scope (and + // its snapshot) must not unmount when the gate closes. If the scope were moved + // back inside the gate, this topology — and the assertion below — would break. + const gateTree = (gated: boolean): ReactNode => + createElement(LocaleProvider, { + locale: 'en' as const, + children: createElement(AstryxLocaleProvider, { + children: createElement(ToastProvider, { + children: createElement(UsageFeatureScope, { + targetKey: 'hostA:1', + services, + loadErrorTitle: 'load failed', + describeError: (error: unknown) => String(error), + children: gated + ? createElement('div', null, 'Loading…') + : createElement(UsageSettingsView, { + settings: base.usage, + describeError: (error: unknown) => String(error), + }), + }), + }), + }), + }); + + await act(async () => { + root.render(gateTree(false)); + await Promise.resolve(); + }); + await act(async () => { + loads.get('24h')!.resolve(statsWithRequests(111)); + await flush(); + }); + assert.match(container.textContent ?? '', /111/, 'totals render before the gate closes'); + + // Gate shows a skeleton (e.g. switching to a not-yet-loaded section): the view + // unmounts, but the scope above the gate keeps the snapshot. + await act(async () => { + root.render(gateTree(true)); + await flush(); + }); + assert.doesNotMatch(container.textContent ?? '', /111/, 'the skeleton replaces the view'); + + // Gate reopens: the retained snapshot shows immediately, not a blank re-fetch. + await act(async () => { + root.render(gateTree(false)); + await flush(); + }); + assert.match( + container.textContent ?? '', + /111/, + 'the snapshot survives the loading gate and re-displays', + ); + + await act(async () => root.unmount()); + }); + + it('fences an in-flight load synchronously when the host changes before the re-render', async () => { + const { container, root } = setupDom(); + const base: AppSettings = mergeSettings(createDefaultSettings(), { + usage: { range: '24h', activeTab: 'providers' }, + }); + const loads = new Map>(); + const services: UsageServices = { + loadUsageStats: (range) => { + const d = deferred(); + loads.set(range, d); + return d.promise; + }, + updateUsageSettings: async (patch) => mergeSettings(base, { usage: patch }).usage, + }; + const scopeRef = createRef(); + const treeWithRef = (): ReactNode => + createElement(LocaleProvider, { + locale: 'en' as const, + children: createElement(AstryxLocaleProvider, { + children: createElement(ToastProvider, { + children: createElement(UsageFeatureScope, { + ref: scopeRef, + targetKey: 'hostA:1', + services, + loadErrorTitle: 'load failed', + describeError: (error: unknown) => String(error), + children: createElement(UsageSettingsView, { + settings: base.usage, + describeError: (error: unknown) => String(error), + }), + }), + }), + }), + }); + + // Mount → a 24h load is in flight (not resolved). + await act(async () => { + root.render(treeWithRef()); + await Promise.resolve(); + }); + + // Host changes: the settings surface fences synchronously at the Host event, + // before React re-renders a new targetKey. Drive that imperative call here. + act(() => { + scopeRef.current!.fenceTarget(); + }); + + // The in-flight load resolves *after* the synchronous fence — it must not + // land. This covers the event→commit window, not just a post-render + // targetKey change (which the other tests exercise). + await act(async () => { + loads.get('24h')!.resolve(statsWithRequests(111)); + await flush(); + }); + assert.doesNotMatch( + container.textContent ?? '', + /111/, + 'a load fenced at the host event must not land, even before the re-render', + ); + + await act(async () => root.unmount()); + }); +}); diff --git a/apps/desktop/src/renderer/features/usage/README.md b/apps/desktop/src/renderer/features/usage/README.md new file mode 100644 index 0000000000..43c0183a1c --- /dev/null +++ b/apps/desktop/src/renderer/features/usage/README.md @@ -0,0 +1,94 @@ + + +# Usage settings feature + +Extracts `Settings → Usage` out of the legacy renderer zone into a feature slice +(issue #4425), following the #3439 reference boundary (`ports / services-context / +ui`). + +## Why + +The renderer-architecture ratchet (#4088, R1 of #3439) freezes the legacy +AppShell closure: no new file may enter it and no legacy file's dependency count +may grow. `settings/usage-settings-page.tsx` is a frozen closure file, so +net-new Usage functionality — the editable pricing tab from #2015 / PR #4164, +which #2015 requires to live inside the Usage tabs — cannot be added there. +Moving the surface into `features/usage/` (exempt from closure debt) unblocks it +and shrinks legacy debt (`usage-settings-page.tsx` drops from ~14 dependencies +to a thin wrapper). + +## Boundary + +- `ports.ts` — `UsageServices`: `loadUsageStats(range)` and + `updateUsageSettings(patch)`. Both narrow — the feature consumes only + `UsageSettings`/`UsageStats`, never the whole `AppSettings`. +- `services-context.tsx` — `UsageFeatureScope`, the persistent state owner + (single tagged `{ range, value }` snapshot, reload ticket, unmount isolation, + Host/generation invalidation, load-failure toast), plus `useUsageServices()` + and `useUsageStats(range)`. +- `ui/usage-settings-view.tsx` — the surface (overview + tabs + per-tab panels). + A disposable view: it unmounts on a section change and reads the snapshot from + the scope via `useUsageStats`, so leaving/returning re-displays the last + snapshot immediately (stale-while-revalidate) instead of blanking. +- `ui/usage-stats-table.tsx`, `ui/metric-card.tsx`, `controller/*` — feature-owned + presentational + framework helpers (external-only deps). + +## Wiring (one deviation from the composition-feature pattern, forced by the ratchet) + +Unlike the composition-wired features, `settings-surface.tsx` is itself a frozen +legacy closure file, so it cannot import the feature or a `platform/` adapter, and +usage stats are scoped to the *settings-selected* Runtime Host (a settings concept +the app-global composition root does not have). So there is **no `platform/desktop` +adapter / no composition registration — a transitional seam.** `settings-surface.tsx` +builds a host-bound `loadUsageStats` (via its existing `window.maka.settings.usageStats` +call) plus an `updateUsageSettings` that projects the app-settings update down to +`UsageSettings`, bundles them as `UsageServices`, and mounts the legacy shim +(`settings/usage-settings-page.tsx`) at two levels: `UsageScopeMount` (hosting +`UsageFeatureScope`) is placed *above the loading/error gate*, so the snapshot +survives a Skeleton/Banner state or a section change; the disposable +`UsageSettingsPage` view is rendered in the section content slot and reads the scope +via context. The scope takes a `host:epoch` `targetKey` as a **prop** (not a React +`key`): on a change it clears the snapshot and fences the in-flight load *in place*, +so a Host change never remounts the rest of the Settings surface. The Host-change +handler also calls the scope's imperative `fenceTarget()` *synchronously* (alongside +the other Host-scoped resources), rejecting an in-flight old-Host load before React +re-renders the new target. When #4425's composition step lands, only this mounting +seam moves to `composition/desktop-feature-services.tsx` + a stateless +`platform/desktop` adapter — the scope stays feature-owned. + +Copy is **not** a deviation: the view imports `getUsageSettingsCopy` + +`UsageSettingsCopy` from `locales/settings-usage-copy.ts` directly. A feature import +of a validated copy catalog is closure-exempt (the ratchet's `isValidatedCopyCatalog`), +the same way workbar / goals / task-entry / session-navigation import their +`locales/*` copy. Only the legacy error helper (`describeError`) is injected by the +shim, since `settings-error-copy` is not a copy catalog. + +## Follow-up + +- Add the editable pricing tab (#2015 / PR #4164) as a feature-internal tab, + replacing the read-only pricing tab preserved here. +- De-duplicate the controllers. `controller/action-guard.ts` and + `controller/optimistic-settings-draft.ts` are feature-local copies of the legacy + `settings/` helpers (which keep ~9 consumers and their own tests). They are + covered here only indirectly via `usage-settings-view.test.ts`, not by the + legacy controller tests. Extracting the pure cores to `src/shared/` (the ratchet + treats `shared/` as external) with a thin React shell on each side is the real + fix, but it touches the legacy originals and their consumers, so it is left as a + focused follow-up rather than widening this extraction PR. diff --git a/apps/desktop/src/renderer/features/usage/controller/action-guard.ts b/apps/desktop/src/renderer/features/usage/controller/action-guard.ts new file mode 100644 index 0000000000..10157a0e30 --- /dev/null +++ b/apps/desktop/src/renderer/features/usage/controller/action-guard.ts @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useEffect, useRef } from 'react'; + +// Feature-local copy of the settings one-shot action guard (#4425): a +// synchronous single-latch re-entrancy guard for the usage refresh action, kept +// React-free at its core so the check happens before React can re-render. + +export interface OneShotActionGuard { + /** Acquire the latch for `action`; false when one is already in flight. */ + begin(action: Action): boolean; + /** Release the latch. */ + finish(): void; + readonly current: Action | null; +} + +export function createOneShotActionGuard(): OneShotActionGuard { + let current: Action | null = null; + return { + begin(action: Action): boolean { + if (current !== null) return false; + current = action; + return true; + }, + finish(): void { + current = null; + }, + get current(): Action | null { + return current; + }, + }; +} + +/** React shell that owns one guard for the component's lifetime and releases it + * on unmount (so a StrictMode remount is never stuck latched). */ +export function useActionGuard(): OneShotActionGuard { + const guardRef = useRef | null>(null); + if (guardRef.current === null) { + guardRef.current = createOneShotActionGuard(); + } + const guard = guardRef.current; + useEffect(() => { + return () => { + guard.finish(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return guard; +} diff --git a/apps/desktop/src/renderer/features/usage/controller/optimistic-settings-draft.ts b/apps/desktop/src/renderer/features/usage/controller/optimistic-settings-draft.ts new file mode 100644 index 0000000000..3501f937e6 --- /dev/null +++ b/apps/desktop/src/renderer/features/usage/controller/optimistic-settings-draft.ts @@ -0,0 +1,182 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useEffect, useRef, useState, type RefObject } from 'react'; +import { useMountedRef } from '@maka/ui'; + +// Feature-local copy of the settings optimistic last-write-wins draft (#4425). +// Pure controller + React shell. Invariants: a monotonic ticket makes overlapping +// saves last-write-wins; a pending count keeps a persisted-value sync from +// resetting local state mid-save; dispose invalidates an in-flight late write. + +export interface OptimisticDraftController { + readonly draftRef: { current: T }; + activate(): void; + syncPersisted(persisted: T): void; + edit(patch: Partial): void; + update(patch: Partial): Promise; + dispose(): void; +} + +interface OptimisticDraftControllerDeps { + initial: T; + onUpdate(patch: Partial): Promise; + onDraftChange(draft: T): void; + onReconcile?(draft: T): void; + onError?(error: unknown): void; + onSavingChange?(saving: boolean): void; + isMounted(): boolean; +} + +function createOptimisticDraftController( + deps: OptimisticDraftControllerDeps, +): OptimisticDraftController { + const draftRef = { current: deps.initial }; + const authoritativeRef = { current: deps.initial }; + let pendingSaveCount = 0; + let saveTicket = 0; + let confirmedSaveTicket = 0; + let lifecycleGeneration = 0; + let disposed = false; + + function commit(next: T): void { + draftRef.current = next; + deps.onDraftChange(next); + } + function reconcile(next: T): void { + commit(next); + deps.onReconcile?.(next); + } + function isCurrent(ticket: number, generation: number): boolean { + return !disposed && generation === lifecycleGeneration && deps.isMounted() && ticket === saveTicket; + } + function syncPersisted(persisted: T): void { + if (disposed) return; + authoritativeRef.current = persisted; + if (pendingSaveCount === 0) reconcile(persisted); + } + function activate(): void { + if (!disposed) return; + disposed = false; + deps.onSavingChange?.(false); + } + function edit(patch: Partial): void { + if (disposed) return; + commit({ ...draftRef.current, ...patch } as T); + } + async function update(patch: Partial): Promise { + if (disposed) return false; + const nextDraft = { ...draftRef.current, ...patch } as T; + saveTicket += 1; + pendingSaveCount += 1; + const ticket = saveTicket; + const generation = lifecycleGeneration; + commit(nextDraft); + if (pendingSaveCount === 1 && deps.isMounted()) deps.onSavingChange?.(true); + try { + const next = await deps.onUpdate(patch); + if (!disposed && generation === lifecycleGeneration && ticket > confirmedSaveTicket) { + confirmedSaveTicket = ticket; + authoritativeRef.current = next; + } + if (isCurrent(ticket, generation)) reconcile(next); + return isCurrent(ticket, generation); + } catch (error) { + if (isCurrent(ticket, generation)) { + reconcile(authoritativeRef.current); + deps.onError?.(error); + } + return false; + } finally { + if (generation === lifecycleGeneration) { + pendingSaveCount = Math.max(0, pendingSaveCount - 1); + if (!disposed && pendingSaveCount === 0 && deps.isMounted()) { + if (draftRef.current !== authoritativeRef.current) reconcile(authoritativeRef.current); + deps.onSavingChange?.(false); + } + } + } + } + function dispose(): void { + disposed = true; + lifecycleGeneration += 1; + pendingSaveCount = 0; + saveTicket += 1; + } + return { draftRef, activate, syncPersisted, edit, update, dispose }; +} + +export interface OptimisticSettingsDraft { + draft: T; + draftRef: { current: T }; + mountedRef: RefObject; + saving: boolean; + edit(patch: Partial): void; + update(patch: Partial): Promise; +} + +export function useOptimisticSettingsDraft( + persisted: T, + onUpdate: (patch: Partial) => Promise, + options?: { onError?(error: unknown): void; onReconcile?(persisted: T): void }, +): OptimisticSettingsDraft { + const mountedRef = useMountedRef(); + const [draft, setDraft] = useState(persisted); + const [saving, setSaving] = useState(false); + const onUpdateRef = useRef(onUpdate); + onUpdateRef.current = onUpdate; + const onErrorRef = useRef(options?.onError); + onErrorRef.current = options?.onError; + const onReconcileRef = useRef(options?.onReconcile); + onReconcileRef.current = options?.onReconcile; + + const controllerRef = useRef | null>(null); + if (controllerRef.current === null) { + controllerRef.current = createOptimisticDraftController({ + initial: persisted, + onUpdate: (patch) => onUpdateRef.current(patch), + onDraftChange: setDraft, + onError: (error) => onErrorRef.current?.(error), + onReconcile: (next) => onReconcileRef.current?.(next), + onSavingChange: setSaving, + isMounted: () => mountedRef.current === true, + }); + } + const controller = controllerRef.current; + + useEffect(() => { + controller.activate(); + return () => controller.dispose(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + controller.syncPersisted(persisted); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [persisted]); + + return { + draft, + draftRef: controller.draftRef, + mountedRef, + saving, + edit: controller.edit, + update: controller.update, + }; +} diff --git a/apps/desktop/src/renderer/features/usage/index.ts b/apps/desktop/src/renderer/features/usage/index.ts new file mode 100644 index 0000000000..bb59e3d785 --- /dev/null +++ b/apps/desktop/src/renderer/features/usage/index.ts @@ -0,0 +1,25 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Public API of the Usage settings feature (issue #4425). Legacy consumers +// import only from this barrel. + +export { UsageSettingsView } from './ui/usage-settings-view.js'; +export { UsageFeatureScope, type UsageScopeHandle } from './services-context.js'; +export type { UsageServices } from './ports.js'; diff --git a/apps/desktop/src/renderer/features/usage/ports.ts b/apps/desktop/src/renderer/features/usage/ports.ts new file mode 100644 index 0000000000..a672b6225d --- /dev/null +++ b/apps/desktop/src/renderer/features/usage/ports.ts @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { UsageRange, UsageSettings, UsageStats } from '@maka/core/settings'; + +// Dependency-inversion boundary for the Usage settings feature (issue #4425). +// The feature controller owns draft/state and reads these ports; it never +// touches `window.maka` or legacy settings helpers directly. Both are narrow — +// the feature consumes only `UsageSettings`, never the whole `AppSettings`. This +// contract is currently implemented in the legacy `settings/settings-surface.tsx` +// (bound to the settings-selected Runtime Host and its settings-update +// reconciliation), which is a transitional seam — not yet the +// `composition/desktop-feature-services.tsx` + `platform/desktop` adapter that +// #4425 ultimately targets. +export interface UsageServices { + /** Host-scoped usage stats for a range (`null` = no Host / not loaded yet). */ + loadUsageStats(range: UsageRange): Promise; + /** + * Persist a usage display-preferences patch and resolve with the reconciled + * usage settings. Routes through the app settings update (client-owned + * settings) so the settings-surface reconciliation (uiLocale gate + + * client-settings reload) is preserved; the adapter projects the result down + * to `UsageSettings` so the feature never sees the whole `AppSettings`. + */ + updateUsageSettings(patch: Partial): Promise; +} diff --git a/apps/desktop/src/renderer/features/usage/services-context.tsx b/apps/desktop/src/renderer/features/usage/services-context.tsx new file mode 100644 index 0000000000..5993bc2327 --- /dev/null +++ b/apps/desktop/src/renderer/features/usage/services-context.tsx @@ -0,0 +1,180 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + createContext, + forwardRef, + useCallback, + useContext, + useImperativeHandle, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react'; +import { useMountedRef, useToast } from '@maka/ui'; +import type { UsageRange, UsageStats } from '@maka/core/settings'; +import type { UsageServices } from './ports.js'; + +interface UsageSnapshot { + readonly range: UsageRange; + readonly value: UsageStats | null; +} + +/** + * Imperative handle the legacy surface uses to fence Usage synchronously the + * instant the selected Host changes — before React re-renders the new + * `targetKey`. This closes the window where an in-flight old-Host load could + * resolve and land between the Host event and the commit. + */ +export interface UsageScopeHandle { + fenceTarget(): void; +} + +interface UsageScopeValue { + readonly services: UsageServices; + readonly snapshot: UsageSnapshot | null; + /** The current Host generation (`host:epoch`); changes when the target does. */ + readonly targetKey: string; + reload(range: UsageRange): Promise; +} + +const UsageScopeContext = createContext(null); + +/** + * Persistent, feature-owned scope for the Usage surface (issue #4425). + * + * The host mounts this above the settings loading/error gate and hands it a + * `targetKey` (`host:epoch`). That placement is the whole point: it lets the + * loaded snapshot survive a Skeleton/Banner state or a section change (the view + * below unmounts, the scope does not). A Host/generation change is signalled by + * `targetKey` changing — the scope clears its snapshot and invalidates any + * in-flight load *without remounting*, so the rest of the settings surface is + * untouched (using a React `key` here would remount every settings page). + * Mounting is wired by the legacy settings surface; the snapshot, the reload + * ticket, unmount isolation, target invalidation, and load-failure reporting are + * owned here, so the disposable view only reads them through `useUsageStats`. + * + * It keeps a single tagged `{ range, value }` snapshot (not a per-range cache): + * the previous surface held exactly one current snapshot, so returning to a + * range visited earlier reloads rather than resurrecting stale data. + */ +export const UsageFeatureScope = forwardRef< + UsageScopeHandle, + { + /** Selected Host generation (`host:epoch`); a change resets the snapshot. */ + readonly targetKey: string; + readonly services: UsageServices; + /** Toast title for a failed stats load (injected; the feature holds no legacy copy). */ + readonly loadErrorTitle: string; + /** Localize a load failure for the toast body (injected by the legacy wrapper). */ + describeError(error: unknown): string; + readonly children?: ReactNode; + } +>(function UsageFeatureScope(props, ref) { + const toast = useToast(); + const mountedRef = useMountedRef(); + const [snapshot, setSnapshot] = useState(null); + const [renderedTargetKey, setRenderedTargetKey] = useState(props.targetKey); + const reloadTicketRef = useRef(0); + const { targetKey, services, loadErrorTitle, describeError } = props; + + // Reset on a target (Host generation) change without remounting the subtree: + // drop the previous Host's snapshot and invalidate its in-flight load so it + // neither lingers nor lands. This is the documented "adjust state during + // render" pattern; it runs once because `renderedTargetKey` then matches. + if (targetKey !== renderedTargetKey) { + setRenderedTargetKey(targetKey); + setSnapshot(null); + reloadTicketRef.current += 1; + } + + // Last-write-wins across concurrent reloads: a superseded (newer reload or a + // target change) or post-unmount load neither publishes its snapshot nor + // toasts. Because the scope outlives the view, a reload started before the + // view unmounts still lands here (its result is visible on return). + const reload = useCallback( + async (range: UsageRange): Promise => { + const ticket = ++reloadTicketRef.current; + try { + const value = await services.loadUsageStats(range); + if (mountedRef.current && ticket === reloadTicketRef.current) { + setSnapshot({ range, value }); + } + } catch (error) { + if (mountedRef.current && ticket === reloadTicketRef.current) { + toast.error(loadErrorTitle, describeError(error)); + } + } + }, + [services, loadErrorTitle, describeError, toast, mountedRef], + ); + + // Fence synchronously when the host signals a target change, before React + // re-renders the new `targetKey` — mirrors the previous surface, which bumped + // the reload ticket and cleared the snapshot inside the Host-change handler. + // The render-time reset above still runs on the following render; this only + // closes the event→commit window where an old-Host load could still land. + useImperativeHandle( + ref, + () => ({ + fenceTarget: () => { + reloadTicketRef.current += 1; + setSnapshot(null); + }, + }), + [], + ); + + const value = useMemo( + () => ({ services, snapshot, targetKey, reload }), + [services, snapshot, targetKey, reload], + ); + + return {props.children}; +}); + +function useUsageScope(): UsageScopeValue { + const value = useContext(UsageScopeContext); + if (!value) throw new Error('UsageFeatureScope is missing'); + return value; +} + +/** Persistence port for the current-range settings update (used by the draft). */ +export function useUsageServices(): UsageServices { + return useUsageScope().services; +} + +/** + * Read the stats for `range` plus the scope's `reload` and `targetKey`. Stats are + * surfaced only when the held snapshot was loaded for the requested range; during + * a range switch (or after a late/failed load, or a target change) the tagged + * range no longer matches, so the caller reads `null` (loading/empty) instead of + * a stale range's numbers. `targetKey` is returned so the view can retrigger a + * load when the Host generation changes (the previous surface reloaded on epoch). + */ +export function useUsageStats(range: UsageRange): { + readonly stats: UsageStats | null; + readonly targetKey: string; + reload(range: UsageRange): Promise; +} { + const { snapshot, targetKey, reload } = useUsageScope(); + const stats = snapshot && snapshot.range === range ? snapshot.value : null; + return { stats, targetKey, reload }; +} diff --git a/apps/desktop/src/renderer/settings/settings-metric-card.tsx b/apps/desktop/src/renderer/features/usage/ui/metric-card.tsx similarity index 67% rename from apps/desktop/src/renderer/settings/settings-metric-card.tsx rename to apps/desktop/src/renderer/features/usage/ui/metric-card.tsx index dd7dc95b3d..69f6ec1e4c 100644 --- a/apps/desktop/src/renderer/settings/settings-metric-card.tsx +++ b/apps/desktop/src/renderer/features/usage/ui/metric-card.tsx @@ -19,8 +19,8 @@ import { StatTile } from '@maka/ui'; -/** Thin alias over the shared StatTile (convergence R4) — usage/bot call - * sites keep their name; the recipe lives in the primitive. */ +/** Thin alias over the shared StatTile — feature-local copy of the settings + * MetricCard so the Usage feature carries no legacy import (#4425). */ export function MetricCard(props: { title: string; value: string; detail?: string }) { return ( /* One tile language across every settings summary strip: this used to ask @@ -34,14 +34,3 @@ export function MetricCard(props: { title: string; value: string; detail?: strin /> ); } - -// Segmented controls are owned directly by Astryx. -// (the retired local segmented-control implementation). PR yuejing/settings-segmented-primitive -// (WAWQAQ msg `f1461d30` 用库的应该用库). - -/** - * PR-USE-SHADCN-BASE-UI-BADGE — map the project's status-tone vocabulary - * (success / warning / destructive / info / neutral) onto the canonical - * shadcn `PrimitiveBadge` variants. `neutral` falls back to `secondary` - * which is the closest "muted chip" appearance the Badge primitive ships. - */ diff --git a/apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx b/apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx new file mode 100644 index 0000000000..61df3dcdd8 --- /dev/null +++ b/apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx @@ -0,0 +1,498 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useEffect, useMemo, useState, type ReactNode } from 'react'; +import { + SegmentedControl, + SegmentedControlItem, + Tab, + TabList, + Tooltip, +} from '@astryxdesign/core'; +import { uiLocaleToIntlLocale } from '@maka/core/ui-locale'; +import { parseDesktopSessionKey } from '../../../../shared/runtime-host-identity.js'; +import type { UsageRange, UsageSettings, UsageStats } from '@maka/core/settings'; +import { estimatedUsageCost, hasUnavailableUsage } from '@maka/core/usage-ledger-merge'; +import { Button, TextInput, Selector, Switch, useToast, useUiLocale, Banner } from '@maka/ui'; +import { ICON_SIZE, Activity, BarChart3, Cpu, Database, RefreshCcw, Search } from '@maka/ui/icons'; +import { + getUsageSettingsCopy, + type UsageSettingsCopy, +} from '../../../locales/settings-usage-copy.js'; +import { MetricCard } from './metric-card.js'; +import { UsageStatsTable } from './usage-stats-table.js'; +import { useActionGuard } from '../controller/action-guard.js'; +import { useOptimisticSettingsDraft } from '../controller/optimistic-settings-draft.js'; +import { useUsageServices, useUsageStats } from '../services-context.js'; + +type UsageActiveTab = UsageSettings['activeTab']; + +/** + * The Usage settings surface (issue #4425). A disposable view: it unmounts when + * the user leaves the Usage section. The loaded stats snapshot lives in the + * persistent `UsageFeatureScope` (read via `useUsageStats`), so leaving and + * returning re-displays the last snapshot immediately while a background reload + * refreshes it. Copy comes straight from the locale catalog (a feature import of + * a validated catalog is closure-exempt); only the legacy error-message helper + * is injected via `describeError`. + */ +export function UsageSettingsView(props: { + settings: UsageSettings; + describeError(error: unknown): string; + onOpenSession?(sessionId: string): void; +}) { + const services = useUsageServices(); + const locale = useUiLocale(); + const copy = getUsageSettingsCopy(locale); + const toast = useToast(); + const persistedUsage = props.settings; + // The stats snapshot lives in the persistent `UsageFeatureScope` (keyed by the + // selected Host generation), so it survives this view unmounting on a section + // switch. `stats` is non-null only when the scope's snapshot was loaded for the + // persisted range — during a range switch (or after a late/failed load) the + // panels read `null` (loading/empty) rather than the previous range's numbers. + const { stats, reload, targetKey } = useUsageStats(persistedUsage.range); + const [refreshing, setRefreshing] = useState(false); + const usageRefreshGuard = useActionGuard<'refresh'>(); + const { + draft: usageDraft, + draftRef: usageDraftRef, + mountedRef: usagePageMountedRef, + update, + } = useOptimisticSettingsDraft( + persistedUsage, + (patch) => services.updateUsageSettings(patch), + { onError: (error) => toast.error(copy.saveFailed, props.describeError(error)) }, + ); + + // Usage records are Host-owned; display preferences are client-owned. Trigger a + // background reload on mount, whenever the persisted range changes, and whenever + // the Host generation changes (`targetKey`) — the last mirrors the previous + // surface's reload-on-epoch. The first frame already shows the scope's existing + // snapshot (stale-while-revalidate); the reload itself (ticket, unmount + // isolation, target invalidation) lives in the scope, so a load in flight when + // this view unmounts still lands and is visible on return. + useEffect(() => { + void reload(persistedUsage.range); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [persistedUsage.range, targetKey]); + + const normalizedModelFilter = usageDraft.modelFilter.trim().toLowerCase(); + const hasRequestFilters = usageDraft.status !== 'all' || normalizedModelFilter.length > 0; + const showRequestDetails = usageDraft.activeTab === 'requests' && usageDraft.showDetails; + const filteredLogs = useMemo(() => { + const logs = stats?.logs ?? []; + return logs + .filter((log) => usageDraft.status === 'all' || log.status === usageDraft.status) + .filter((log) => + normalizedModelFilter.length === 0 || + log.model.toLowerCase().includes(normalizedModelFilter) || + log.provider.toLowerCase().includes(normalizedModelFilter) || + (log.toolName ?? '').toLowerCase().includes(normalizedModelFilter) + ); + }, [stats, usageDraft.status, normalizedModelFilter]); + + const tabCounts: Record = { + requests: stats?.logs.length ?? 0, + providers: stats?.byProvider.length ?? 0, + models: stats?.byModel.length ?? 0, + tools: stats?.byTool.length ?? 0, + pricing: stats?.pricing.length ?? 0, + }; + + function updateUsage(patch: Partial): Promise { + return update(patch); + } + + async function setRange(range: UsageRange) { + // Persist only: the surface refetches when the persisted range lands. + await updateUsage({ range }); + } + + async function refresh() { + if (!usageRefreshGuard.begin('refresh')) return; + setRefreshing(true); + try { + await reload(usageDraftRef.current.range); + } finally { + usageRefreshGuard.finish(); + if (usagePageMountedRef.current) setRefreshing(false); + } + } + + function clearRequestFilters() { + void updateUsage({ status: 'all', modelFilter: '' }); + } + + const usageIncomplete = + stats != null && (hasUnavailableUsage(stats.provenance) || stats.logsTruncated === true); + const totalCostDisplay = stats + ? (() => { + const cost = estimatedUsageCost(stats.provenance, stats.summary.totalCostUsd); + if (cost !== undefined) return `$${cost.toFixed(2)}`; + return stats.summary.totalRequests === 0 ? '$0.00' : copy.costUnavailable; + })() + : '—'; + + return ( + <> + {usageIncomplete ? ( + + ) : null} +
+
+ void setRange(value as UsageRange)} + > + {(['24h', '7d', '30d', 'all'] as const).map((value, index) => ( + + ))} + +
+ +
+ + + + +
+
+ +
+
+ void updateUsage({ activeTab: activeTab as UsageActiveTab })} + hasDivider + aria-label={copy.viewAria} + > + {tabCounts.requests}} /> + {tabCounts.providers}} /> + {tabCounts.models}} /> + {tabCounts.tools}} /> + {tabCounts.pricing}} /> + +
+ + {usageDraft.activeTab === 'requests' ? ( +
+ void updateUsage({ showDetails: true })} + onModelFilterChange={(modelFilter) => void updateUsage({ modelFilter })} + onStatusChange={(status) => void updateUsage({ status })} + onToggleDetails={(showDetails) => void updateUsage({ showDetails })} + onClearFilters={clearRequestFilters} + /> +
+ ) : null} + + {usageDraft.activeTab === 'providers' ? ( +
+ +
+ ) : null} + + {usageDraft.activeTab === 'models' ? ( +
+ +
+ ) : null} + + {usageDraft.activeTab === 'tools' ? ( +
+ +
+ ) : null} + + {usageDraft.activeTab === 'pricing' ? ( +
+ +
+ ) : null} +
+ + ); +} + +// ── Per-tab panels ───────────────────────────────────────────────────────── + +function UsageRequestsPanel(props: { + logs: UsageStats['logs']; + showDetails: boolean; + modelFilter: string; + status: UsageSettings['status']; + recordCount: number; + hasRequestFilters: boolean; + requestEmpty: string; + copy: UsageSettingsCopy; + locale: ReturnType; + onOpenSession?(sessionId: string): void; + onEnableDetails(): void; + onModelFilterChange(value: string): void; + onStatusChange(status: UsageSettings['status']): void; + onToggleDetails(showDetails: boolean): void; + onClearFilters(): void; +}) { + if (!props.showDetails) { + return ( + } /> + ); + } + return ( + <> +
+
+ props.onModelFilterChange(value)} + placeholder={props.copy.filterPlaceholder} + label={props.copy.filterAria} + isLabelHidden + width="100%" + /> +
+ props.onStatusChange(value as UsageSettings['status'])} + /> +
+ {props.copy.details} + +
+ {props.copy.recordCount(props.recordCount)} +
+ [ + new Date(row.ts).toLocaleString(uiLocaleToIntlLocale(props.locale)), + usageRequestKindLabel(row.kind, props.copy), + usageRequestTarget(row), + usageRequestSessionCell(row, props.copy, props.onOpenSession), + row.inputTokens + row.outputTokens, + row.kind === 'model' && row.costUsd !== undefined ? `$${row.costUsd.toFixed(2)}` : '-', + row.latencyMs !== undefined ? `${row.latencyMs}ms` : '-', + usageRequestStatusLabel(row.status, props.copy), + ])} + empty={{ + Icon: props.hasRequestFilters ? Search : Activity, + title: props.requestEmpty, + body: props.hasRequestFilters ? props.copy.filteredEmptyHelp : undefined, + action: props.hasRequestFilters ? ( +