mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
c97bd55a57
commit
58d5221ca1
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
|
||||
@ -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': '多い',
|
||||
|
||||
|
||||
@ -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': '많음',
|
||||
|
||||
|
||||
@ -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': '多',
|
||||
|
||||
|
||||
@ -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': '多',
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user