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={
-
- - - - +
+ + + + +
-
+
) : null}
-
+ ); } @@ -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