Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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(/<td\b[^>]*role="rowheader"/g)?.length, 1);
});
});
Expand Down
80 changes: 42 additions & 38 deletions apps/desktop/src/renderer/settings/usage-settings-page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useMemo, useState, type ReactNode } from 'react';
import {
Card,
EmptyState,
SegmentedControl,
SegmentedControlItem,
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -115,44 +117,46 @@ export function UsageSettingsPage(props: {
}

return (
<div className="settingsUsagePage">
<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. */}
<IconButton
variant="ghost"
size="sm"
isDisabled={refreshing}
aria-busy={refreshing}
data-pending={refreshing ? 'true' : undefined}
label={refreshing ? copy.refreshingAria : copy.refreshAria}
tooltip={refreshing ? copy.refreshingAria : copy.refreshAria}
onClick={() => void refresh()}
icon={<RefreshCcw size={15} aria-hidden="true" />}
/>
</div>
<SettingsPage className="settingsUsagePage">
<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. */}
<IconButton
variant="ghost"
size="sm"
isDisabled={refreshing}
aria-busy={refreshing}
data-pending={refreshing ? 'true' : undefined}
label={refreshing ? copy.refreshingAria : copy.refreshAria}
tooltip={refreshing ? copy.refreshingAria : copy.refreshAria}
onClick={() => void refresh()}
icon={<RefreshCcw size={15} aria-hidden="true" />}
/>
</div>

<div className="settingsUsageSummary" role="group" aria-label={copy.summaryAria}>
<MetricCard title={copy.totalRequests} value={String(stats?.summary.totalRequests ?? 0)} />
<MetricCard title={copy.totalCost} value={`$${(stats?.summary.totalCostUsd ?? 0).toFixed(2)}`} detail={copy.costHelp} />
<MetricCard title={copy.totalTokens} value={String(stats?.summary.totalTokens ?? 0)} detail={copy.tokenDetail(stats?.summary.inputTokens ?? 0, stats?.summary.outputTokens ?? 0)} />
<MetricCard title={copy.cacheTokens} value={String(stats?.summary.cacheTokens ?? 0)} detail={copy.cacheDetail(stats?.summary.cacheMiss ?? 0, stats?.summary.cacheRead ?? 0, stats?.summary.cacheCreation ?? 0)} />
<div className="settingsUsageSummary" role="group" aria-label={copy.summaryAria}>
<MetricCard title={copy.totalRequests} value={String(stats?.summary.totalRequests ?? 0)} />
<MetricCard title={copy.totalCost} value={`$${(stats?.summary.totalCostUsd ?? 0).toFixed(2)}`} detail={copy.costHelp} />
<MetricCard title={copy.totalTokens} value={String(stats?.summary.totalTokens ?? 0)} detail={copy.tokenDetail(stats?.summary.inputTokens ?? 0, stats?.summary.outputTokens ?? 0)} />
<MetricCard title={copy.cacheTokens} value={String(stats?.summary.cacheTokens ?? 0)} detail={copy.cacheDetail(stats?.summary.cacheMiss ?? 0, stats?.summary.cacheRead ?? 0, stats?.summary.cacheCreation ?? 0)} />
</div>
</div>

<div>
<div className="settingsUsageBreakdown">
<div className="settingsUsageTabsBar">
<TabList
value={usageDraft.activeTab}
Expand Down Expand Up @@ -215,7 +219,7 @@ export function UsageSettingsPage(props: {
</div>
) : null}
</div>
</div>
</SettingsPage>
);
}

Expand Down Expand Up @@ -483,7 +487,7 @@ function UsageStatsTable(props: {
}));

return (
<div className="settingsUsageTable">
<Card className="settingsUsageTable" padding={3}>
<Table
aria-label={props.ariaLabel}
data={data}
Expand All @@ -494,6 +498,6 @@ function UsageStatsTable(props: {
textOverflow="truncate"
plugins={usageTablePlugins}
/>
</div>
</Card>
);
}
43 changes: 19 additions & 24 deletions apps/desktop/src/renderer/styles/settings/usage.css
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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). */

2 changes: 1 addition & 1 deletion apps/desktop/stories/product-smoke-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
134 changes: 109 additions & 25 deletions apps/desktop/stories/settings/settings-pages.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -611,23 +665,39 @@ const withMemoryPopulatedBridge = withScopedMakaBridge({
...makeMemoryBridgeChannels(populatedMemoryState),
} satisfies Record<string, unknown>);

/** 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<AppSettings['usage']>,
) {
const settings = mergeSettings(createDefaultSettings(), { usage });
return withScopedMakaBridge({
...makaBridge,
settings: {
...makaBridge.settings,
get: async () => settings,
update: async (
patch: Parameters<typeof window.maka.settings.update>[0],
): Promise<UpdateAppSettingsResult> => ({
settings: mergeSettings(settings, patch),
}),
usageStats: async (): Promise<UsageStats> => stats,
},
} satisfies Record<string, unknown>);
}

const withUsagePopulatedBridge = withScopedMakaBridge({
...makaBridge,
settings: {
...makaBridge.settings,
get: async () => usagePopulatedSettings,
update: async (
patch: Parameters<typeof window.maka.settings.update>[0],
): Promise<UpdateAppSettingsResult> => ({
settings: mergeSettings(usagePopulatedSettings, patch),
}),
},
} satisfies Record<string, unknown>);
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: {
Expand Down Expand Up @@ -939,17 +1009,31 @@ export const Appearance: Story = {
render: () => <SettingsStory section="appearance" />,
};
/** #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: () => <SettingsStory section="usage" />,
};
// Real path: 设置 → 使用统计 → 供应商统计, with traffic from one provider.
export const UsageSingleProvider: Story = {
decorators: [withUsageSingleProviderBridge],
render: () => <SettingsStory section="usage" />,
};
// Real path: 设置 → 使用统计 → 模型统计, with several model families to compare.
export const UsageMultiModel: Story = {
decorators: [withUsageMultiModelBridge],
render: () => <SettingsStory section="usage" />,
};
// Real path: 设置 → 使用统计 → 详情记录 on → 请求日志, with long model and tool names.
export const UsageLongTail: Story = {
decorators: [withUsageLongTailBridge],
render: () => <SettingsStory section="usage" />,
};
// 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
Expand Down
Loading