You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/09/03 05:02:20 UTC
(maka) branch main updated: refactor(desktop): move Settings → Usage into a renderer feature slice (#4440)
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/main by this push:
new e1e108a792 refactor(desktop): move Settings → Usage into a renderer feature slice (#4440)
e1e108a792 is described below
commit e1e108a7922e48cc4ee60e4f93cac949d5737958
Author: Xiao Liu <li...@qq.com>
AuthorDate: Thu Sep 3 13:02:08 2026 +0800
refactor(desktop): move Settings → Usage into a renderer feature slice (#4440)
The renderer-architecture ratchet froze the legacy AppShell closure: no new file may enter it and no legacy file's dependency count may grow. Settings → Usage lives inside that closure, so net-new Usage work (most immediately the editable pricing tab of #2015 / #4164, which has to live in the Usage tabs) had nowhere to go.
Usage moves to `src/renderer/features/usage/`, following the #3439 reference boundary. `ports.ts` narrows the port to `UsageSettings` / `UsageStats` instead of the whole `AppSettings`. `services-context.tsx` owns the state: one tagged `{ range, value }` snapshot, the reload ticket, unmount isolation, Host-generation invalidation, the failure toast, and an imperative `fenceTarget()`. `ui/usage-settings-view.tsx` is a disposable view that reads the snapshot and reloads on `[range, targe [...]
Behavior is unchanged. The DOM was compared tag by tag against the old page; the only differences are the two wrappers that moved into the shim and a type narrowing. No CSS, IPC or copy changed.
One deviation from the composition-feature pattern, forced by the ratchet: there is no `platform/desktop` adapter and no composition wiring, because a frozen closure file cannot import the feature, and usage stats are scoped to the settings-selected Runtime Host, which the app-global composition root does not know about. `settings-surface` assembles the services and mounts the feature through a thin shim. When #4425's composition step lands, only that seam moves; the scope stays featu [...]
Two follow-ups are recorded in the feature README rather than done here: a `SettingsSurface` integration test covering the three lifecycle obligations (the current tests reconstruct the mount shape by hand and never load `settings-surface`), and de-duplicating the feature-local `action-guard` and `optimistic-settings-draft` copies, which are about 212 lines and the main source of the net addition.
Refs #4425, #4582, #4088
Generated-by: Claude Code
---
apps/desktop/renderer-architecture.json | 35 +-
.../src/main/__tests__/usage-settings-view.test.ts | 549 ++++++++++++++++++
apps/desktop/src/renderer/features/usage/README.md | 103 ++++
.../features/usage/controller/action-guard.ts | 66 +++
.../usage/controller/optimistic-settings-draft.ts | 182 ++++++
apps/desktop/src/renderer/features/usage/index.ts | 25 +
apps/desktop/src/renderer/features/usage/ports.ts | 42 ++
.../renderer/features/usage/services-context.tsx | 180 ++++++
.../usage/ui/metric-card.tsx} | 15 +-
.../usage/ui/usage-settings-view.tsx} | 280 +++------
.../features/usage/ui/usage-stats-table.tsx | 134 +++++
.../src/renderer/settings/settings-surface.tsx | 124 ++--
.../src/renderer/settings/usage-settings-page.tsx | 627 ++-------------------
docs/astryx-surface-file-inventory.md | 9 +-
docs/astryx-surface-file-inventory.paths | 5 +-
15 files changed, 1447 insertions(+), 929 deletions(-)
diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json
index 57999f4148..fbbc2ae3d0 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,30 +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": 3,
"./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
+ "@maka/ui": 1
}
},
"src/renderer/settings/use-action-guard.ts": {
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<T> {
+ promise: Promise<T>;
+ resolve(value: T): void;
+ reject(error: unknown): void;
+}
+function deferred<T>(): Deferred<T> {
+ let resolve!: (value: T) => void;
+ let reject!: (error: unknown) => void;
+ const promise = new Promise<T>((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('<div id="root"></div>');
+ 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<HTMLElement>('#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<UsageRange, Deferred<UsageStats | null>>();
+ const services: UsageServices = {
+ loadUsageStats: (range) => {
+ const d = deferred<UsageStats | null>();
+ 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<UsageRange, Deferred<UsageStats | null>>();
+ const services: UsageServices = {
+ loadUsageStats: (range) => {
+ const d = deferred<UsageStats | null>();
+ 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<string, Deferred<UsageStats | null>>();
+ let generation = 1;
+ const services: UsageServices = {
+ loadUsageStats: (range) => {
+ const d = deferred<UsageStats | null>();
+ 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<UsageRange, Deferred<UsageStats | null>>();
+ const services: UsageServices = {
+ loadUsageStats: (range) => {
+ const d = deferred<UsageStats | null>();
+ 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<string, Deferred<UsageStats | null>>();
+ let generation = 1;
+ const services: UsageServices = {
+ loadUsageStats: (range) => {
+ const d = deferred<UsageStats | null>();
+ 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<UsageRange, Deferred<UsageStats | null>>();
+ const services: UsageServices = {
+ loadUsageStats: (range) => {
+ const d = deferred<UsageStats | null>();
+ 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<UsageRange, Deferred<UsageStats | null>>();
+ const services: UsageServices = {
+ loadUsageStats: (range) => {
+ const d = deferred<UsageStats | null>();
+ loads.set(range, d);
+ return d.promise;
+ },
+ updateUsageSettings: async (patch) => mergeSettings(base, { usage: patch }).usage,
+ };
+ const scopeRef = createRef<UsageScopeHandle>();
+ 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..48c7e7f5b7
--- /dev/null
+++ b/apps/desktop/src/renderer/features/usage/README.md
@@ -0,0 +1,103 @@
+<!--
+ 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.
+-->
+
+# 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 a `SettingsSurface` integration test for the mount seam this PR moves.
+ `usage-settings-view.test.ts` mounts `UsageFeatureScope` + `UsageSettingsView`
+ directly and drives `fenceTarget()` / `targetKey` by hand; it does not load
+ `settings-surface.tsx`, so the surface's fence call sites
+ (`commitSelectedRuntimeHostProfile`, the generation-change handler) and the
+ `usageTargetKey` derivation are not exercised end-to-end. Those three lifecycle
+ obligations are exactly what a stale head had regressed with every test green, so
+ a surface-level test guarding them is the real coverage; it is deferred to keep
+ this extraction PR contained.
+- 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<Action> {
+ /** 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<Action>(): OneShotActionGuard<Action> {
+ 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<Action>(): OneShotActionGuard<Action> {
+ const guardRef = useRef<OneShotActionGuard<Action> | null>(null);
+ if (guardRef.current === null) {
+ guardRef.current = createOneShotActionGuard<Action>();
+ }
+ 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<T> {
+ readonly draftRef: { current: T };
+ activate(): void;
+ syncPersisted(persisted: T): void;
+ edit(patch: Partial<T>): void;
+ update(patch: Partial<T>): Promise<boolean>;
+ dispose(): void;
+}
+
+interface OptimisticDraftControllerDeps<T> {
+ initial: T;
+ onUpdate(patch: Partial<T>): Promise<T>;
+ onDraftChange(draft: T): void;
+ onReconcile?(draft: T): void;
+ onError?(error: unknown): void;
+ onSavingChange?(saving: boolean): void;
+ isMounted(): boolean;
+}
+
+function createOptimisticDraftController<T>(
+ deps: OptimisticDraftControllerDeps<T>,
+): OptimisticDraftController<T> {
+ 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<T>): void {
+ if (disposed) return;
+ commit({ ...draftRef.current, ...patch } as T);
+ }
+ async function update(patch: Partial<T>): Promise<boolean> {
+ 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<T> {
+ draft: T;
+ draftRef: { current: T };
+ mountedRef: RefObject<boolean>;
+ saving: boolean;
+ edit(patch: Partial<T>): void;
+ update(patch: Partial<T>): Promise<boolean>;
+}
+
+export function useOptimisticSettingsDraft<T>(
+ persisted: T,
+ onUpdate: (patch: Partial<T>) => Promise<T>,
+ options?: { onError?(error: unknown): void; onReconcile?(persisted: T): void },
+): OptimisticSettingsDraft<T> {
+ const mountedRef = useMountedRef();
+ const [draft, setDraft] = useState<T>(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<OptimisticDraftController<T> | null>(null);
+ if (controllerRef.current === null) {
+ controllerRef.current = createOptimisticDraftController<T>({
+ 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<UsageStats | null>;
+ /**
+ * 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<UsageSettings>): Promise<UsageSettings>;
+}
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<void>;
+}
+
+const UsageScopeContext = createContext<UsageScopeValue | null>(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<UsageSnapshot | null>(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<void> => {
+ 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<UsageScopeValue>(
+ () => ({ services, snapshot, targetKey, reload }),
+ [services, snapshot, targetKey, reload],
+ );
+
+ return <UsageScopeContext.Provider value={value}>{props.children}</UsageScopeContext.Provider>;
+});
+
+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<void>;
+} {
+ 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/settings/usage-settings-page.tsx b/apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx
similarity index 67%
copy from apps/desktop/src/renderer/settings/usage-settings-page.tsx
copy to apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx
index 87c9860f2f..61df3dcdd8 100644
--- a/apps/desktop/src/renderer/settings/usage-settings-page.tsx
+++ b/apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx
@@ -17,77 +17,82 @@
* under the License.
*/
-import { useMemo, useState, type ReactNode } from 'react';
+import { useEffect, useMemo, useState, type ReactNode } from 'react';
import {
- Card,
- EmptyState,
SegmentedControl,
SegmentedControlItem,
Tab,
TabList,
- Table,
Tooltip,
- type TableColumn,
- type TablePlugin,
- pixel,
- proportional,
} from '@astryxdesign/core';
import { uiLocaleToIntlLocale } from '@maka/core/ui-locale';
-import { parseDesktopSessionKey } from '../../shared/runtime-host-identity.js';
-import {
- type AppSettings,
- type UpdateAppSettingsResult,
- type UsageRange,
- type UsageStats,
-} from '@maka/core/settings';
+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 { 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';
-import { MetricCard } from './settings-metric-card';
-import { settingsActionErrorMessage } from './settings-error-copy';
-import { SettingsPage } from './settings-section';
-import { useActionGuard } from './use-action-guard';
-import { useOptimisticSettingsDraft } from './use-optimistic-settings-draft';
-
-type UsageActiveTab = AppSettings['usage']['activeTab'];
-
-export function UsageSettingsPage(props: {
- settings: AppSettings;
- stats: UsageStats | null;
- onUpdate(patch: Parameters<typeof window.maka.settings.update>[0]): Promise<UpdateAppSettingsResult>;
- onReload(range?: UsageRange): Promise<void>;
+} 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 persistedUsage = props.settings.usage;
+ 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 stats = props.stats;
- const toast = useToast();
const {
draft: usageDraft,
draftRef: usageDraftRef,
mountedRef: usagePageMountedRef,
update,
- } = useOptimisticSettingsDraft<AppSettings['usage']>(
+ } = useOptimisticSettingsDraft<UsageSettings>(
persistedUsage,
- (patch) => props.onUpdate({ usage: patch }).then((result) => result.settings.usage),
- { onError: (error) => toast.error(copy.saveFailed, settingsActionErrorMessage(error, locale)) },
+ (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;
@@ -111,27 +116,23 @@ export function UsageSettingsPage(props: {
pricing: stats?.pricing.length ?? 0,
};
- async function setRange(range: UsageRange) {
- // Persist only: the surface refetches when the persisted range lands
- // (the same trigger that reloads a Settings window restored directly
- // onto this page), so a second fetch here would just race it.
- await updateUsage({ range });
+ function updateUsage(patch: Partial<UsageSettings>): Promise<boolean> {
+ return update(patch);
}
- function updateUsage(patch: Partial<AppSettings['usage']>): Promise<boolean> {
- 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 props.onReload(usageDraftRef.current.range);
+ await reload(usageDraftRef.current.range);
} finally {
usageRefreshGuard.finish();
- if (usagePageMountedRef.current) {
- setRefreshing(false);
- }
+ if (usagePageMountedRef.current) setRefreshing(false);
}
}
@@ -139,24 +140,18 @@ export function UsageSettingsPage(props: {
void updateUsage({ status: 'all', modelFilter: '' });
}
- // `stats == null` means "not loaded for this Host/range yet", which is not the
- // same as a real zero — render an em dash so the cards do not fabricate 0s
- // (mirrors the '-' the activity cost cell already uses for unknown values).
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)}`;
- // No priced/legacy basis to trust: a genuinely empty range is $0.00, but
- // a range that had spend we could not qualify reads as unavailable
- // rather than a misleading $0.00.
return stats.summary.totalRequests === 0 ? '$0.00' : copy.costUnavailable;
})()
: '—';
return (
- <SettingsPage className="settingsUsagePage">
+ <>
{usageIncomplete ? (
<Banner
status="warning"
@@ -176,14 +171,6 @@ export function UsageSettingsPage(props: {
<SegmentedControlItem key={value} value={value} label={copy.ranges[index]} />
))}
</SegmentedControl>
- {/* Detail audit: 刷新 was a primary --action chip glued to the
- segmented — two control styles fighting in one row for a
- low-frequency utility. Same quiet icon form as the automations
- page refresh (one action, one shape everywhere); pinned to the
- row's trailing edge so the time cluster reads as a single
- left-aligned group. */}
- {/* Button rather than IconButton: busy buttons take Astryx isLoading
- (DESIGN.md §10), which IconButton does not expose. */}
<Button
variant="ghost"
size="sm"
@@ -223,22 +210,21 @@ export function UsageSettingsPage(props: {
{usageDraft.activeTab === 'requests' ? (
<div className="settingsUsageTabPanel">
<UsageRequestsPanel
- stats={stats}
- logs={showRequestDetails ? filteredLogs : []}
- showDetails={usageDraft.showDetails}
- modelFilter={usageDraft.modelFilter}
- status={usageDraft.status}
- recordCount={filteredLogs.length}
- hasRequestFilters={hasRequestFilters}
- requestEmpty={hasRequestFilters ? copy.filteredEmpty : copy.requestEmpty}
- copy={copy}
- locale={locale}
- onOpenSession={props.onOpenSession}
- onEnableDetails={() => void updateUsage({ showDetails: true })}
- onModelFilterChange={(modelFilter) => void updateUsage({ modelFilter })}
- onStatusChange={(status) => void updateUsage({ status })}
- onToggleDetails={(showDetails) => void updateUsage({ showDetails })}
- onClearFilters={clearRequestFilters}
+ logs={showRequestDetails ? filteredLogs : []}
+ showDetails={usageDraft.showDetails}
+ modelFilter={usageDraft.modelFilter}
+ status={usageDraft.status}
+ recordCount={filteredLogs.length}
+ hasRequestFilters={hasRequestFilters}
+ requestEmpty={hasRequestFilters ? copy.filteredEmpty : copy.requestEmpty}
+ copy={copy}
+ locale={locale}
+ onOpenSession={props.onOpenSession}
+ onEnableDetails={() => void updateUsage({ showDetails: true })}
+ onModelFilterChange={(modelFilter) => void updateUsage({ modelFilter })}
+ onStatusChange={(status) => void updateUsage({ status })}
+ onToggleDetails={(showDetails) => void updateUsage({ showDetails })}
+ onClearFilters={clearRequestFilters}
/>
</div>
) : null}
@@ -267,22 +253,17 @@ export function UsageSettingsPage(props: {
</div>
) : null}
</div>
- </SettingsPage>
+ </>
);
}
// ── Per-tab panels ─────────────────────────────────────────────────────────
-// Each tab owns its own component so the panel structure (filters, tables,
-// empty states) reads top-to-bottom instead of hiding inside one switch.
-// They all funnel their rows through the shared UsageStatsTable so every tab
-// inherits the same hairline / column-rhythm / tabular-nums recipe.
function UsageRequestsPanel(props: {
- stats: UsageStats | null;
logs: UsageStats['logs'];
showDetails: boolean;
modelFilter: string;
- status: AppSettings['usage']['status'];
+ status: UsageSettings['status'];
recordCount: number;
hasRequestFilters: boolean;
requestEmpty: string;
@@ -291,7 +272,7 @@ function UsageRequestsPanel(props: {
onOpenSession?(sessionId: string): void;
onEnableDetails(): void;
onModelFilterChange(value: string): void;
- onStatusChange(status: AppSettings['usage']['status']): void;
+ onStatusChange(status: UsageSettings['status']): void;
onToggleDetails(showDetails: boolean): void;
onClearFilters(): void;
}) {
@@ -327,7 +308,7 @@ function UsageRequestsPanel(props: {
{ value: 'aborted', label: props.copy.statuses[3] },
]}
width={320}
- onChange={(value) => props.onStatusChange(value as AppSettings['usage']['status'])}
+ onChange={(value) => props.onStatusChange(value as UsageSettings['status'])}
/>
<div className="settingsUsageDetailToggle">
<span>{props.copy.details}</span>
@@ -375,8 +356,6 @@ function UsageRequestsPanel(props: {
empty={{
Icon: props.hasRequestFilters ? Search : Activity,
title: props.requestEmpty,
- // Filter empty (DESIGN.md §10): a filter no-match always carries the
- // clear action — the user is in a state they caused and must exit it.
body: props.hasRequestFilters ? props.copy.filteredEmptyHelp : undefined,
action: props.hasRequestFilters ? (
<Button
@@ -476,8 +455,6 @@ function usageRequestTarget(row: UsageStats['logs'][number]) {
}
function usageRequestSessionCell(row: UsageStats['logs'][number], copy: UsageSettingsCopy, onOpenSession?: (sessionId: string) => void) {
- // Canonical activity rows can lack a session (e.g. an aborted call before a
- // session was attached); show it as unknown rather than a blank link.
if (!row.sessionId) return copy.tables.unknown;
const sessionId = row.sessionId;
const label = usageSessionDisplayLabel(row, copy);
@@ -494,10 +471,6 @@ function usageRequestSessionCell(row: UsageStats['logs'][number], copy: UsageSet
);
}
-// The Task column names the session (conversation) each request belongs to.
-// The name is the real title; untitled sessions fall back to a short slice of
-// their *real* session id (parsed out of the desktop composite key) so rows
-// stay distinguishable instead of collapsing onto the shared host-id prefix.
function usageSessionDisplayLabel(row: UsageStats['logs'][number], copy: UsageSettingsCopy) {
const name = row.sessionName?.trim();
if (name) return name;
@@ -523,104 +496,3 @@ function usageRequestStatusLabel(status: UsageStats['logs'][number]['status'], c
case 'aborted': return copy.tables.aborted;
}
}
-
-// ── Usage table mapping ─────────────────────────────────────────────────────
-// Astryx Table owns table geometry, scrolling, dividers, density, and cell
-// semantics. This page only maps its product rows and empty-state copy into
-// that public API.
-
-interface UsageColumn {
- header: string;
- numeric?: boolean;
- grow?: boolean;
- width?: number;
-}
-
-type UsageTableRow = Record<string, unknown> & { id: number };
-
-function usageCellNeedsCustomRenderer(value: ReactNode) {
- return value !== null
- && value !== undefined
- && !['string', 'number', 'boolean', 'bigint'].includes(typeof value);
-}
-
-const usageTablePlugins = {
- cellSemantics: {
- transformBodyCell: (cell, column, _row, columnIndex) => ({
- ...cell,
- htmlProps: {
- ...cell.htmlProps,
- ...(columnIndex === 0 ? { role: 'rowheader' as const } : {}),
- ...(column.align === 'end'
- ? {
- className: [cell.htmlProps.className, 'settingsUsageNumericCell']
- .filter(Boolean)
- .join(' '),
- }
- : {}),
- },
- }),
- },
-} satisfies Record<string, TablePlugin<UsageTableRow>>;
-
-interface UsageEmpty {
- /** A lucide icon (same shape EmptyState accepts). */
- Icon: typeof Search;
- title: string;
- body?: string;
- /** Tier-3 single action (DESIGN.md §10) — e.g. a filter empty's clear button. */
- action?: ReactNode;
-}
-
-function UsageStatsTable(props: {
- ariaLabel: string;
- columns: UsageColumn[];
- rows: Array<Array<ReactNode>>;
- empty: UsageEmpty;
-}) {
- if (props.rows.length === 0) {
- return (
- <EmptyState
- icon={<props.empty.Icon />}
- title={props.empty.title}
- description={props.empty.body ?? undefined}
- actions={props.empty.action}
- className="settingsUsageEmpty"
- />
- );
- }
- const data: UsageTableRow[] = props.rows.map((cells, id) => ({
- id,
- ...Object.fromEntries(cells.map((cell, index) => [`cell-${index}`, cell])),
- }));
- const columns: Array<TableColumn<UsageTableRow>> = props.columns.map((column, index) => {
- const key = `cell-${index}`;
- const needsCustomRenderer = props.rows.some((row) => usageCellNeedsCustomRenderer(row[index]));
- return {
- key,
- header: column.header,
- align: column.numeric ? 'end' : 'start',
- width: column.width !== undefined
- ? pixel(column.width)
- : column.grow
- ? proportional(1)
- : pixel(column.numeric ? 88 : 120),
- ...(needsCustomRenderer ? { renderCell: (row) => row[key] as ReactNode } : {}),
- };
- });
-
- return (
- <Card className="settingsUsageTable" padding={3}>
- <Table
- aria-label={props.ariaLabel}
- data={data}
- columns={columns}
- idKey="id"
- density="compact"
- dividers="rows"
- textOverflow="truncate"
- plugins={usageTablePlugins}
- />
- </Card>
- );
-}
diff --git a/apps/desktop/src/renderer/features/usage/ui/usage-stats-table.tsx b/apps/desktop/src/renderer/features/usage/ui/usage-stats-table.tsx
new file mode 100644
index 0000000000..beb6a1830b
--- /dev/null
+++ b/apps/desktop/src/renderer/features/usage/ui/usage-stats-table.tsx
@@ -0,0 +1,134 @@
+/*
+ * 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 { ReactNode } from 'react';
+import {
+ Card,
+ EmptyState,
+ Table,
+ type TableColumn,
+ type TablePlugin,
+ pixel,
+ proportional,
+} from '@astryxdesign/core';
+import type { LucideIcon } from '@maka/ui/icons';
+
+// Feature-owned copy of the shared Usage table recipe (#4425). Astryx Table owns
+// geometry/scrolling/dividers/density/cell semantics; every Usage tab maps its
+// product rows and empty-state copy into this recipe. External-only deps so the
+// feature zone carries no legacy import.
+
+export interface UsageColumn {
+ header: string;
+ numeric?: boolean;
+ grow?: boolean;
+ width?: number;
+}
+
+type UsageTableRow = Record<string, unknown> & { id: number };
+
+function usageCellNeedsCustomRenderer(value: ReactNode) {
+ return (
+ value !== null &&
+ value !== undefined &&
+ !['string', 'number', 'boolean', 'bigint'].includes(typeof value)
+ );
+}
+
+const usageTablePlugins = {
+ cellSemantics: {
+ transformBodyCell: (cell, column, _row, columnIndex) => ({
+ ...cell,
+ htmlProps: {
+ ...cell.htmlProps,
+ ...(columnIndex === 0 ? { role: 'rowheader' as const } : {}),
+ ...(column.align === 'end'
+ ? {
+ className: [cell.htmlProps.className, 'settingsUsageNumericCell']
+ .filter(Boolean)
+ .join(' '),
+ }
+ : {}),
+ },
+ }),
+ },
+} satisfies Record<string, TablePlugin<UsageTableRow>>;
+
+export interface UsageEmpty {
+ /** A lucide icon (same shape EmptyState accepts). */
+ Icon: LucideIcon;
+ title: string;
+ body?: string;
+ /** Tier-3 single action (DESIGN.md §10) — e.g. a filter empty's clear button. */
+ action?: ReactNode;
+}
+
+export function UsageStatsTable(props: {
+ ariaLabel: string;
+ columns: UsageColumn[];
+ rows: Array<Array<ReactNode>>;
+ empty: UsageEmpty;
+}) {
+ if (props.rows.length === 0) {
+ return (
+ <EmptyState
+ icon={<props.empty.Icon />}
+ title={props.empty.title}
+ description={props.empty.body ?? undefined}
+ actions={props.empty.action}
+ className="settingsUsageEmpty"
+ />
+ );
+ }
+ const data: UsageTableRow[] = props.rows.map((cells, id) => ({
+ id,
+ ...Object.fromEntries(cells.map((cell, index) => [`cell-${index}`, cell])),
+ }));
+ const columns: Array<TableColumn<UsageTableRow>> = props.columns.map((column, index) => {
+ const key = `cell-${index}`;
+ const needsCustomRenderer = props.rows.some((row) => usageCellNeedsCustomRenderer(row[index]));
+ return {
+ key,
+ header: column.header,
+ align: column.numeric ? 'end' : 'start',
+ width:
+ column.width !== undefined
+ ? pixel(column.width)
+ : column.grow
+ ? proportional(1)
+ : pixel(column.numeric ? 88 : 120),
+ ...(needsCustomRenderer ? { renderCell: (row) => row[key] as ReactNode } : {}),
+ };
+ });
+
+ return (
+ <Card className="settingsUsageTable" padding={3}>
+ <Table
+ aria-label={props.ariaLabel}
+ data={data}
+ columns={columns}
+ idKey="id"
+ density="compact"
+ dividers="rows"
+ textOverflow="truncate"
+ plugins={usageTablePlugins}
+ />
+ </Card>
+ );
+}
diff --git a/apps/desktop/src/renderer/settings/settings-surface.tsx b/apps/desktop/src/renderer/settings/settings-surface.tsx
index d6bae697cd..2fd53045e2 100644
--- a/apps/desktop/src/renderer/settings/settings-surface.tsx
+++ b/apps/desktop/src/renderer/settings/settings-surface.tsx
@@ -48,7 +48,6 @@ import type {
ThemePreference,
UpdateAppSettingsResult,
UsageRange,
- UsageStats,
} from '@maka/core/settings';
import type {
IdentifiedLlmConnection,
@@ -91,7 +90,7 @@ import { SettingsPage } from './settings-section';
import { settingsActionErrorMessage } from './settings-error-copy';
import { ImportTasksSettingsPage } from './import-tasks-settings-page';
import { TasksSettingsPage, type ArchivedTasksBridge } from './tasks-settings-page';
-import { UsageSettingsPage } from './usage-settings-page';
+import { UsageScopeMount, UsageSettingsPage, type UsageScopeHandle } from './usage-settings-page';
import { WebSearchSettingsPage } from './web-search-settings-page';
import type { UiLocaleUpdateGate } from './ui-locale-update-gate';
import { getSettingsSharedCopy } from '../locales/settings-shared-copy.js';
@@ -321,19 +320,12 @@ function SettingsSurfaceContent(
const defaultRuntimeHostProfileIdRef = useRef(
initialRuntimeHostCatalog?.defaultProfileId,
);
- const [usageStats, setUsageStats] = useState<{
- hostKey: string;
- epoch: string | undefined;
- range: UsageRange;
- value: UsageStats;
- } | null>(null);
const [clientLoading, setClientLoading] = useState(initialClientSettings === undefined);
const settingsModalMountedRef = useMountedRef();
const clientSettingsTicketRef = useRef(0);
const [runtimeHostRequestAuthority] = useState(
() => createSettingsRequestAuthority(initialRuntimeHostKey),
);
- const usageReloadTicketRef = useRef(0);
const runtimeHostReloadTicketRef = useRef(0);
const runtimeHostCatalogHydratedRef = useRef(false);
const selectedProfileChangedByUserRef = useRef(
@@ -380,16 +372,35 @@ function SettingsSurfaceContent(
const selectedRuntimeHostKey = selectedRuntimeHost
? runtimeHostSettingsKey(selectedRuntimeHost)
: undefined;
- const selectedRuntimeHostKeyRef = useRef(selectedRuntimeHostKey);
- selectedRuntimeHostKeyRef.current = selectedRuntimeHostKey;
// A same-key Host can be replaced in place (hostId stable, epoch bumped) on
// reconnect. `runtimeHostSettingsKey` is epoch-free, so usage must key on the
// epoch too — otherwise a reconnect clears the page but never refetches.
const selectedRuntimeHostEpoch = selectedProfileId
? runtimeHostLifecycleByProfile.get(selectedProfileId)?.epoch
: undefined;
- const selectedRuntimeHostEpochRef = useRef(selectedRuntimeHostEpoch);
- selectedRuntimeHostEpochRef.current = selectedRuntimeHostEpoch;
+ // Usage feature scope wiring (issue #4425). The Host-scoped stats read and the
+ // settings-update reconciliation are bound here (the `window.maka` bridge path
+ // stays in this file). The scope is mounted above the loading/error gate below,
+ // so a loaded snapshot survives a Skeleton/Banner state or a section change; it
+ // takes `usageTargetKey` (host:epoch) as a prop and clears itself when the
+ // target changes, so a Host/generation change never remounts the rest of the
+ // Settings surface. `usageScopeRef.fenceTarget()` rejects an in-flight old-Host
+ // load synchronously at a Host change, before React re-renders the new target.
+ const usageScopeRef = useRef<UsageScopeHandle>(null);
+ const usageServices = {
+ loadUsageStats: (range: UsageRange) =>
+ selectedRuntimeHost
+ ? window.maka.settings.usageStats(range, selectedRuntimeHost)
+ : Promise.resolve(null),
+ updateUsageSettings: (patch: Partial<AppSettings['usage']>) =>
+ updateSettings({ usage: patch }).then((result) => result.settings.usage),
+ };
+ // `selectedRuntimeHostKey` is the one authority for the `profileId:hostId`
+ // shape (`runtimeHostSettingsKey`); usage keys on it plus the epoch so a
+ // same-key reconnect (epoch bump) still changes the target.
+ const usageTargetKey = selectedRuntimeHostKey
+ ? `${selectedRuntimeHostKey}:${selectedRuntimeHostEpoch ?? ''}`
+ : 'no-host';
function commitSelectedRuntimeHostProfile(
profileId: string,
snapshot = runtimeHosts,
@@ -399,14 +410,8 @@ function SettingsSurfaceContent(
const nextKey = nextHost ? runtimeHostSettingsKey(nextHost) : undefined;
// Reject old-Host reads and writes synchronously with the authority
// change, before React renders the newly selected profile.
- const targetChanged = runtimeHostRequestAuthority.selectTarget(
- nextKey,
- lifecycle?.epoch,
- );
- if (targetChanged) {
- usageReloadTicketRef.current += 1;
- setUsageStats(null);
- }
+ const targetChanged = runtimeHostRequestAuthority.selectTarget(nextKey, lifecycle?.epoch);
+ if (targetChanged) usageScopeRef.current?.fenceTarget();
selectedProfileIdRef.current = profileId;
setSelectedProfileId(profileId);
}
@@ -655,39 +660,6 @@ function SettingsSurfaceContent(
}
}
- async function reloadUsage(range: UsageRange = settings.usage.range) {
- const host = selectedRuntimeHost;
- if (!host) {
- usageReloadTicketRef.current += 1;
- setUsageStats(null);
- return;
- }
- const hostKey = runtimeHostSettingsKey(host);
- const epoch = selectedRuntimeHostEpochRef.current;
- const ticket = usageReloadTicketRef.current + 1;
- usageReloadTicketRef.current = ticket;
- try {
- const next = await window.maka.settings.usageStats(range, host);
- if (
- settingsModalMountedRef.current &&
- ticket === usageReloadTicketRef.current &&
- selectedRuntimeHostKeyRef.current === hostKey &&
- selectedRuntimeHostEpochRef.current === epoch
- ) {
- setUsageStats({ hostKey, epoch, range, value: next });
- }
- } catch (error) {
- if (
- settingsModalMountedRef.current &&
- ticket === usageReloadTicketRef.current &&
- selectedRuntimeHostKeyRef.current === hostKey &&
- selectedRuntimeHostEpochRef.current === epoch
- ) {
- toast.error(copy.usageLoadFailed, settingsActionErrorMessage(error, locale));
- }
- }
- }
-
async function reloadRuntimeHosts(): Promise<void> {
const ticket = ++runtimeHostReloadTicketRef.current;
setRuntimeHostCatalog((current) => beginSettingsResourceLoad(
@@ -753,11 +725,12 @@ function SettingsSurfaceContent(
// Fence synchronously, before the catalog refresh can resolve. The
// previous generation's snapshots stay visible but no Host-backed
// control may treat them as current write authority.
- usageReloadTicketRef.current += 1;
- setUsageStats(null);
setRuntimeHostCatalog(invalidateSettingsResourceGeneration);
setRuntimeHostSettings(invalidateSettingsResourceGeneration);
setRuntimeHostConnections(invalidateSettingsResourceGeneration);
+ // Usage is Host-owned: drop its snapshot and fence its in-flight load
+ // here too, so an old-generation load cannot land before the re-render.
+ usageScopeRef.current?.fenceTarget();
}
}
void reloadRuntimeHosts().catch(() => undefined);
@@ -819,14 +792,6 @@ function SettingsSurfaceContent(
};
}, [connectionsBridge, selectedRuntimeHost]);
- useEffect(() => {
- // Usage records are Host-owned while the display preferences remain
- // client-owned. Refetch when the persisted range arrives, the selected Host
- // changes, or the selected Host is replaced in place (epoch bump) so labels
- // and numbers always describe one live Host generation.
- if (section === 'usage') void reloadUsage(settings.usage.range);
- }, [section, settings.usage.range, selectedRuntimeHostKey, selectedRuntimeHostEpoch]);
-
// PR-SETTINGS-HEADER-COPY-MAP-0 (U1): the page header derives its title
// and description from the section→copy map keyed by the active section,
// never from a `nav[0]` fallback. A section that is routable but missing
@@ -979,6 +944,13 @@ function SettingsSurfaceContent(
)}
content={(
<LayoutContent padding={6} isScrollable={false}>
+ <UsageScopeMount
+ ref={usageScopeRef}
+ targetKey={usageTargetKey}
+ services={usageServices}
+ loadErrorTitle={copy.usageLoadFailed}
+ describeError={(error) => settingsActionErrorMessage(error, locale)}
+ >
{loading ? (
<SettingsSkeleton />
) : requiresRuntimeHost &&
@@ -1031,14 +1003,6 @@ function SettingsSurfaceContent(
<SettingsPageBody
section={section}
settings={settings}
- usageStats={
- usageStats &&
- usageStats.hostKey === selectedRuntimeHostKey &&
- usageStats.epoch === selectedRuntimeHostEpoch &&
- usageStats.range === settings.usage.range
- ? usageStats.value
- : null
- }
connections={connections}
connectionsBridge={connectionsBridge}
apiKeyOnboardingBridge={apiKeyOnboardingBridge}
@@ -1058,7 +1022,6 @@ function SettingsSurfaceContent(
onReloadSettings={reloadRuntimeHostSettings}
onReloadClientSettings={reloadClientSettings}
onRetryRuntimeHost={retryRuntimeHostContent}
- onReloadUsage={reloadUsage}
onThemeChange={props.onThemeChange}
onThemePaletteChange={props.onThemePaletteChange}
onOpenDailyReview={props.onOpenDailyReview}
@@ -1081,6 +1044,7 @@ function SettingsSurfaceContent(
</RuntimeHostSettingsTarget>
</>
)}
+ </UsageScopeMount>
</LayoutContent>
)}
/>
@@ -1094,7 +1058,6 @@ function SettingsSurfaceContent(
function SettingsPageBody(props: {
section: SettingsSection;
settings: AppSettings;
- usageStats: UsageStats | null;
connections: ProjectedLlmConnection[];
connectionsBridge: RuntimeHostSettingsConnectionsBridge | undefined;
apiKeyOnboardingBridge:
@@ -1116,7 +1079,6 @@ function SettingsPageBody(props: {
onReloadSettings(): Promise<void>;
onReloadClientSettings(): Promise<void>;
onRetryRuntimeHost(): Promise<void>;
- onReloadUsage(range?: UsageRange): Promise<void>;
onThemeChange(pref: ThemePreference): void;
onThemePaletteChange(palette: ThemePalette): void;
onOpenDailyReview?(): void;
@@ -1164,15 +1126,9 @@ function SettingsPageBody(props: {
/>
);
case 'usage':
- return (
- <UsageSettingsPage
- settings={props.settings}
- stats={props.usageStats}
- onUpdate={props.onUpdateSettings}
- onReload={props.onReloadUsage}
- onOpenSession={props.onOpenSession}
- />
- );
+ // State lives in the persistent `UsageScopeMount` above the loading gate;
+ // this view is disposable and reads it from context.
+ return <UsageSettingsPage settings={props.settings.usage} onOpenSession={props.onOpenSession} />;
case 'bot-chat':
return (
<BotChatSettingsPage
diff --git a/apps/desktop/src/renderer/settings/usage-settings-page.tsx b/apps/desktop/src/renderer/settings/usage-settings-page.tsx
index 87c9860f2f..8f34805752 100644
--- a/apps/desktop/src/renderer/settings/usage-settings-page.tsx
+++ b/apps/desktop/src/renderer/settings/usage-settings-page.tsx
@@ -17,610 +17,49 @@
* under the License.
*/
-import { useMemo, useState, type ReactNode } from 'react';
-import {
- Card,
- EmptyState,
- SegmentedControl,
- SegmentedControlItem,
- Tab,
- TabList,
- Table,
- Tooltip,
- type TableColumn,
- type TablePlugin,
- pixel,
- proportional,
-} from '@astryxdesign/core';
-import { uiLocaleToIntlLocale } from '@maka/core/ui-locale';
-import { parseDesktopSessionKey } from '../../shared/runtime-host-identity.js';
-import {
- type AppSettings,
- type UpdateAppSettingsResult,
- type UsageRange,
- type 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';
-import { MetricCard } from './settings-metric-card';
+import { useUiLocale } from '@maka/ui';
+import type { UsageSettings } from '@maka/core/settings';
+import { UsageSettingsView } from '../features/usage';
import { settingsActionErrorMessage } from './settings-error-copy';
import { SettingsPage } from './settings-section';
-import { useActionGuard } from './use-action-guard';
-import { useOptimisticSettingsDraft } from './use-optimistic-settings-draft';
-type UsageActiveTab = AppSettings['usage']['activeTab'];
+/**
+ * Legacy delegation seam for the Usage feature (issue #4425), split into the two
+ * levels the settings surface mounts it at:
+ *
+ * - `UsageScopeMount` is the feature's persistent `UsageFeatureScope`, re-exported
+ * under the mount name. `settings-surface` mounts it *above* the loading/error
+ * gate, so the loaded snapshot survives a Skeleton/Banner state or a section
+ * change. The scope takes `targetKey` (`host:epoch`) as a prop and
+ * clears/invalidates internally when it changes, so a Host/generation change
+ * never remounts the surface. A plain re-export is enough — the scope's prop
+ * contract is already the mount contract, so no wrapper is needed.
+ * - `UsageSettingsPage` is the disposable view, mounted in the section content
+ * slot; it reads the snapshot from the scope above via context.
+ *
+ * The page binds only the locale-scoped error description (`describeError`) here;
+ * copy is imported directly from the validated locale catalog by the view. This
+ * shim touches no `window.maka` and imports no platform/feature-internal code, so
+ * it stays a thin closure shim. It is a transitional seam, not the composition
+ * ownership #4425 targets: the `UsageServices` are still assembled in
+ * `settings-surface`, not in `composition/desktop-feature-services.tsx` +
+ * a `platform/desktop` adapter.
+ */
+export { UsageFeatureScope as UsageScopeMount } from '../features/usage';
+export type { UsageScopeHandle } from '../features/usage';
export function UsageSettingsPage(props: {
- settings: AppSettings;
- stats: UsageStats | null;
- onUpdate(patch: Parameters<typeof window.maka.settings.update>[0]): Promise<UpdateAppSettingsResult>;
- onReload(range?: UsageRange): Promise<void>;
+ settings: UsageSettings;
onOpenSession?(sessionId: string): void;
}) {
const locale = useUiLocale();
- const copy = getUsageSettingsCopy(locale);
- const persistedUsage = props.settings.usage;
- const [refreshing, setRefreshing] = useState(false);
- const usageRefreshGuard = useActionGuard<'refresh'>();
- const stats = props.stats;
- const toast = useToast();
- const {
- draft: usageDraft,
- draftRef: usageDraftRef,
- mountedRef: usagePageMountedRef,
- update,
- } = useOptimisticSettingsDraft<AppSettings['usage']>(
- persistedUsage,
- (patch) => props.onUpdate({ usage: patch }).then((result) => result.settings.usage),
- { onError: (error) => toast.error(copy.saveFailed, settingsActionErrorMessage(error, locale)) },
- );
-
- 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<UsageActiveTab, number> = {
- 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,
- };
-
- async function setRange(range: UsageRange) {
- // Persist only: the surface refetches when the persisted range lands
- // (the same trigger that reloads a Settings window restored directly
- // onto this page), so a second fetch here would just race it.
- await updateUsage({ range });
- }
-
- function updateUsage(patch: Partial<AppSettings['usage']>): Promise<boolean> {
- return update(patch);
- }
-
- async function refresh() {
- if (!usageRefreshGuard.begin('refresh')) return;
- setRefreshing(true);
- try {
- await props.onReload(usageDraftRef.current.range);
- } finally {
- usageRefreshGuard.finish();
- if (usagePageMountedRef.current) {
- setRefreshing(false);
- }
- }
- }
-
- function clearRequestFilters() {
- void updateUsage({ status: 'all', modelFilter: '' });
- }
-
- // `stats == null` means "not loaded for this Host/range yet", which is not the
- // same as a real zero — render an em dash so the cards do not fabricate 0s
- // (mirrors the '-' the activity cost cell already uses for unknown values).
- 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)}`;
- // No priced/legacy basis to trust: a genuinely empty range is $0.00, but
- // a range that had spend we could not qualify reads as unavailable
- // rather than a misleading $0.00.
- return stats.summary.totalRequests === 0 ? '$0.00' : copy.costUnavailable;
- })()
- : '—';
-
return (
<SettingsPage className="settingsUsagePage">
- {usageIncomplete ? (
- <Banner
- status="warning"
- role="status"
- title={copy.incompleteTitle}
- description={copy.incompleteBody}
- />
- ) : null}
- <div className="settingsUsageOverview">
- <div className="settingsUsageToolbar" role="group" aria-label={copy.toolbarAria}>
- <SegmentedControl
- value={usageDraft.range}
- label={copy.rangeAria}
- onChange={(value) => void setRange(value as UsageRange)}
- >
- {(['24h', '7d', '30d', 'all'] as const).map((value, index) => (
- <SegmentedControlItem key={value} value={value} label={copy.ranges[index]} />
- ))}
- </SegmentedControl>
- {/* Detail audit: 刷新 was a primary --action chip glued to the
- segmented — two control styles fighting in one row for a
- low-frequency utility. Same quiet icon form as the automations
- page refresh (one action, one shape everywhere); pinned to the
- row's trailing edge so the time cluster reads as a single
- left-aligned group. */}
- {/* Button rather than IconButton: busy buttons take Astryx isLoading
- (DESIGN.md §10), which IconButton does not expose. */}
- <Button
- variant="ghost"
- size="sm"
- isIconOnly
- isLoading={refreshing}
- label={copy.refreshAria}
- tooltip={copy.refreshAria}
- onClick={() => void refresh()}
- icon={<RefreshCcw size={ICON_SIZE.control} aria-hidden="true" />}
- />
- </div>
-
- <div className="settingsUsageSummary" role="group" aria-label={copy.summaryAria}>
- <MetricCard title={copy.totalRequests} value={stats ? String(stats.summary.totalRequests) : '—'} />
- <MetricCard title={copy.totalCost} value={totalCostDisplay} detail={copy.costHelp} />
- <MetricCard title={copy.totalTokens} value={stats ? String(stats.summary.totalTokens) : '—'} detail={stats ? copy.tokenDetail(stats.summary.inputTokens, stats.summary.outputTokens) : undefined} />
- <MetricCard title={copy.cacheTokens} value={stats ? String(stats.summary.cacheTokens) : '—'} detail={stats ? copy.cacheDetail(stats.summary.cacheMiss, stats.summary.cacheRead, stats.summary.cacheCreation) : undefined} />
- </div>
- </div>
-
- <div className="settingsUsageBreakdown">
- <div className="settingsUsageTabsBar">
- <TabList
- value={usageDraft.activeTab}
- onChange={(activeTab) => void updateUsage({ activeTab: activeTab as UsageActiveTab })}
- hasDivider
- aria-label={copy.viewAria}
- >
- <Tab value="requests" label={copy.tabs[0]} endContent={<span>{tabCounts.requests}</span>} />
- <Tab value="providers" label={copy.tabs[1]} endContent={<span>{tabCounts.providers}</span>} />
- <Tab value="models" label={copy.tabs[2]} endContent={<span>{tabCounts.models}</span>} />
- <Tab value="tools" label={copy.tabs[3]} endContent={<span>{tabCounts.tools}</span>} />
- <Tab value="pricing" label={copy.tabs[4]} endContent={<span>{tabCounts.pricing}</span>} />
- </TabList>
- </div>
-
- {usageDraft.activeTab === 'requests' ? (
- <div className="settingsUsageTabPanel">
- <UsageRequestsPanel
- stats={stats}
- logs={showRequestDetails ? filteredLogs : []}
- showDetails={usageDraft.showDetails}
- modelFilter={usageDraft.modelFilter}
- status={usageDraft.status}
- recordCount={filteredLogs.length}
- hasRequestFilters={hasRequestFilters}
- requestEmpty={hasRequestFilters ? copy.filteredEmpty : copy.requestEmpty}
- copy={copy}
- locale={locale}
- onOpenSession={props.onOpenSession}
- onEnableDetails={() => void updateUsage({ showDetails: true })}
- onModelFilterChange={(modelFilter) => void updateUsage({ modelFilter })}
- onStatusChange={(status) => void updateUsage({ status })}
- onToggleDetails={(showDetails) => void updateUsage({ showDetails })}
- onClearFilters={clearRequestFilters}
- />
- </div>
- ) : null}
-
- {usageDraft.activeTab === 'providers' ? (
- <div className="settingsUsageTabPanel">
- <UsageProvidersPanel stats={stats} copy={copy} />
- </div>
- ) : null}
-
- {usageDraft.activeTab === 'models' ? (
- <div className="settingsUsageTabPanel">
- <UsageModelsPanel stats={stats} copy={copy} />
- </div>
- ) : null}
-
- {usageDraft.activeTab === 'tools' ? (
- <div className="settingsUsageTabPanel">
- <UsageToolsPanel stats={stats} copy={copy} />
- </div>
- ) : null}
-
- {usageDraft.activeTab === 'pricing' ? (
- <div className="settingsUsageTabPanel">
- <UsagePricingPanel stats={stats} copy={copy} />
- </div>
- ) : null}
- </div>
- </SettingsPage>
- );
-}
-
-// ── Per-tab panels ─────────────────────────────────────────────────────────
-// Each tab owns its own component so the panel structure (filters, tables,
-// empty states) reads top-to-bottom instead of hiding inside one switch.
-// They all funnel their rows through the shared UsageStatsTable so every tab
-// inherits the same hairline / column-rhythm / tabular-nums recipe.
-
-function UsageRequestsPanel(props: {
- stats: UsageStats | null;
- logs: UsageStats['logs'];
- showDetails: boolean;
- modelFilter: string;
- status: AppSettings['usage']['status'];
- recordCount: number;
- hasRequestFilters: boolean;
- requestEmpty: string;
- copy: UsageSettingsCopy;
- locale: ReturnType<typeof useUiLocale>;
- onOpenSession?(sessionId: string): void;
- onEnableDetails(): void;
- onModelFilterChange(value: string): void;
- onStatusChange(status: AppSettings['usage']['status']): void;
- onToggleDetails(showDetails: boolean): void;
- onClearFilters(): void;
-}) {
- if (!props.showDetails) {
- return (
- <Banner
- status="info"
- title={props.copy.summaryOnly}
- endContent={<Button variant="secondary" size="sm" onClick={props.onEnableDetails} label={props.copy.showDetails} />} />
- );
- }
- return (
- <>
- <div className="settingsUsageFilters" role="group" aria-label={props.copy.filtersAria}>
- <div className="settingsUsageModelFilter">
- <TextInput
- value={props.modelFilter}
- onChange={(value) => props.onModelFilterChange(value)}
- placeholder={props.copy.filterPlaceholder}
- label={props.copy.filterAria}
- isLabelHidden
- width="100%"
- />
- </div>
- <Selector
- value={props.status}
- label={props.copy.statusAria}
- isLabelHidden
- options={[
- { value: 'all', label: props.copy.statuses[0] },
- { value: 'success', label: props.copy.statuses[1] },
- { value: 'error', label: props.copy.statuses[2] },
- { value: 'aborted', label: props.copy.statuses[3] },
- ]}
- width={320}
- onChange={(value) => props.onStatusChange(value as AppSettings['usage']['status'])}
- />
- <div className="settingsUsageDetailToggle">
- <span>{props.copy.details}</span>
- <Switch
- label={props.copy.detailsAria}
- isLabelHidden
- value={props.showDetails}
- onChange={props.onToggleDetails}
- />
- </div>
- <small className="settingsUsageRecordCount">{props.copy.recordCount(props.recordCount)}</small>
- <Button
- className="settingsUsageClearFilter"
- variant="ghost"
- size="sm"
- isDisabled={!props.hasRequestFilters}
- aria-hidden={!props.hasRequestFilters ? 'true' : undefined}
- tabIndex={!props.hasRequestFilters ? -1 : undefined}
- onClick={props.hasRequestFilters ? props.onClearFilters : undefined}
- label={props.copy.clearFilters}
- />
- </div>
- <UsageStatsTable
- ariaLabel={props.copy.tables.requestsAria}
- columns={[
- { header: props.copy.tables.requestHeaders[0], width: 168 },
- { header: props.copy.tables.requestHeaders[1], width: 72 },
- { header: props.copy.tables.requestHeaders[2], grow: true },
- { header: props.copy.tables.requestHeaders[3], width: 168 },
- { header: props.copy.tables.requestHeaders[4], numeric: true },
- { header: props.copy.tables.requestHeaders[5], numeric: true },
- { header: props.copy.tables.requestHeaders[6], numeric: true },
- { header: props.copy.tables.requestHeaders[7], width: 72 },
- ]}
- rows={props.logs.map((row) => [
- 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,
- // Filter empty (DESIGN.md §10): a filter no-match always carries the
- // clear action — the user is in a state they caused and must exit it.
- body: props.hasRequestFilters ? props.copy.filteredEmptyHelp : undefined,
- action: props.hasRequestFilters ? (
- <Button
- variant="ghost"
- size="sm"
- label={props.copy.clearFilters}
- onClick={props.onClearFilters}
- />
- ) : undefined,
- }}
- />
- </>
- );
-}
-
-function UsageProvidersPanel(props: { stats: UsageStats | null; copy: UsageSettingsCopy }) {
- return (
- <UsageStatsTable
- ariaLabel={props.copy.tables.providersAria}
- columns={[
- { header: props.copy.tables.providerHeaders[0], grow: true },
- { header: props.copy.tables.providerHeaders[1], numeric: true },
- { header: props.copy.tables.providerHeaders[2], numeric: true },
- { header: props.copy.tables.providerHeaders[3], numeric: true },
- ]}
- rows={(props.stats?.byProvider ?? []).map((row) => [row.provider, row.requests, row.tokens, `$${row.costUsd.toFixed(2)}`])}
- empty={{ Icon: Database, title: props.copy.tables.providerEmptyTitle, body: props.copy.tables.providerEmptyBody }}
- />
- );
-}
-
-function UsageModelsPanel(props: { stats: UsageStats | null; copy: UsageSettingsCopy }) {
- return (
- <UsageStatsTable
- ariaLabel={props.copy.tables.modelsAria}
- columns={[
- { header: props.copy.tables.modelHeaders[0], grow: true },
- { header: props.copy.tables.modelHeaders[1], numeric: true },
- { header: props.copy.tables.modelHeaders[2], numeric: true },
- { header: props.copy.tables.modelHeaders[3], numeric: true },
- ]}
- rows={(props.stats?.byModel ?? []).map((row) => [row.model, row.requests, row.tokens, `$${row.costUsd.toFixed(2)}`])}
- empty={{ Icon: Cpu, title: props.copy.tables.modelEmptyTitle, body: props.copy.tables.modelEmptyBody }}
- />
- );
-}
-
-function UsageToolsPanel(props: { stats: UsageStats | null; copy: UsageSettingsCopy }) {
- return (
- <UsageStatsTable
- ariaLabel={props.copy.tables.toolsAria}
- columns={[
- { header: props.copy.tables.toolHeaders[0], grow: true },
- { header: props.copy.tables.toolHeaders[1], numeric: true },
- { header: props.copy.tables.toolHeaders[2], numeric: true },
- { header: props.copy.tables.toolHeaders[3], numeric: true },
- { header: props.copy.tables.toolHeaders[4], numeric: true },
- ]}
- rows={(props.stats?.byTool ?? []).map((row) => [row.tool, row.calls, row.success, row.errors, `${row.avgDurationMs}ms`])}
- empty={{ Icon: Activity, title: props.copy.tables.toolEmptyTitle, body: props.copy.tables.toolEmptyBody }}
- />
- );
-}
-
-function UsagePricingPanel(props: { stats: UsageStats | null; copy: UsageSettingsCopy }) {
- return (
- <UsageStatsTable
- ariaLabel={props.copy.tables.pricingAria}
- columns={[
- { header: props.copy.tables.pricingHeaders[0], grow: true },
- { header: props.copy.tables.pricingHeaders[1] },
- { header: props.copy.tables.pricingHeaders[2], numeric: true },
- { header: props.copy.tables.pricingHeaders[3], numeric: true },
- ]}
- rows={(props.stats?.pricing ?? []).map((row) => [row.provider, row.model, `$${row.inputPerMTokUsd}`, `$${row.outputPerMTokUsd}`])}
- empty={{ Icon: BarChart3, title: props.copy.tables.noPricing, body: props.copy.tables.pricingEmptyBody }}
- />
- );
-}
-
-// ── Request-log cell helpers ────────────────────────────────────────────────
-
-function usageRequestKindLabel(kind: UsageStats['logs'][number]['kind'], copy: UsageSettingsCopy) {
- switch (kind) {
- case 'model': return copy.tables.modelKind;
- case 'tool': return copy.tables.toolKind;
- }
-}
-
-function usageRequestTarget(row: UsageStats['logs'][number]) {
- const target = row.kind === 'tool' ? row.toolName || row.model || row.provider || '-' : row.model || row.provider || '-';
- return (
- <Tooltip content={target}>
- <span className="settingsUsageTargetCell" title={target}>{target}</span>
- </Tooltip>
- );
-}
-
-function usageRequestSessionCell(row: UsageStats['logs'][number], copy: UsageSettingsCopy, onOpenSession?: (sessionId: string) => void) {
- // Canonical activity rows can lack a session (e.g. an aborted call before a
- // session was attached); show it as unknown rather than a blank link.
- if (!row.sessionId) return copy.tables.unknown;
- const sessionId = row.sessionId;
- const label = usageSessionDisplayLabel(row, copy);
- if (!onOpenSession) return label;
- return (
- <Button
- className="settingsUsageSessionCell"
- variant="ghost"
- size="sm"
- onClick={() => onOpenSession(sessionId)}
- label={label}
- tooltip={copy.tables.openSession(label)}
- />
- );
-}
-
-// The Task column names the session (conversation) each request belongs to.
-// The name is the real title; untitled sessions fall back to a short slice of
-// their *real* session id (parsed out of the desktop composite key) so rows
-// stay distinguishable instead of collapsing onto the shared host-id prefix.
-function usageSessionDisplayLabel(row: UsageStats['logs'][number], copy: UsageSettingsCopy) {
- const name = row.sessionName?.trim();
- if (name) return name;
- return `${copy.tables.untitledSession} · ${shortRealSessionId(row.sessionId ?? '')}`;
-}
-
-function shortRealSessionId(sessionKey: string) {
- try {
- return shortUsageSessionId(parseDesktopSessionKey(sessionKey).sessionId);
- } catch {
- return shortUsageSessionId(sessionKey);
- }
-}
-
-function shortUsageSessionId(sessionId: string) {
- return sessionId.length > 8 ? sessionId.slice(0, 8) : sessionId;
-}
-
-function usageRequestStatusLabel(status: UsageStats['logs'][number]['status'], copy: UsageSettingsCopy) {
- switch (status) {
- case 'success': return copy.tables.success;
- case 'error': return copy.tables.error;
- case 'aborted': return copy.tables.aborted;
- }
-}
-
-// ── Usage table mapping ─────────────────────────────────────────────────────
-// Astryx Table owns table geometry, scrolling, dividers, density, and cell
-// semantics. This page only maps its product rows and empty-state copy into
-// that public API.
-
-interface UsageColumn {
- header: string;
- numeric?: boolean;
- grow?: boolean;
- width?: number;
-}
-
-type UsageTableRow = Record<string, unknown> & { id: number };
-
-function usageCellNeedsCustomRenderer(value: ReactNode) {
- return value !== null
- && value !== undefined
- && !['string', 'number', 'boolean', 'bigint'].includes(typeof value);
-}
-
-const usageTablePlugins = {
- cellSemantics: {
- transformBodyCell: (cell, column, _row, columnIndex) => ({
- ...cell,
- htmlProps: {
- ...cell.htmlProps,
- ...(columnIndex === 0 ? { role: 'rowheader' as const } : {}),
- ...(column.align === 'end'
- ? {
- className: [cell.htmlProps.className, 'settingsUsageNumericCell']
- .filter(Boolean)
- .join(' '),
- }
- : {}),
- },
- }),
- },
-} satisfies Record<string, TablePlugin<UsageTableRow>>;
-
-interface UsageEmpty {
- /** A lucide icon (same shape EmptyState accepts). */
- Icon: typeof Search;
- title: string;
- body?: string;
- /** Tier-3 single action (DESIGN.md §10) — e.g. a filter empty's clear button. */
- action?: ReactNode;
-}
-
-function UsageStatsTable(props: {
- ariaLabel: string;
- columns: UsageColumn[];
- rows: Array<Array<ReactNode>>;
- empty: UsageEmpty;
-}) {
- if (props.rows.length === 0) {
- return (
- <EmptyState
- icon={<props.empty.Icon />}
- title={props.empty.title}
- description={props.empty.body ?? undefined}
- actions={props.empty.action}
- className="settingsUsageEmpty"
+ <UsageSettingsView
+ settings={props.settings}
+ describeError={(error) => settingsActionErrorMessage(error, locale)}
+ onOpenSession={props.onOpenSession}
/>
- );
- }
- const data: UsageTableRow[] = props.rows.map((cells, id) => ({
- id,
- ...Object.fromEntries(cells.map((cell, index) => [`cell-${index}`, cell])),
- }));
- const columns: Array<TableColumn<UsageTableRow>> = props.columns.map((column, index) => {
- const key = `cell-${index}`;
- const needsCustomRenderer = props.rows.some((row) => usageCellNeedsCustomRenderer(row[index]));
- return {
- key,
- header: column.header,
- align: column.numeric ? 'end' : 'start',
- width: column.width !== undefined
- ? pixel(column.width)
- : column.grow
- ? proportional(1)
- : pixel(column.numeric ? 88 : 120),
- ...(needsCustomRenderer ? { renderCell: (row) => row[key] as ReactNode } : {}),
- };
- });
-
- return (
- <Card className="settingsUsageTable" padding={3}>
- <Table
- aria-label={props.ariaLabel}
- data={data}
- columns={columns}
- idKey="id"
- density="compact"
- dividers="rows"
- textOverflow="truncate"
- plugins={usageTablePlugins}
- />
- </Card>
+ </SettingsPage>
);
}
diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md
index 9059574389..3a6020b081 100644
--- a/docs/astryx-surface-file-inventory.md
+++ b/docs/astryx-surface-file-inventory.md
@@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.5.2` (194 component exports).
Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding.
-**Totals:** 240 files — blocker 0, reimplementation 0, polish 1, aligned 239.
+**Totals:** 243 files — blocker 0, reimplementation 0, polish 1, aligned 242.
## Exclusions (explicit)
@@ -66,6 +66,10 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
| `apps/desktop/src/renderer/features/session-settings/services-context.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/features/task-entry/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/features/task-entry/ui/task-entry-host.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
+| `apps/desktop/src/renderer/features/usage/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
+| `apps/desktop/src/renderer/features/usage/ui/metric-card.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
+| `apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx` | other | Banner, Button, SegmentedControl, SegmentedControlItem, Selector, Switch, Tab, TabList, TextInput, Tooltip | aligned — uses Astryx (Banner, Button, SegmentedControl, SegmentedControlItem, Selector, Switch, Tab, TabList) | aligned |
+| `apps/desktop/src/renderer/features/usage/ui/usage-stats-table.tsx` | other | Card, EmptyState, Table | aligned — uses Astryx (Card, EmptyState, Table) | aligned |
| `apps/desktop/src/renderer/features/workbar/services-context.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx` | shell-chrome-or-panel | Badge, Banner, Button, EmptyState, MoreMenu | aligned — uses Astryx (Badge, Banner, Button, EmptyState, MoreMenu) | aligned |
| `apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview-registry-shell.tsx` | shell-chrome-or-panel | Banner, Button, Spinner | aligned — uses Astryx (Banner, Button, Spinner) | aligned |
@@ -129,7 +133,6 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
| `apps/desktop/src/renderer/settings/runtime-host-settings-target.tsx` | settings-module | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/settings/runtime-host-ssh-terminal-dialog.tsx` | settings-module | Banner, Button, Dialog, DialogHeader, Layout, LayoutContent, LayoutFooter | aligned — uses Astryx (Banner, Button, Dialog, DialogHeader, Layout, LayoutContent, LayoutFooter) | aligned |
| `apps/desktop/src/renderer/settings/settings-expandable-row.tsx` | settings-module | Button, HStack, Text | aligned — uses Astryx (Button, HStack, Text) | aligned |
-| `apps/desktop/src/renderer/settings/settings-metric-card.tsx` | settings-module | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/settings/settings-modal.tsx` | settings-page | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/settings/settings-route-header.tsx` | settings-module | HStack, Heading, IconButton, Text, Toolbar, VStack | aligned — uses Astryx (HStack, Heading, IconButton, Text, Toolbar, VStack) | aligned |
| `apps/desktop/src/renderer/settings/settings-rows.tsx` | settings-module | none | aligned — no raw controls; no Astryx JSX usage | aligned |
@@ -139,7 +142,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
| `apps/desktop/src/renderer/settings/settings-surface.tsx` | settings-module | Badge, Banner, Button, IconButton, Layout, LayoutContent, LayoutHeader, LayoutPanel, Selector, SideNav, SideNavItem, SideNavSection | aligned — uses Astryx (Badge, Banner, Button, IconButton, Layout, LayoutContent, LayoutHeader, LayoutPanel) | aligned |
| `apps/desktop/src/renderer/settings/subagent-settings-page.tsx` | settings-page | Badge, Banner, Button, EmptyState, HStack, IconButton, Selector, Switch, TextArea, TextInput, VStack | aligned — uses Astryx (Badge, Banner, Button, EmptyState, HStack, IconButton, Selector, Switch) | aligned |
| `apps/desktop/src/renderer/settings/tasks-settings-page.tsx` | settings-page | Button, EmptyState, HStack, List, ListItem, MoreMenu, StackItem, TextInput | aligned — uses Astryx (Button, EmptyState, HStack, List, ListItem, MoreMenu, StackItem, TextInput) | aligned |
-| `apps/desktop/src/renderer/settings/usage-settings-page.tsx` | settings-page | Banner, Button, Card, EmptyState, SegmentedControl, SegmentedControlItem, Selector, Switch, Tab, TabList, Table, TextInput, Tooltip | aligned — uses Astryx (Banner, Button, Card, EmptyState, SegmentedControl, SegmentedControlItem, Selector, Switch) | aligned |
+| `apps/desktop/src/renderer/settings/usage-settings-page.tsx` | settings-page | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/settings/web-search-settings-page.tsx` | settings-page | Banner, Button, EmptyState, Link, Selector, StatusDot, Switch, TextInput | aligned — uses Astryx (Banner, Button, EmptyState, Link, Selector, StatusDot, Switch, TextInput) | aligned |
| `apps/desktop/src/renderer/styles.css` | styles | n/a (css) | aligned — no off-rhythm control heights flagged | aligned |
| `apps/desktop/src/renderer/styles/agent-graph.css` | styles | n/a (css) | aligned — no off-rhythm control heights flagged | aligned |
diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths
index dbf0e86e67..e5dcda131d 100644
--- a/docs/astryx-surface-file-inventory.paths
+++ b/docs/astryx-surface-file-inventory.paths
@@ -37,6 +37,10 @@ apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-prov
apps/desktop/src/renderer/features/session-settings/services-context.tsx
apps/desktop/src/renderer/features/task-entry/services-context.tsx
apps/desktop/src/renderer/features/task-entry/ui/task-entry-host.tsx
+apps/desktop/src/renderer/features/usage/services-context.tsx
+apps/desktop/src/renderer/features/usage/ui/metric-card.tsx
+apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx
+apps/desktop/src/renderer/features/usage/ui/usage-stats-table.tsx
apps/desktop/src/renderer/features/workbar/services-context.tsx
apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx
apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview-registry-shell.tsx
@@ -100,7 +104,6 @@ apps/desktop/src/renderer/settings/runtime-host-project-directory-editor.tsx
apps/desktop/src/renderer/settings/runtime-host-settings-target.tsx
apps/desktop/src/renderer/settings/runtime-host-ssh-terminal-dialog.tsx
apps/desktop/src/renderer/settings/settings-expandable-row.tsx
-apps/desktop/src/renderer/settings/settings-metric-card.tsx
apps/desktop/src/renderer/settings/settings-modal.tsx
apps/desktop/src/renderer/settings/settings-route-header.tsx
apps/desktop/src/renderer/settings/settings-rows.tsx
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org