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
6 changes: 3 additions & 3 deletions apps/desktop/src/renderer/locales/settings-usage-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export type UsageSettingsCopy = {
tables: {
providersAria: string; modelsAria: string; toolsAria: string; pricingAria: string; requestsAria: string;
providerHeaders: string[]; modelHeaders: string[]; toolHeaders: string[]; pricingHeaders: string[]; requestHeaders: string[];
noPricing: string; modelKind: string; toolKind: string; openSession(label: string): string; success: string; error: string;
noPricing: string; modelKind: string; toolKind: string; openSession(label: string): string; untitledSession: string; success: string; error: string;
providerEmptyTitle: string; providerEmptyBody: string; modelEmptyTitle: string; modelEmptyBody: string;
toolEmptyTitle: string; toolEmptyBody: string; pricingEmptyBody: string;
};
Expand All @@ -49,7 +49,7 @@ const SETTINGS_USAGE_COPY = {
providersAria: '使用统计供应商统计表', modelsAria: '使用统计模型统计表', toolsAria: '使用统计工具统计表', pricingAria: '使用统计定价配置表', requestsAria: '使用统计请求日志表',
providerHeaders: ['供应商', '请求', 'Token', '费用'], modelHeaders: ['模型', '请求', 'Token', '费用'], toolHeaders: ['工具', '调用', '成功', '错误', '平均耗时'],
pricingHeaders: ['供应商', '模型', '输入 / 1M', '输出 / 1M'], requestHeaders: ['时间', '类型', '对象', '任务', 'Token', '费用', '延迟', '状态'],
noPricing: '暂无定价覆盖配置', modelKind: '模型', toolKind: '工具', openSession: (label) => `打开 ${label}`, success: '成功', error: '错误',
noPricing: '暂无定价覆盖配置', modelKind: '模型', toolKind: '工具', openSession: (label) => `打开会话「${label}」`, untitledSession: '未命名会话', success: '成功', error: '错误',
providerEmptyTitle: '暂无供应商用量', providerEmptyBody: '完成一次模型请求后,这里会按供应商聚合请求数、Token 与费用。',
modelEmptyTitle: '暂无模型用量', modelEmptyBody: '完成一次模型请求后,这里会按模型聚合请求数、Token 与费用。',
toolEmptyTitle: '暂无工具调用', toolEmptyBody: '智能体调用工具后,这里会按工具聚合调用次数、成功、错误与平均耗时。',
Expand All @@ -69,7 +69,7 @@ const SETTINGS_USAGE_COPY = {
providersAria: 'Usage by provider', modelsAria: 'Usage by model', toolsAria: 'Usage by tool', pricingAria: 'Usage pricing configuration', requestsAria: 'Usage request log',
providerHeaders: ['Provider', 'Requests', 'Tokens', 'Cost'], modelHeaders: ['Model', 'Requests', 'Tokens', 'Cost'], toolHeaders: ['Tool', 'Calls', 'Success', 'Errors', 'Average duration'],
pricingHeaders: ['Provider', 'Model', 'Input / 1M', 'Output / 1M'], requestHeaders: ['Time', 'Type', 'Target', 'Task', 'Tokens', 'Cost', 'Latency', 'Status'],
noPricing: 'No pricing overrides', modelKind: 'Model', toolKind: 'Tool', openSession: (label) => `Open ${label}`, success: 'Success', error: 'Error',
noPricing: 'No pricing overrides', modelKind: 'Model', toolKind: 'Tool', openSession: (label) => `Open session "${label}"`, untitledSession: 'Untitled session', success: 'Success', error: 'Error',
providerEmptyTitle: 'No provider usage', providerEmptyBody: 'After a model request, provider request counts, tokens, and costs appear here.',
modelEmptyTitle: 'No model usage', modelEmptyBody: 'After a model request, request counts, tokens, and costs appear here by model.',
toolEmptyTitle: 'No tool calls', toolEmptyBody: 'After an agent calls a tool, calls, successes, errors, and average duration appear here by tool.',
Expand Down
34 changes: 30 additions & 4 deletions apps/desktop/src/renderer/settings/usage-settings-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
proportional,
} from '@astryxdesign/core';
import { uiLocaleToIntlLocale } from '@maka/core/ui-locale';
import { parseDesktopSessionKey } from '../../shared/runtime-host-identity.js';
import {
type AppSettings,
type UpdateAppSettingsResult,
Expand Down Expand Up @@ -324,10 +325,10 @@ function UsageRequestsPanel(props: {
<UsageStatsTable
ariaLabel={props.copy.tables.requestsAria}
columns={[
{ header: props.copy.tables.requestHeaders[0], width: 192 },
{ 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] },
{ 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 },
Expand Down Expand Up @@ -442,13 +443,38 @@ function usageRequestTarget(row: UsageStats['logs'][number]) {
}

function usageRequestSessionCell(row: UsageStats['logs'][number], copy: UsageSettingsCopy, onOpenSession?: (sessionId: string) => void) {
const label = shortUsageSessionId(row.sessionId);
const label = usageSessionDisplayLabel(row, copy);
if (!onOpenSession) return label;
return (
<Button variant="ghost" size="sm" onClick={() => onOpenSession(row.sessionId)} label={copy.tables.openSession(label)} />
<Button
className="settingsUsageSessionCell"
variant="ghost"
size="sm"
onClick={() => onOpenSession(row.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;
}
Expand Down
23 changes: 23 additions & 0 deletions apps/desktop/src/renderer/styles/settings/usage.css
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,29 @@
min-width: 0;
}

/* 任务 column: the session-name link truncates inside its fixed column width and
surfaces the full name via the button tooltip. StyleX owns the button's own
layout; these rules only cap its width and ellipsis the label text (descendant
specificity outranks StyleX's single-class atoms), so row height and the
neighbouring columns stay aligned no matter how long a session name is. */
.settingsUsageSessionCell {
max-width: 100%;
min-width: 0;
justify-content: flex-start;
/* Align the session-name link flush with the 任务 header and the other
left-aligned text columns by neutralising the ghost button's leading inline
padding. !important because Astryx's StyleX size padding is unlayered and
otherwise wins on source order (same escape hatch as sidebar.css). */
padding-inline-start: 0 !important;
}

.settingsUsageSessionCell span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

/* Empty tab: the shared EmptyState card, nudged to the density of the
stats surface rather than the full page hero. */
.settingsUsageEmpty {
Expand Down
8 changes: 7 additions & 1 deletion apps/desktop/stories/settings/settings-pages.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,12 +205,14 @@ function makeUsageLog(input: {
toolName?: string;
status?: 'success' | 'error';
minutesAgo: number;
sessionName?: string;
}): UsageStats['logs'][number] {
return {
id: input.id,
ts: NOW - input.minutesAgo * 60_000,
kind: input.kind,
sessionId: `b0efaaf9-9e58-46c1-bfea-${input.id.padStart(12, '0')}`,
sessionName: input.sessionName ?? '',
turnId: `turn-${input.id}`,
provider: 'zai-coding-plan',
model: input.model,
Expand All @@ -228,17 +230,21 @@ const usageLogs: UsageStats['logs'] = [
id: '1',
kind: 'model',
model: 'anthropic/claude-sonnet-4-5-20250929-preview-extended-thinking',
// A long session name exercises the 任务 column's truncate-plus-tooltip path.
sessionName: '重构使用统计页请求日志的任务列,改为显示会话名称并处理超长标题的截断',
minutesAgo: 4,
}),
makeUsageLog({
id: '2',
kind: 'tool',
model: 'glm-4.7',
toolName: 'mcp__cloud_workspace__list_repository_branch_protection_rules',
sessionName: '排查 MCP 分支保护规则拉取失败',
minutesAgo: 9,
}),
// No sessionName → renders the "未命名会话 · <short id>" fallback.
makeUsageLog({ id: '3', kind: 'model', model: 'glm-4.7', status: 'error', minutesAgo: 16 }),
makeUsageLog({ id: '4', kind: 'tool', model: 'glm-4.7', toolName: 'Bash', minutesAgo: 25 }),
makeUsageLog({ id: '4', kind: 'tool', model: 'glm-4.7', toolName: 'Bash', sessionName: 'Bash 环境探查', minutesAgo: 25 }),
];

const usageStats: UsageStats = {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,8 @@ export interface UsageRequestLog {
ts: number;
kind: 'model' | 'tool';
sessionId: string;
/** Human-readable session title (SessionHeader.name); may be empty for untitled sessions. */
sessionName: string;
turnId: string;
provider: string;
model: string;
Expand Down
7 changes: 6 additions & 1 deletion packages/storage/src/usage-stats-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
} from './operational-state-store.js';
import { createSqliteSessionMetadataStore } from './sqlite-session-metadata-store.js';

type UsageSessionHeader = Pick<SessionHeader, 'id' | 'llmConnectionSlug' | 'model'>;
type UsageSessionHeader = Pick<SessionHeader, 'id' | 'name' | 'llmConnectionSlug' | 'model'>;

type UsageAssistantMessage = {
type: 'assistant';
Expand Down Expand Up @@ -92,6 +92,7 @@ export async function readUsageStats(
ts: message.ts,
kind: 'model' as const,
sessionId: header.id,
sessionName: header.name,
turnId: message.turnId,
provider: header.llmConnectionSlug,
model: assistantByTurn.get(message.turnId) ?? header.model,
Expand Down Expand Up @@ -158,6 +159,7 @@ async function readStoredSessions(
sessions.push({
header: {
id: header.id,
name: header.name,
llmConnectionSlug: header.llmConnectionSlug,
model: header.model,
},
Expand All @@ -175,10 +177,12 @@ async function readStoredSessions(
function normalizeUsageSessionHeader(value: unknown, sessionId: string): UsageSessionHeader | null {
if (!isRecord(value)) return null;
if (value.id !== sessionId) return null;
if (typeof value.name !== 'string') return null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — a display fix should not be able to drop a session from the report.

normalizeUsageSessionHeader is defensive parsing of unknown, and this adds a hard reject: a header missing name now returns null, so that session's requests and costs disappear from usage entirely rather than showing as untitled.

SessionHeader.name has been required since the initial scaffold, so this is unlikely — but the failure mode is disproportionate to the feature, and the safer form costs nothing:

name: typeof value.name === 'string' ? value.name : '',

That degrades to the untitled fallback the UI already renders.

简体中文

P3 —— 一个显示层修复不应该能让会话从报表里消失。

normalizeUsageSessionHeader 是对 unknown 的防御性解析,这里新增了硬拒绝:缺 name 的会话头返回 null,于是该会话的请求和费用会整条从用量统计里消失,而不是显示为未命名。

SessionHeader.name 从最初的脚手架起就是必填,所以概率很低——但失败形态与这个特性不相称,而更安全的写法没有任何代价,退化结果正是 UI 已经实现的「未命名」回退。

if (typeof value.llmConnectionSlug !== 'string') return null;
if (typeof value.model !== 'string') return null;
return {
id: value.id,
name: value.name,
llmConnectionSlug: value.llmConnectionSlug,
model: value.model,
};
Expand Down Expand Up @@ -365,6 +369,7 @@ function toolLogRowsFromMessages(
ts,
kind: 'tool' as const,
sessionId: header.id,
sessionName: header.name,
turnId: call.turnId,
provider: header.llmConnectionSlug,
model: header.model,
Expand Down