diff --git a/apps/desktop/src/main/__tests__/astryx-component-behavior.test.ts b/apps/desktop/src/main/__tests__/astryx-component-behavior.test.ts
index 0a04aed605..c5acb6d776 100644
--- a/apps/desktop/src/main/__tests__/astryx-component-behavior.test.ts
+++ b/apps/desktop/src/main/__tests__/astryx-component-behavior.test.ts
@@ -178,7 +178,7 @@ describe('Astryx component behavior', () => {
assert.match(renderFields({ ...channel, appId: 'existing-app' }), /aria-expanded="true"/);
});
- it('exposes the first usage cell in each body row as its row header', async () => {
+ it('composes Usage from the settings page kit and Astryx data surface', async () => {
const { LocaleProvider, ToastProvider, UsageSettingsPage } = await rendererComponents;
const settings = {
usage: {
@@ -219,6 +219,9 @@ describe('Astryx component behavior', () => {
ToastProvider,
);
+ assert.match(markup, /class="[^"]*settingsPageStack[^"]*settingsUsagePage/);
+ assert.match(markup, /class="astryx-card[^"]*settingsUsageTable/);
+ assert.match(markup, /class="astryx-table-scroll-wrapper/);
assert.equal(markup.match(/
]*role="rowheader"/g)?.length, 1);
});
});
diff --git a/apps/desktop/src/renderer/settings/usage-settings-page.tsx b/apps/desktop/src/renderer/settings/usage-settings-page.tsx
index 7a7b8fc993..48ffbce6d9 100644
--- a/apps/desktop/src/renderer/settings/usage-settings-page.tsx
+++ b/apps/desktop/src/renderer/settings/usage-settings-page.tsx
@@ -1,5 +1,6 @@
import { useMemo, useState, type ReactNode } from 'react';
import {
+ Card,
EmptyState,
SegmentedControl,
SegmentedControlItem,
@@ -35,6 +36,7 @@ import {
} 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';
@@ -115,44 +117,46 @@ export function UsageSettingsPage(props: {
}
return (
-
-
- void setRange(value as UsageRange)}
- >
- {(['24h', '7d', '30d', 'all'] as const).map((value, index) => (
-
- ))}
-
- {/* 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. */}
- void refresh()}
- icon={}
- />
-
+
+
+
+ void setRange(value as UsageRange)}
+ >
+ {(['24h', '7d', '30d', 'all'] as const).map((value, index) => (
+
+ ))}
+
+ {/* 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. */}
+ void refresh()}
+ icon={}
+ />
+
-
-
+
+
);
}
@@ -483,7 +487,7 @@ function UsageStatsTable(props: {
}));
return (
-
+
);
}
diff --git a/apps/desktop/src/renderer/styles/settings/usage.css b/apps/desktop/src/renderer/styles/settings/usage.css
index 07aeb6a8ee..1a3d443724 100644
--- a/apps/desktop/src/renderer/styles/settings/usage.css
+++ b/apps/desktop/src/renderer/styles/settings/usage.css
@@ -1,19 +1,22 @@
-/* Settings → Usage page (使用量). Moved out of bot.css — nothing bot-related
- consumes these rules; they had been hiding ~150 lines of usage styling in
- the bot budget. Content is an unmodified move; the #1879 height pins and
- the 900px media query travel intact. */
+/* Settings → Usage page (使用量). Astryx owns the controls and table
+ geometry; this file only composes those primitives into the page hierarchy. */
-.settingsUsagePage {
+.settingsUsageOverview,
+.settingsUsageBreakdown,
+.settingsUsageTabPanel {
display: grid;
align-content: start;
- /* #1364: pin the single column to `minmax(0, 1fr)`. With the implicit
- `auto` track, every block's min-content propagated into the track size —
- the five-tab bar (~453px intrinsic) and the requests table dragged the
- whole page into horizontal scroll even though both scroll within
- themselves. An explicit 0-floor track is the grid-level equivalent of
- `min-width: 0` on every row. */
+ /* #1364: wide tabs and tables stop at their local group and scroll within
+ themselves instead of widening the settings content column. */
grid-template-columns: minmax(0, 1fr);
- gap: var(--space-2-5);
+}
+
+.settingsUsageOverview {
+ gap: var(--space-3);
+}
+
+.settingsUsageBreakdown {
+ gap: var(--space-4);
}
.settingsUsageToolbar,
@@ -44,25 +47,18 @@
}
.settingsUsageTabPanel {
- display: grid;
- align-content: start;
- /* #1364: same 0-floor track as `.settingsUsagePage` — the requests table's
- intrinsic width must stop here and scroll inside its own container. */
- grid-template-columns: minmax(0, 1fr);
- gap: var(--space-2);
+ gap: var(--space-3);
min-height: 0;
- margin-top: var(--space-2-5);
}
.settingsUsageNumericCell {
font-variant-numeric: tabular-nums;
}
-/* The requests table's 8 sized columns are wider than the content column at
- narrow widths — it scrolls inside its own wrapper instead of clipping at
- the card edge (page body never scrolls horizontally). */
+/* Astryx Card establishes the table's surface and resets its container-bleed
+ boundary. Astryx Table owns horizontal scrolling inside that boundary. */
.settingsUsageTable {
- overflow-x: auto;
+ min-width: 0;
}
/* Empty tab: the shared EmptyState card, nudged to the density of the
@@ -156,4 +152,3 @@
/* .settingsRow / .settingsRows moved to rows.css — the single style home
for the settings-rows.tsx primitives (PR-SETTINGS-ROWS-CONVERGENCE-0). */
-
diff --git a/apps/desktop/stories/product-smoke-manifest.json b/apps/desktop/stories/product-smoke-manifest.json
index 3d09ee660f..a9a43cdb1d 100644
--- a/apps/desktop/stories/product-smoke-manifest.json
+++ b/apps/desktop/stories/product-smoke-manifest.json
@@ -33,7 +33,7 @@
"colorSchemes": ["light", "dark"]
},
"usage": {
- "storyId": "product-settings-pages--usage-requests-populated",
+ "storyId": "product-settings-pages--usage-long-tail",
"productionHost": "SettingsSurface > UsageSettingsPage",
"state": "Populated usage metrics, filters, tabs, and request table",
"viewports": ["wide", "compact", "floor"],
diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx
index 607fb1f241..0cefcdfdf0 100644
--- a/apps/desktop/stories/settings/settings-pages.stories.tsx
+++ b/apps/desktop/stories/settings/settings-pages.stories.tsx
@@ -202,6 +202,60 @@ const usageStats: UsageStats = {
pricing: [{ provider: 'zai-coding-plan', model: 'glm-4.7', inputPerMTokUsd: 0, outputPerMTokUsd: 0 }],
};
+const emptyUsageStats: UsageStats = {
+ summary: {
+ totalRequests: 0,
+ totalCostUsd: 0,
+ totalTokens: 0,
+ inputTokens: 0,
+ outputTokens: 0,
+ cacheTokens: 0,
+ cacheMiss: 0,
+ cacheRead: 0,
+ cacheCreation: 0,
+ reasoning: 0,
+ },
+ logs: [],
+ byProvider: [],
+ byModel: [],
+ byTool: [],
+ pricing: [],
+};
+
+const singleProviderUsageStats: UsageStats = {
+ ...emptyUsageStats,
+ summary: {
+ ...emptyUsageStats.summary,
+ totalRequests: 37,
+ totalCostUsd: 0.18,
+ totalTokens: 24_800,
+ inputTokens: 19_600,
+ outputTokens: 5_200,
+ },
+ byProvider: [{ provider: 'zai-coding-plan', requests: 37, tokens: 24_800, costUsd: 0.18 }],
+};
+
+const multiModelUsageStats: UsageStats = {
+ ...emptyUsageStats,
+ summary: {
+ ...emptyUsageStats.summary,
+ totalRequests: 592,
+ totalCostUsd: 8.42,
+ totalTokens: 1_284_000,
+ inputTokens: 914_000,
+ outputTokens: 370_000,
+ cacheTokens: 436_000,
+ cacheRead: 436_000,
+ },
+ byModel: [
+ { model: 'glm-4.7', requests: 280, tokens: 624_000, costUsd: 1.5 },
+ { model: 'gpt-5', requests: 148, tokens: 318_000, costUsd: 3.74 },
+ { model: 'claude-sonnet-4-5-20250929', requests: 96, tokens: 214_000, costUsd: 2.56 },
+ { model: 'gemini-2.5-pro', requests: 48, tokens: 96_000, costUsd: 0.52 },
+ { model: 'qwen3-coder-480b-a35b-instruct', requests: 20, tokens: 32_000, costUsd: 0.1 },
+ ],
+};
+
function makeMemoryEntry(input: {
id: string;
title: string;
@@ -611,23 +665,39 @@ const withMemoryPopulatedBridge = withScopedMakaBridge({
...makeMemoryBridgeChannels(populatedMemoryState),
} satisfies Record );
-/** Requests tab visible with the hostile-width logs (see `usageLogs`). */
-const usagePopulatedSettings = mergeSettings(createDefaultSettings(), {
- usage: { showDetails: true, activeTab: 'requests' },
-});
+function withUsageStoryBridge(
+ stats: UsageStats,
+ usage: Partial,
+) {
+ const settings = mergeSettings(createDefaultSettings(), { usage });
+ return withScopedMakaBridge({
+ ...makaBridge,
+ settings: {
+ ...makaBridge.settings,
+ get: async () => settings,
+ update: async (
+ patch: Parameters[0],
+ ): Promise => ({
+ settings: mergeSettings(settings, patch),
+ }),
+ usageStats: async (): Promise => stats,
+ },
+ } satisfies Record);
+}
-const withUsagePopulatedBridge = withScopedMakaBridge({
- ...makaBridge,
- settings: {
- ...makaBridge.settings,
- get: async () => usagePopulatedSettings,
- update: async (
- patch: Parameters[0],
- ): Promise => ({
- settings: mergeSettings(usagePopulatedSettings, patch),
- }),
- },
-} satisfies Record);
+const withUsageEmptyBridge = withUsageStoryBridge(emptyUsageStats, {
+ activeTab: 'providers',
+});
+const withUsageSingleProviderBridge = withUsageStoryBridge(singleProviderUsageStats, {
+ activeTab: 'providers',
+});
+const withUsageMultiModelBridge = withUsageStoryBridge(multiModelUsageStats, {
+ activeTab: 'models',
+});
+const withUsageLongTailBridge = withUsageStoryBridge(usageStats, {
+ showDetails: true,
+ activeTab: 'requests',
+});
const subagentStorySettings = mergeSettings(createDefaultSettings(), {
subagents: {
@@ -939,17 +1009,31 @@ export const Appearance: Story = {
render: () => ,
};
/** #1362: proxy + auth enabled so the full form-grid stack renders. */
-/**
- * #1364: the requests Astryx Table with hostile-width content (dated preview
- * model ids, namespaced MCP tool names). No story rendered a table at
- * all before this — `logs` was `[]` and the requests tab defaulted to its
- * summary-only Banner.
- */
-// Real path: 设置 → 使用统计 → 详情记录 on → 请求日志, with recorded traffic.
-export const UsageRequestsPopulated: Story = {
- decorators: [withUsagePopulatedBridge],
+// Real path: 设置 → 使用统计 → 供应商统计, before any usage has been recorded.
+export const UsageEmpty: Story = {
+ decorators: [withUsageEmptyBridge],
+ render: () => ,
+};
+// Real path: 设置 → 使用统计 → 供应商统计, with traffic from one provider.
+export const UsageSingleProvider: Story = {
+ decorators: [withUsageSingleProviderBridge],
render: () => ,
};
+// Real path: 设置 → 使用统计 → 模型统计, with several model families to compare.
+export const UsageMultiModel: Story = {
+ decorators: [withUsageMultiModelBridge],
+ render: () => ,
+};
+// Real path: 设置 → 使用统计 → 详情记录 on → 请求日志, with long model and tool names.
+export const UsageLongTail: Story = {
+ decorators: [withUsageLongTailBridge],
+ render: () => ,
+};
+// Real path: the same long-content Usage page at the minimum supported window width.
+export const UsageNarrow: Story = {
+ ...UsageLongTail,
+ parameters: { viewport: { defaultViewport: 'mobile2' } },
+};
/**
* #1364: entry list (long title / content / tag set), archived group, and
* backup-candidate rows. The bridge used to lack the `memory` channel
|