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