feat(activity): add plugin and skill usage insights

Add historical tool and skill aggregation to activity stats, compact the token summary, and show Codex-style activity insights with plugin/skill ranking.

Tested: bun test src/utils/__tests__/stats.test.ts
Tested: bun run check:persistence-upgrade
Tested: bun run check:desktop
Not-tested: bun run check:server (previous full run exposed unrelated conversations WebSocket timeouts; focused stats and persistence checks passed)
Confidence: medium
Scope-risk: moderate
This commit is contained in:
程序员阿江(Relakkes) 2026-06-12 10:41:33 +08:00
parent c97bd55a57
commit 58d5221ca1
14 changed files with 502 additions and 72 deletions

View File

@ -29,6 +29,13 @@ export type SessionStats = {
timestamp: string
}
export type ModelUsage = {
inputTokens?: number
outputTokens?: number
cacheReadInputTokens?: number
cacheCreationInputTokens?: number
}
export type ActivityStats = {
totalSessions: number
totalMessages: number
@ -38,7 +45,9 @@ export type ActivityStats = {
dailyActivity: DailyActivity[]
dailyModelTokens: DailyModelTokens[]
longestSession: SessionStats | null
modelUsage: Record<string, unknown>
modelUsage: Record<string, ModelUsage>
toolUsage: Record<string, number>
skillUsage: Record<string, number>
firstSessionDate: string | null
lastSessionDate: string | null
peakActivityDay: string | null

View File

@ -226,6 +226,8 @@ export const en = {
'settings.activity.count.sessionOther': '{count} sessions',
'settings.activity.count.dayOne': '{count} day',
'settings.activity.count.dayOther': '{count} days',
'settings.activity.count.runOne': '{count} run',
'settings.activity.count.runOther': '{count} runs',
'settings.activity.weekday.mon': 'Mon',
'settings.activity.weekday.wed': 'Wed',
'settings.activity.weekday.fri': 'Fri',
@ -234,6 +236,15 @@ export const en = {
'settings.activity.tokens': 'Tokens',
'settings.activity.messages': 'Messages',
'settings.activity.tools': 'Tools',
'settings.activity.activityInsights': 'Activity insights',
'settings.activity.activeRate': 'Active rate',
'settings.activity.mostUsedModel': 'Most used model',
'settings.activity.exploredSkills': 'Skills explored',
'settings.activity.totalSkillUses': 'Skill uses',
'settings.activity.totalToolCalls': 'Tool calls',
'settings.activity.totalSessions': 'Total sessions',
'settings.activity.mostUsedPluginsAndSkills': 'Most used plugins & skills',
'settings.activity.none': 'None',
'settings.activity.less': 'Less',
'settings.activity.more': 'More',

View File

@ -228,6 +228,8 @@ export const jp: Record<TranslationKey, string> = {
'settings.activity.count.sessionOther': '{count} セッション',
'settings.activity.count.dayOne': '{count} 日',
'settings.activity.count.dayOther': '{count} 日',
'settings.activity.count.runOne': '{count} 回実行',
'settings.activity.count.runOther': '{count} 回実行',
'settings.activity.weekday.mon': '月',
'settings.activity.weekday.wed': '水',
'settings.activity.weekday.fri': '金',
@ -236,6 +238,15 @@ export const jp: Record<TranslationKey, string> = {
'settings.activity.tokens': 'トークン',
'settings.activity.messages': 'メッセージ',
'settings.activity.tools': 'ツール',
'settings.activity.activityInsights': 'アクティビティ分析',
'settings.activity.activeRate': 'アクティブ率',
'settings.activity.mostUsedModel': '最も使ったモデル',
'settings.activity.exploredSkills': '使用したスキル',
'settings.activity.totalSkillUses': 'スキル使用数',
'settings.activity.totalToolCalls': 'ツール呼び出し',
'settings.activity.totalSessions': 'セッション総数',
'settings.activity.mostUsedPluginsAndSkills': 'よく使うプラグイン/スキル',
'settings.activity.none': 'なし',
'settings.activity.less': '少ない',
'settings.activity.more': '多い',

View File

@ -228,6 +228,8 @@ export const kr: Record<TranslationKey, string> = {
'settings.activity.count.sessionOther': '{count} 세션',
'settings.activity.count.dayOne': '{count}일',
'settings.activity.count.dayOther': '{count}일',
'settings.activity.count.runOne': '{count}회 실행',
'settings.activity.count.runOther': '{count}회 실행',
'settings.activity.weekday.mon': '월',
'settings.activity.weekday.wed': '수',
'settings.activity.weekday.fri': '금',
@ -236,6 +238,15 @@ export const kr: Record<TranslationKey, string> = {
'settings.activity.tokens': '토큰',
'settings.activity.messages': '메시지',
'settings.activity.tools': '도구',
'settings.activity.activityInsights': '활동 인사이트',
'settings.activity.activeRate': '활성 비율',
'settings.activity.mostUsedModel': '가장 많이 쓴 모델',
'settings.activity.exploredSkills': '사용한 스킬',
'settings.activity.totalSkillUses': '스킬 사용 수',
'settings.activity.totalToolCalls': '도구 호출',
'settings.activity.totalSessions': '총 세션',
'settings.activity.mostUsedPluginsAndSkills': '자주 쓰는 플러그인/스킬',
'settings.activity.none': '없음',
'settings.activity.less': '적음',
'settings.activity.more': '많음',

View File

@ -228,6 +228,8 @@ export const zh: Record<TranslationKey, string> = {
'settings.activity.count.sessionOther': '{count} 次會話',
'settings.activity.count.dayOne': '{count} 天',
'settings.activity.count.dayOther': '{count} 天',
'settings.activity.count.runOne': '{count} 次執行',
'settings.activity.count.runOther': '{count} 次執行',
'settings.activity.weekday.mon': '週一',
'settings.activity.weekday.wed': '週三',
'settings.activity.weekday.fri': '週五',
@ -236,6 +238,15 @@ export const zh: Record<TranslationKey, string> = {
'settings.activity.tokens': 'Token',
'settings.activity.messages': '訊息',
'settings.activity.tools': '工具',
'settings.activity.activityInsights': '活動洞察',
'settings.activity.activeRate': '活躍率',
'settings.activity.mostUsedModel': '最常用模型',
'settings.activity.exploredSkills': '已使用的技能',
'settings.activity.totalSkillUses': '技能使用總數',
'settings.activity.totalToolCalls': '工具呼叫',
'settings.activity.totalSessions': '會話總數',
'settings.activity.mostUsedPluginsAndSkills': '最常用的外掛和技能',
'settings.activity.none': '暫無',
'settings.activity.less': '少',
'settings.activity.more': '多',

View File

@ -228,6 +228,8 @@ export const zh: Record<TranslationKey, string> = {
'settings.activity.count.sessionOther': '{count} 次会话',
'settings.activity.count.dayOne': '{count} 天',
'settings.activity.count.dayOther': '{count} 天',
'settings.activity.count.runOne': '{count} 次运行',
'settings.activity.count.runOther': '{count} 次运行',
'settings.activity.weekday.mon': '周一',
'settings.activity.weekday.wed': '周三',
'settings.activity.weekday.fri': '周五',
@ -236,6 +238,15 @@ export const zh: Record<TranslationKey, string> = {
'settings.activity.tokens': 'Token',
'settings.activity.messages': '消息',
'settings.activity.tools': '工具',
'settings.activity.activityInsights': '活动洞察',
'settings.activity.activeRate': '活跃率',
'settings.activity.mostUsedModel': '最常用模型',
'settings.activity.exploredSkills': '已使用的技能',
'settings.activity.totalSkillUses': '技能使用总数',
'settings.activity.totalToolCalls': '工具调用',
'settings.activity.totalSessions': '会话总数',
'settings.activity.mostUsedPluginsAndSkills': '最常用的插件和技能',
'settings.activity.none': '暂无',
'settings.activity.less': '少',
'settings.activity.more': '多',

View File

@ -62,7 +62,28 @@ const activityResponse = {
{ date: '2026-05-09', tokensByModel: { 'claude-sonnet': 128_000 } },
],
longestSession: null,
modelUsage: {},
modelUsage: {
'claude-sonnet': {
inputTokens: 1_900_000,
outputTokens: 700_000,
cacheReadInputTokens: 230_000,
cacheCreationInputTokens: 34_000,
},
},
toolUsage: {
Bash: 180,
Read: 160,
Skill: 40,
mcp__github__get_pull_request: 14,
mcp__chrome_devtools__new_page: 8,
mcp__figma__get_screenshot: 7,
mcp__linear__create_issue: 2,
},
skillUsage: {
'frontend-design': 24,
'git-commit-pr': 16,
'code-review': 11,
},
firstSessionDate: '2025-06-01T10:00:00.000Z',
lastSessionDate: '2026-05-09T11:00:00.000Z',
peakActivityDay: '2026-04-20',
@ -195,6 +216,27 @@ describe('ActivitySettings', () => {
expect(screen.getByText('0m')).toBeInTheDocument()
expect(screen.getByText('9 days')).toBeInTheDocument()
expect(screen.getByText('18 days')).toBeInTheDocument()
expect(screen.getByText('Activity insights')).toBeInTheDocument()
expect(screen.getByText('Active rate')).toBeInTheDocument()
expect(screen.getByText('Most used model')).toBeInTheDocument()
expect(screen.getByText('Skills explored')).toBeInTheDocument()
expect(screen.getByText('Skill uses')).toBeInTheDocument()
expect(screen.getByText('Tool calls')).toBeInTheDocument()
expect(screen.getByText('Total sessions')).toBeInTheDocument()
expect(screen.getByText('Most used plugins & skills')).toBeInTheDocument()
expect(screen.getByText('Sonnet')).toBeInTheDocument()
expect(screen.getByText('$frontend-design')).toBeInTheDocument()
expect(screen.getByText('$git-commit-pr')).toBeInTheDocument()
expect(screen.getByText('$code-review')).toBeInTheDocument()
expect(screen.getByText('@github')).toBeInTheDocument()
expect(screen.getByText('@chrome-devtools')).toBeInTheDocument()
expect(screen.getByText('@figma')).toBeInTheDocument()
expect(screen.getByText('24 runs')).toBeInTheDocument()
expect(screen.getByText('14 runs')).toBeInTheDocument()
expect(screen.getByText('7 runs')).toBeInTheDocument()
expect(screen.queryByText('@linear')).not.toBeInTheDocument()
expect(screen.queryByText('Bash')).not.toBeInTheDocument()
expect(screen.queryByText('Read')).not.toBeInTheDocument()
expect(screen.getAllByText('May').length).toBeGreaterThan(0)
expect(screen.queryByText('5月')).not.toBeInTheDocument()
@ -239,13 +281,14 @@ describe('ActivitySettings', () => {
expect(editButton.closest('div')).toHaveClass('group/activity-profile')
})
it('uses a balanced responsive summary grid instead of the loose medium-width layout', async () => {
it('uses a compact summary strip instead of the loose card layout', async () => {
render(<ActivitySettings />)
await flushActivityLoad()
const summaryPanel = screen.getByText('Total tokens').closest('section')
expect(summaryPanel).toHaveClass('activity-summary-panel')
expect(summaryPanel).toHaveClass('max-w-[900px]')
const summaryGrid = summaryPanel?.querySelector('.activity-summary-grid')
expect(summaryGrid).not.toHaveClass('sm:grid-cols-2')
@ -256,10 +299,11 @@ describe('ActivitySettings', () => {
expect(primaryMetric).toHaveClass('activity-summary-metric-primary')
expect(primaryMetric).not.toHaveClass('sm:col-span-2')
expect(primaryMetric).not.toHaveClass('lg:col-span-1')
expect(primaryMetric).toHaveClass('text-center')
const longestTaskValue = screen.getByText('0m')
expect(longestTaskValue).toHaveClass('activity-summary-value')
expect(longestTaskValue).not.toHaveClass('truncate')
expect(longestTaskValue).toHaveClass('truncate')
expect(longestTaskValue).not.toHaveClass('break-words')
})
@ -364,6 +408,8 @@ describe('ActivitySettings', () => {
activeDays: 0,
dailyActivity: [],
dailyModelTokens: [],
toolUsage: {},
skillUsage: {},
longestSession: {
id: 'session-1',
startedAt: '2026-05-09T08:00:00.000Z',
@ -388,5 +434,6 @@ describe('ActivitySettings', () => {
expect(screen.getByText('1 小时 30 分钟')).toBeInTheDocument()
expect(screen.getByText('12 消息')).toBeInTheDocument()
expect(screen.getByText('暂无本地用量')).toBeInTheDocument()
expect(screen.queryByText('活动洞察')).not.toBeInTheDocument()
})
})

View File

@ -28,6 +28,19 @@ type SummaryMetric = {
detail?: string
}
type InsightMetric = {
label: string
value: string
detail?: string
}
type PluginRankItem = {
id: string
label: string
count: number
kind: 'plugin' | 'skill'
}
type HeatmapMode = 'daily' | 'weekly' | 'cumulative'
const WEEK_COUNT = 52
@ -102,6 +115,18 @@ function formatTokens(tokens: number) {
return `${tokens}`
}
function formatInteger(value: number, locale: Locale) {
return new Intl.NumberFormat(DATE_LOCALES[locale], { maximumFractionDigits: 0 }).format(value)
}
function formatPercent(numerator: number, denominator: number, locale: Locale) {
if (denominator <= 0) return '0%'
return new Intl.NumberFormat(DATE_LOCALES[locale], {
maximumFractionDigits: 0,
style: 'percent',
}).format(numerator / denominator)
}
function formatDayCount(value: number, t: ReturnType<typeof useTranslation>) {
return t(value === 1 ? 'settings.activity.count.dayOne' : 'settings.activity.count.dayOther', { count: value })
}
@ -131,6 +156,67 @@ function formatMessageCount(value: number, t: ReturnType<typeof useTranslation>)
return `${value} ${t('settings.activity.messages')}`
}
function formatRunCount(value: number, t: ReturnType<typeof useTranslation>) {
return t(value === 1 ? 'settings.activity.count.runOne' : 'settings.activity.count.runOther', { count: value })
}
function getModelTokenTotal(usage: ActivityStatsResponse['modelUsage'][string] | undefined) {
if (!usage) return 0
return (
(usage.inputTokens ?? 0) +
(usage.outputTokens ?? 0) +
(usage.cacheReadInputTokens ?? 0) +
(usage.cacheCreationInputTokens ?? 0)
)
}
function formatModelName(model: string) {
return model
.replace(/^claude-/i, '')
.replace(/-/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase())
}
function getPluginNameFromToolName(toolName: string) {
if (!toolName.startsWith('mcp__')) return null
const parts = toolName.split('__').filter(Boolean)
const serverName = parts[1]
if (!serverName) return null
if (serverName === 'codex_apps' && parts[2]) return parts[2]
return serverName
}
function formatPluginName(pluginName: string) {
return pluginName.replace(/_/g, '-')
}
function buildPluginAndSkillRankItems(stats: ActivityStatsResponse | null) {
const skillItems = Object.entries(stats?.skillUsage ?? {}).map<PluginRankItem>(([skill, count]) => ({
id: `skill:${skill}`,
label: `$${skill}`,
count,
kind: 'skill',
}))
const pluginUsage = new Map<string, number>()
for (const [toolName, count] of Object.entries(stats?.toolUsage ?? {})) {
const pluginName = getPluginNameFromToolName(toolName)
if (!pluginName || count <= 0) continue
pluginUsage.set(pluginName, (pluginUsage.get(pluginName) || 0) + count)
}
const pluginItems = [...pluginUsage.entries()].map<PluginRankItem>(([pluginName, count]) => ({
id: `plugin:${pluginName}`,
label: `@${formatPluginName(pluginName)}`,
count,
kind: 'plugin',
}))
return [...skillItems, ...pluginItems]
.filter((item) => item.count > 0)
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label))
.slice(0, 6)
}
function withProfileDefaults(profile: Partial<DesktopProfilePreferences> | null | undefined): DesktopProfilePreferences {
return { ...DEFAULT_PROFILE, ...profile }
}
@ -461,12 +547,31 @@ export function ActivitySettings() {
sum + Object.values(day.tokensByModel).reduce((daySum, tokens) => daySum + tokens, 0)
), 0)
}, [stats])
const totalToolCalls = useMemo(() => {
return (stats?.dailyActivity ?? []).reduce((sum, day) => sum + day.toolCallCount, 0)
}, [stats])
const totalSkillUses = useMemo(() => {
return Object.values(stats?.skillUsage ?? {}).reduce((sum, count) => sum + count, 0)
}, [stats])
const exploredSkillsCount = Object.keys(stats?.skillUsage ?? {}).length
const topModel = useMemo(() => {
return Object.entries(stats?.modelUsage ?? {}).reduce<{
model: string
tokens: number
} | null>((top, [model, usage]) => {
const tokens = getModelTokenTotal(usage)
if (tokens <= 0) return top
if (!top || tokens > top.tokens) return { model, tokens }
return top
}, null)
}, [stats])
const peakTokens = useMemo(() => {
return (stats?.dailyModelTokens ?? []).reduce((peak, day) => {
const dayTotal = Object.values(day.tokensByModel).reduce((sum, tokens) => sum + tokens, 0)
return Math.max(peak, dayTotal)
}, 0)
}, [stats])
const topPluginItems = useMemo(() => buildPluginAndSkillRankItems(stats), [stats])
const metrics: SummaryMetric[] = [
{
label: t('settings.activity.totalTokens'),
@ -494,6 +599,33 @@ export function ActivitySettings() {
detail: formatSessionCount(last30Usage.sessions, t),
},
]
const insightMetrics: InsightMetric[] = [
{
label: t('settings.activity.activeRate'),
value: formatPercent(stats?.activeDays ?? 0, stats?.totalDays ?? 0, locale),
},
{
label: t('settings.activity.mostUsedModel'),
value: topModel ? formatModelName(topModel.model) : t('settings.activity.none'),
detail: topModel ? `${formatTokens(topModel.tokens)} ${t('settings.activity.tokens')}` : undefined,
},
{
label: t('settings.activity.exploredSkills'),
value: formatInteger(exploredSkillsCount, locale),
},
{
label: t('settings.activity.totalSkillUses'),
value: formatInteger(totalSkillUses, locale),
},
{
label: t('settings.activity.totalToolCalls'),
value: formatInteger(totalToolCalls, locale),
},
{
label: t('settings.activity.totalSessions'),
value: formatInteger(stats?.totalSessions ?? 0, locale),
},
]
const avatarSrc = profile.avatarFile ? getProfileAvatarUrl(profile.avatarUpdatedAt) : DEFAULT_AVATAR_SRC
const avatarClassName = profile.avatarFile
? 'h-full w-full object-cover'
@ -565,9 +697,9 @@ export function ActivitySettings() {
}
return (
<div className="mx-auto w-full max-w-[1160px] min-w-0 pb-12">
<section className="relative flex min-h-[245px] flex-col items-center justify-start pt-6 text-center">
<div className="relative h-[88px] w-[88px] overflow-hidden rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[0_12px_34px_-24px_rgba(15,23,42,0.65)]">
<div className="mx-auto w-full max-w-[1060px] min-w-0 pb-12">
<section className="relative flex min-h-[176px] flex-col items-center justify-start pt-4 text-center">
<div className="relative h-16 w-16 overflow-hidden rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[0_10px_28px_-22px_rgba(15,23,42,0.6)]">
<img
src={avatarSrc}
alt={`${profile.displayName} avatar`}
@ -578,8 +710,8 @@ export function ActivitySettings() {
}}
/>
</div>
<div className="group/activity-profile mt-6 flex max-w-full items-center justify-center gap-2">
<h1 className="max-w-[min(720px,calc(100%-2.25rem))] truncate text-4xl font-semibold tracking-tight text-[var(--color-text-primary)] sm:text-[44px]">{profile.displayName}</h1>
<div className="group/activity-profile mt-4 flex max-w-full items-center justify-center gap-2">
<h1 className="max-w-[min(720px,calc(100%-2.25rem))] truncate text-[28px] font-semibold tracking-tight text-[var(--color-text-primary)] sm:text-[34px]">{profile.displayName}</h1>
<button
type="button"
aria-label={t('settings.activity.editProfile')}
@ -600,30 +732,30 @@ export function ActivitySettings() {
href={profileSubtitleHref}
target="_blank"
rel="noreferrer"
className="mt-3 inline-flex max-w-full items-center justify-center gap-2 truncate text-lg text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
className="mt-2 inline-flex max-w-full items-center justify-center gap-2 truncate text-base text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
>
<span>{profile.subtitle}</span>
</a>
) : (
<div className="mt-3 max-w-full truncate text-lg text-[var(--color-text-tertiary)]">{profile.subtitle}</div>
<div className="mt-2 max-w-full truncate text-base text-[var(--color-text-tertiary)]">{profile.subtitle}</div>
)}
{profileStatus && <div className="mt-3 text-xs text-[var(--color-success)]">{profileStatus}</div>}
{profileError && !isEditingProfile && <div className="mt-3 text-xs text-[var(--color-error)]">{profileError}</div>}
</section>
<section className="activity-summary-panel mx-auto mt-8 overflow-hidden rounded-[18px] border border-[var(--color-border)] bg-[var(--color-border)] p-px shadow-[0_18px_45px_-38px_rgba(15,23,42,0.65)]">
<section className="activity-summary-panel mx-auto mt-7 w-full max-w-[900px] overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-border)] p-px shadow-[0_12px_34px_-32px_rgba(15,23,42,0.55)]">
{isLoading ? (
<div className="activity-summary-grid grid gap-px">
{Array.from({ length: 5 }).map((_, index) => (
<div
key={index}
className={`activity-summary-metric min-h-[112px] animate-pulse bg-[var(--color-surface)] px-5 py-4 ${
className={`activity-summary-metric min-h-[76px] animate-pulse bg-[var(--color-surface)] px-4 py-3 ${
index === 0 ? 'activity-summary-metric-primary' : ''
}`}
>
<div className="h-3 w-20 rounded bg-[var(--color-surface-container)]" />
<div className="mt-6 h-7 w-24 rounded bg-[var(--color-surface-container)]" />
<div className="mt-3 h-3 w-16 rounded bg-[var(--color-surface-container)]" />
<div className="mx-auto h-5 w-16 rounded bg-[var(--color-surface-container)]" />
<div className="mx-auto mt-2 h-3 w-20 rounded bg-[var(--color-surface-container)]" />
<div className="mx-auto mt-2 h-2.5 w-14 rounded bg-[var(--color-surface-container)]" />
</div>
))}
</div>
@ -634,31 +766,21 @@ export function ActivitySettings() {
return (
<div
key={metric.label}
className={`activity-summary-metric min-w-0 bg-[var(--color-surface-container-lowest)] px-5 py-4 text-left opacity-0 shadow-[inset_0_1px_0_rgba(255,255,255,0.48)] [animation:activity-reveal_420ms_cubic-bezier(0.16,1,0.3,1)_forwards] ${
className={`activity-summary-metric min-w-0 bg-[var(--color-surface-container-lowest)] px-4 py-3 text-center opacity-0 shadow-[inset_0_1px_0_rgba(255,255,255,0.48)] [animation:activity-reveal_420ms_cubic-bezier(0.16,1,0.3,1)_forwards] ${
isPrimary ? 'activity-summary-metric-primary' : ''
}`}
style={{ animationDelay: `${index * 45}ms` }}
>
<div className="flex min-h-[104px] flex-col justify-between gap-4">
<div className="flex min-w-0 items-center gap-2">
<span
className={`h-1.5 w-1.5 shrink-0 rounded-full ${
isPrimary ? 'bg-[var(--color-primary)]' : 'bg-[var(--color-text-tertiary)] opacity-45'
}`}
aria-hidden="true"
/>
<div className="min-w-0 truncate text-[13px] font-semibold text-[var(--color-text-secondary)]">
{metric.label}
</div>
<div className="flex min-h-[68px] flex-col items-center justify-center gap-1.5">
<div className={`activity-summary-value max-w-full truncate font-semibold leading-none tracking-tight text-[var(--color-text-primary)] tabular-nums ${
isPrimary ? 'text-[23px]' : 'text-[22px]'
}`}>
{metric.value}
</div>
<div className="min-w-0">
<div className={`activity-summary-value max-w-full font-semibold leading-[1.08] tracking-tight text-[var(--color-text-primary)] tabular-nums ${
isPrimary ? 'text-[32px] lg:text-[26px]' : 'text-[28px] lg:text-[26px]'
}`}>
{metric.value}
</div>
{metric.detail && <div className="mt-2 truncate text-sm text-[var(--color-text-tertiary)]">{metric.detail}</div>}
<div className="min-w-0 truncate text-[13px] font-medium leading-tight text-[var(--color-text-secondary)]">
{metric.label}
</div>
{metric.detail && <div className="max-w-full truncate text-[11px] leading-tight text-[var(--color-text-tertiary)]">{metric.detail}</div>}
</div>
</div>
)
@ -776,8 +898,8 @@ export function ActivitySettings() {
document.body,
)}
<div className="mt-16">
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="mt-10">
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-xl font-semibold text-[var(--color-text-primary)]">{t('settings.activity.tokenActivity')}</h2>
</div>
@ -833,7 +955,7 @@ export function ActivitySettings() {
<div ref={heatmapMeasureRef} className="min-w-0 pb-2">
<div className="relative" style={{ width: heatmapWidth, maxWidth: '100%' }}>
<div
className="mb-4 grid h-5 text-[11px] leading-none text-[var(--color-text-tertiary)]"
className="mb-3 grid h-5 text-[11px] leading-none text-[var(--color-text-tertiary)]"
style={{
marginLeft: HEAT_LABEL_WIDTH,
gridTemplateColumns: `repeat(${WEEK_COUNT}, ${heatCellSize}px)`,
@ -931,6 +1053,48 @@ export function ActivitySettings() {
</>
)}
</div>
{!isLoading && !error && hasUsage && (
<div className={`mt-12 grid gap-10 ${
topPluginItems.length > 0 ? 'lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1fr)]' : 'lg:max-w-[520px]'
}`}>
<section className="min-w-0">
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">{t('settings.activity.activityInsights')}</h2>
<dl className="mt-5 grid gap-3">
{insightMetrics.map((metric) => (
<div key={metric.label} className="grid grid-cols-[minmax(0,1fr)_auto] items-baseline gap-5">
<dt className="min-w-0 truncate text-sm font-medium text-[var(--color-text-tertiary)]">{metric.label}</dt>
<dd className="min-w-0 text-right text-sm font-semibold text-[var(--color-text-primary)]">
<span className="tabular-nums">{metric.value}</span>
{metric.detail && (
<span className="ml-2 text-xs font-medium text-[var(--color-text-tertiary)]">{metric.detail}</span>
)}
</dd>
</div>
))}
</dl>
</section>
{topPluginItems.length > 0 && (
<section className="min-w-0">
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">{t('settings.activity.mostUsedPluginsAndSkills')}</h2>
<div className="mt-5 grid gap-3">
{topPluginItems.map((item) => (
<div key={item.id} className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3">
<span className="flex h-7 w-7 items-center justify-center rounded-md border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] text-[var(--color-text-tertiary)]">
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
{item.kind === 'skill' ? 'extension' : 'hub'}
</span>
</span>
<span className="min-w-0 truncate text-sm font-medium text-[var(--color-text-primary)]">{item.label}</span>
<span className="text-sm text-[var(--color-text-tertiary)] tabular-nums">{formatRunCount(item.count, t)}</span>
</div>
))}
</div>
</section>
)}
</div>
)}
</div>
)
}

View File

@ -1301,23 +1301,9 @@ button, input, textarea, select, a, [role="button"] {
.activity-summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.activity-summary-metric-primary {
grid-column: span 2;
}
}
@container (min-width: 620px) {
.activity-summary-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.activity-summary-metric-primary {
grid-column: span 2;
}
}
@container (min-width: 860px) {
@container (min-width: 560px) {
.activity-summary-grid {
grid-template-columns: repeat(5, minmax(0, 1fr));
}

View File

@ -89,18 +89,15 @@ describe('desktop theme tokens', () => {
expect(css).toContain('box-shadow: var(--shadow-activity-cell-hover);')
})
it('uses container queries for the activity summary grid', () => {
it('uses container queries for the compact activity summary strip', () => {
const activitySummaryCss = getCssBetween('.activity-summary-panel {', '.activity-heat-cell {')
const mediumStart = activitySummaryCss.indexOf('@container (min-width: 620px)')
const wideStart = activitySummaryCss.indexOf('@container (min-width: 860px)')
const mediumSummaryCss = activitySummaryCss.slice(mediumStart, wideStart)
expect(activitySummaryCss).toContain('container-type: inline-size;')
expect(activitySummaryCss).toContain('@container (min-width: 360px)')
expect(activitySummaryCss).toContain('@container (min-width: 620px)')
expect(activitySummaryCss).toContain('@container (min-width: 860px)')
expect(activitySummaryCss).toContain('@container (min-width: 560px)')
expect(activitySummaryCss).toContain('grid-template-columns: repeat(5, minmax(0, 1fr));')
expect(mediumSummaryCss).toContain('grid-column: span 2;')
expect(activitySummaryCss).toContain('grid-column: auto;')
expect(activitySummaryCss).not.toContain('grid-column: span 2;')
})
it('avoids color-mix in the startup-critical UI zoom shell chrome for Safari 15 WebView support', () => {

View File

@ -982,6 +982,8 @@ describe('Activity Stats API', () => {
const body = await res.json()
expect(body.range).toBe('all')
expect(body.stats.totalSessions).toBe(0)
expect(body.stats.toolUsage).toEqual({})
expect(body.stats.skillUsage).toEqual({})
expect(new Date(body.generatedAt).toString()).not.toBe('Invalid Date')
})

View File

@ -41,7 +41,12 @@ function assistantEntry(
cache_read_input_tokens?: number
cache_creation_input_tokens?: number
},
options: { model?: string; isSidechain?: boolean; parentUuid?: string } = {},
options: {
model?: string
isSidechain?: boolean
parentUuid?: string
content?: unknown[]
} = {},
) {
return {
type: 'assistant',
@ -52,7 +57,7 @@ function assistantEntry(
message: {
role: 'assistant',
model: options.model ?? 'claude-test',
content: [],
content: options.content ?? [],
usage,
},
}
@ -189,6 +194,59 @@ describe('activity stats token accounting', () => {
expect(totalForDate(stats.dailyModelTokens, today)).toBe(34)
})
it('tracks tool and skill usage from main and subagent transcripts', async () => {
const today = dateKey(0)
await writeJsonl(projectFile('tool-session'), [
userEntry('tool-user', at(today, '10:00:00')),
assistantEntry(
'tool-assistant',
at(today, '10:01:00'),
{ input_tokens: 1, output_tokens: 1 },
{
parentUuid: 'tool-user',
content: [
{ type: 'tool_use', id: 'tool-1', name: 'Bash', input: { command: 'pwd' } },
{ type: 'tool_use', id: 'tool-2', name: 'Skill', input: { skill: 'frontend-design' } },
{ type: 'tool_use', id: 'tool-3', name: 'mcp__figma__get_screenshot', input: {} },
{ type: 'tool_use', id: 'tool-4', name: 'Skill', input: { skill: 42 } },
],
},
),
])
await writeJsonl(subagentFile('tool-session', '001'), [
userEntry('agent-user', at(today, '10:02:00'), true),
assistantEntry(
'agent-assistant',
at(today, '10:03:00'),
{ input_tokens: 2, output_tokens: 2 },
{
isSidechain: true,
parentUuid: 'agent-user',
content: [
{ type: 'tool_use', id: 'tool-5', name: 'Skill', input: { skill: 'frontend-design' } },
],
},
),
])
const stats = await aggregateClaudeCodeStatsForRange('all')
expect(stats.totalSessions).toBe(1)
expect(stats.dailyActivity.find(day => day.date === today)).toMatchObject({
sessionCount: 1,
toolCallCount: 5,
})
expect(stats.toolUsage).toMatchObject({
Bash: 1,
Skill: 3,
mcp__figma__get_screenshot: 1,
})
expect(stats.skillUsage).toEqual({
'frontend-design': 2,
})
})
it('keeps resumed old sessions in range token totals with active daily session counts', async () => {
const oldDate = dateKey(-50)
const inRangeDate = dateKey(-1)
@ -233,16 +291,18 @@ describe('activity stats token accounting', () => {
expect(totalForDate(stats.dailyModelTokens, future)).toBe(0)
})
it('invalidates pre-v5 stats caches because daily activity accounting changed', async () => {
it('invalidates pre-v6 stats caches because cached aggregates lack tool usage', async () => {
await mkdir(tmpConfigDir, { recursive: true })
await writeFile(
join(tmpConfigDir, 'stats-cache.json'),
JSON.stringify({
version: 3,
version: 5,
lastComputedDate: dateKey(-1),
dailyActivity: [{ date: dateKey(-1), messageCount: 1, sessionCount: 1, toolCallCount: 0 }],
dailyModelTokens: [{ date: dateKey(-1), tokensByModel: { stale: 1 } }],
modelUsage: {},
toolUsage: { stale: 1 },
skillUsage: { stale: 1 },
totalSessions: 1,
totalMessages: 1,
longestSession: null,
@ -255,8 +315,10 @@ describe('activity stats token accounting', () => {
const cache = await loadStatsCache()
expect(STATS_CACHE_VERSION).toBe(5)
expect(STATS_CACHE_VERSION).toBe(6)
expect(cache.dailyModelTokens).toEqual([])
expect(cache.toolUsage).toEqual({})
expect(cache.skillUsage).toEqual({})
expect(cache.totalSessions).toBe(0)
})
})

View File

@ -50,6 +50,10 @@ export type SessionStats = {
timestamp: string
}
export type ToolUsageMap = { [toolName: string]: number }
export type SkillUsageMap = { [skillName: string]: number }
export type ClaudeCodeStats = {
// Activity overview
totalSessions: number
@ -72,6 +76,10 @@ export type ClaudeCodeStats = {
// Model usage aggregated
modelUsage: { [modelName: string]: ModelUsage }
// Tool and skill usage aggregated from assistant tool_use blocks
toolUsage: ToolUsageMap
skillUsage: SkillUsageMap
// Time stats
firstSessionDate: string | null
lastSessionDate: string | null
@ -93,6 +101,8 @@ type ProcessedStats = {
dailyActivity: DailyActivity[]
dailyModelTokens: DailyModelTokens[]
modelUsage: { [modelName: string]: ModelUsage }
toolUsage: ToolUsageMap
skillUsage: SkillUsageMap
sessionStats: SessionStats[]
hourCounts: { [hour: number]: number }
totalMessages: number
@ -126,6 +136,38 @@ function getTotalUsageTokens(usage: UsageLike): number {
)
}
function incrementUsageCount(counts: Map<string, number>, name: string) {
const normalizedName = name.trim()
if (!normalizedName) return
counts.set(normalizedName, (counts.get(normalizedName) || 0) + 1)
}
function mergeUsageCounts(
...sources: Array<Record<string, number> | undefined>
): Record<string, number> {
const merged: Record<string, number> = {}
for (const source of sources) {
for (const [name, count] of Object.entries(source ?? {})) {
if (typeof count !== 'number' || count <= 0) continue
merged[name] = (merged[name] || 0) + count
}
}
return merged
}
function getSkillNameFromToolInput(toolName: string, input: unknown) {
if (toolName !== 'Skill') return null
if (
typeof input === 'object' &&
input !== null &&
'skill' in input &&
typeof (input as { skill: unknown }).skill === 'string'
) {
return (input as { skill: string }).skill
}
return null
}
function isDateInRange(
date: string,
fromDate?: string,
@ -163,6 +205,8 @@ async function processSessionFiles(
let totalMessages = 0
let totalSpeculationTimeSavedMs = 0
const modelUsageAgg: { [modelName: string]: ModelUsage } = {}
const toolUsageMap = new Map<string, number>()
const skillUsageMap = new Map<string, number>()
const shotDistributionMap = feature('SHOT_STATS')
? new Map<number, number>()
: undefined
@ -340,6 +384,16 @@ async function processSessionFiles(
for (const block of content) {
if (block.type === 'tool_use') {
getDailyActivity(messageDateKey).toolCallCount++
if (typeof block.name === 'string') {
incrementUsageCount(toolUsageMap, block.name)
const skillName = getSkillNameFromToolInput(
block.name,
block.input,
)
if (skillName) {
incrementUsageCount(skillUsageMap, skillName)
}
}
}
}
}
@ -404,6 +458,8 @@ async function processSessionFiles(
.map(([date, tokensByModel]) => ({ date, tokensByModel }))
.sort((a, b) => a.date.localeCompare(b.date)),
modelUsage: modelUsageAgg,
toolUsage: Object.fromEntries(toolUsageMap),
skillUsage: Object.fromEntries(skillUsageMap),
sessionStats: sessions,
hourCounts: Object.fromEntries(hourCounts),
totalMessages,
@ -555,6 +611,9 @@ function cacheToStats(
}
}
const toolUsage = mergeUsageCounts(cache.toolUsage, todayStats?.toolUsage)
const skillUsage = mergeUsageCounts(cache.skillUsage, todayStats?.skillUsage)
// Merge hour counts
const hourCountsMap = new Map<number, number>()
for (const [hour, count] of Object.entries(cache.hourCounts)) {
@ -647,6 +706,8 @@ function cacheToStats(
dailyModelTokens,
longestSession,
modelUsage,
toolUsage,
skillUsage,
firstSessionDate,
lastSessionDate,
peakActivityDay,
@ -868,6 +929,8 @@ function processedStatsToClaudeCodeStats(
dailyModelTokens: dailyModelTokensSorted,
longestSession,
modelUsage: stats.modelUsage,
toolUsage: stats.toolUsage,
skillUsage: stats.skillUsage,
firstSessionDate,
lastSessionDate,
peakActivityDay,
@ -1101,6 +1164,8 @@ function getEmptyStats(): ClaudeCodeStats {
dailyModelTokens: [],
longestSession: null,
modelUsage: {},
toolUsage: {},
skillUsage: {},
firstSessionDate: null,
lastSessionDate: null,
peakActivityDay: null,

View File

@ -9,10 +9,16 @@ import { errorMessage } from './errors.js'
import { getFsImplementation } from './fsOperations.js'
import { logError } from './log.js'
import { jsonParse, jsonStringify } from './slowOperations.js'
import type { DailyActivity, DailyModelTokens, SessionStats } from './stats.js'
import type {
DailyActivity,
DailyModelTokens,
SessionStats,
SkillUsageMap,
ToolUsageMap,
} from './stats.js'
export const STATS_CACHE_VERSION = 5
const MIN_MIGRATABLE_VERSION = 5
export const STATS_CACHE_VERSION = 6
const MIN_MIGRATABLE_VERSION = 6
const STATS_CACHE_FILENAME = 'stats-cache.json'
/**
@ -60,6 +66,9 @@ export type PersistedStatsCache = {
dailyModelTokens: DailyModelTokens[]
// Model usage aggregated (bounded by number of models)
modelUsage: { [modelName: string]: ModelUsage }
// Tool and skill usage aggregated from assistant tool_use blocks
toolUsage: ToolUsageMap
skillUsage: SkillUsageMap
// Session aggregates (replaces unbounded sessionStats array)
totalSessions: number
totalMessages: number
@ -85,6 +94,8 @@ function getEmptyCache(): PersistedStatsCache {
dailyActivity: [],
dailyModelTokens: [],
modelUsage: {},
toolUsage: {},
skillUsage: {},
totalSessions: 0,
totalMessages: 0,
longestSession: null,
@ -99,10 +110,10 @@ function getEmptyCache(): PersistedStatsCache {
* Migrate an older cache to the current schema.
* Returns null if the version is unknown or too old to migrate.
*
* Daily activity accounting changed in v5 to count per-day active parent
* sessions from message timestamps instead of session-start dates. Older
* caches are intentionally rejected so the next aggregation recomputes daily
* session counts and token buckets with the same date semantics.
* Daily activity accounting changed in v5, and v6 added historical tool/skill
* usage. Older caches are intentionally rejected so the next aggregation
* recomputes fields that cannot be reconstructed from aggregate-only cache
* files without undercounting.
*/
function migrateStatsCache(
parsed: Partial<PersistedStatsCache> & { version: number },
@ -117,6 +128,10 @@ function migrateStatsCache(
if (
!Array.isArray(parsed.dailyActivity) ||
!Array.isArray(parsed.dailyModelTokens) ||
typeof parsed.toolUsage !== 'object' ||
parsed.toolUsage === null ||
typeof parsed.skillUsage !== 'object' ||
parsed.skillUsage === null ||
typeof parsed.totalSessions !== 'number' ||
typeof parsed.totalMessages !== 'number'
) {
@ -128,6 +143,8 @@ function migrateStatsCache(
dailyActivity: parsed.dailyActivity,
dailyModelTokens: parsed.dailyModelTokens,
modelUsage: parsed.modelUsage ?? {},
toolUsage: parsed.toolUsage,
skillUsage: parsed.skillUsage,
totalSessions: parsed.totalSessions,
totalMessages: parsed.totalMessages,
longestSession: parsed.longestSession ?? null,
@ -182,6 +199,10 @@ export async function loadStatsCache(): Promise<PersistedStatsCache> {
if (
!Array.isArray(parsed.dailyActivity) ||
!Array.isArray(parsed.dailyModelTokens) ||
typeof parsed.toolUsage !== 'object' ||
parsed.toolUsage === null ||
typeof parsed.skillUsage !== 'object' ||
parsed.skillUsage === null ||
typeof parsed.totalSessions !== 'number' ||
typeof parsed.totalMessages !== 'number'
) {
@ -263,6 +284,8 @@ export function mergeCacheWithNewStats(
dailyActivity: DailyActivity[]
dailyModelTokens: DailyModelTokens[]
modelUsage: { [modelName: string]: ModelUsage }
toolUsage: ToolUsageMap
skillUsage: SkillUsageMap
sessionStats: SessionStats[]
hourCounts: { [hour: number]: number }
totalSpeculationTimeSavedMs: number
@ -270,6 +293,18 @@ export function mergeCacheWithNewStats(
},
newLastComputedDate: string,
): PersistedStatsCache {
const mergeUsageCounts = (
existingCounts: Record<string, number>,
nextCounts: Record<string, number>,
) => {
const merged = { ...existingCounts }
for (const [name, count] of Object.entries(nextCounts)) {
if (typeof count !== 'number' || count <= 0) continue
merged[name] = (merged[name] || 0) + count
}
return merged
}
// Merge daily activity - combine by date
const dailyActivityMap = new Map<string, DailyActivity>()
for (const day of existingCache.dailyActivity) {
@ -331,6 +366,12 @@ export function mergeCacheWithNewStats(
}
}
const toolUsage = mergeUsageCounts(existingCache.toolUsage, newStats.toolUsage)
const skillUsage = mergeUsageCounts(
existingCache.skillUsage,
newStats.skillUsage,
)
// Merge hour counts
const hourCounts = { ...existingCache.hourCounts }
for (const [hour, count] of Object.entries(newStats.hourCounts)) {
@ -371,6 +412,8 @@ export function mergeCacheWithNewStats(
.map(([date, tokensByModel]) => ({ date, tokensByModel }))
.sort((a, b) => a.date.localeCompare(b.date)),
modelUsage,
toolUsage,
skillUsage,
totalSessions,
totalMessages,
longestSession,