diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index 68a3932b..bb0e06a9 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -67,6 +67,10 @@ vi.mock('../pages/AdapterSettings', () => ({ AdapterSettings: () =>
Adapter Settings Mock
, })) +vi.mock('../pages/ActivitySettings', () => ({ + ActivitySettings: () =>
Activity Settings Mock
, +})) + vi.mock('../stores/agentStore', () => ({ useAgentStore: () => ({ activeAgents: [], @@ -185,6 +189,18 @@ describe('Settings > General tab', () => { expect(toggle).toBeChecked() }) + it('opens the Token usage tab from Settings navigation above Diagnostics', () => { + render() + + const usageTab = screen.getByText('Token usage') + const diagnosticsTab = screen.getByText('Diagnostics') + expect((usageTab.compareDocumentPosition(diagnosticsTab) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true) + + fireEvent.click(usageTab) + + expect(screen.getByText('Activity Settings Mock')).toBeInTheDocument() + }) + it('lets the user disable WebFetch preflight skipping', () => { render() diff --git a/desktop/src/api/activityStats.ts b/desktop/src/api/activityStats.ts new file mode 100644 index 00000000..bd7d274c --- /dev/null +++ b/desktop/src/api/activityStats.ts @@ -0,0 +1,70 @@ +import { api } from './client' + +export type ActivityStatsRange = '7d' | '30d' | 'all' + +export type DailyActivity = { + date: string + messageCount: number + sessionCount: number + toolCallCount: number +} + +export type DailyModelTokens = { + date: string + tokensByModel: Record +} + +export type StreakInfo = { + currentStreak: number + longestStreak: number + currentStreakStart: string | null + longestStreakStart: string | null + longestStreakEnd: string | null +} + +export type SessionStats = { + sessionId: string + duration: number + messageCount: number + timestamp: string +} + +export type ActivityStats = { + totalSessions: number + totalMessages: number + totalDays: number + activeDays: number + streaks: StreakInfo + dailyActivity: DailyActivity[] + dailyModelTokens: DailyModelTokens[] + longestSession: SessionStats | null + modelUsage: Record + firstSessionDate: string | null + lastSessionDate: string | null + peakActivityDay: string | null + peakActivityHour: number | null + totalSpeculationTimeSavedMs: number +} + +export type ActivityStatsApiResponse = { + stats: ActivityStats + range: ActivityStatsRange + generatedAt: string +} + +export type ActivityStatsResponse = ActivityStats & { + range: ActivityStatsRange + generatedAt: string +} + +export const activityStatsApi = { + async getStats(range: ActivityStatsRange = 'all'): Promise { + const suffix = range === 'all' ? '' : `/${range}` + const response = await api.get(`/api/activity-stats${suffix}`, { timeout: 120_000 }) + return { + ...response.stats, + range: response.range, + generatedAt: response.generatedAt, + } + }, +} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 09548318..bffd7fcc 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -89,6 +89,7 @@ export const en = { 'settings.title': 'Settings', 'settings.tab.providers': 'Providers', 'settings.tab.permissions': 'Permissions', + 'settings.tab.activity': 'Token usage', 'settings.tab.general': 'General', 'settings.tab.terminal': 'Terminal', 'settings.tab.skills': 'Skills', @@ -96,6 +97,31 @@ export const en = { 'settings.tab.plugins': 'Plugins', 'settings.tab.diagnostics': 'Diagnostics', + // Settings > Usage + 'settings.activity.title': 'Token usage', + 'settings.activity.subtitleLoading': 'Based on local Claude Code CLI session transcripts', + 'settings.activity.metric.yesterday': 'Yesterday', + 'settings.activity.metric.today': 'Today', + 'settings.activity.metric.last4': 'Last 4 days', + 'settings.activity.metric.last30': '30 days', + 'settings.activity.metric.streak': 'Streak', + 'settings.activity.metric.bestStreak': 'best {days} days', + 'settings.activity.heatmapLabel': 'Token usage by day', + 'settings.activity.count.sessionOne': '{count} session', + 'settings.activity.count.sessionOther': '{count} sessions', + 'settings.activity.count.dayOne': '{count} day', + 'settings.activity.count.dayOther': '{count} days', + 'settings.activity.weekday.mon': 'Mon', + 'settings.activity.weekday.wed': 'Wed', + 'settings.activity.weekday.fri': 'Fri', + 'settings.activity.selectedDay': 'Selected day', + 'settings.activity.sessions': 'Sessions', + 'settings.activity.tokens': 'Tokens', + 'settings.activity.messages': 'Messages', + 'settings.activity.tools': 'Tools', + 'settings.activity.less': 'Less', + 'settings.activity.more': 'More', + // Settings > Terminal 'settings.terminal.title': 'Terminal', 'settings.terminal.description': 'Run host-machine commands for plugin, skill, and MCP setup. The desktop app includes claude-haha; replace documented claude with claude-haha , for example: claude-haha plugin install ... or claude-haha mcp add ...', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 3f1be41b..1e0bc841 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -91,6 +91,7 @@ export const zh: Record = { 'settings.title': '设置', 'settings.tab.providers': '服务商', 'settings.tab.permissions': '权限', + 'settings.tab.activity': 'Token 用量', 'settings.tab.general': '通用', 'settings.tab.terminal': '终端', 'settings.tab.skills': '技能', @@ -98,6 +99,31 @@ export const zh: Record = { 'settings.tab.plugins': '插件', 'settings.tab.diagnostics': '诊断', + // Settings > Usage + 'settings.activity.title': 'Token 用量', + 'settings.activity.subtitleLoading': '基于本机 Claude Code CLI 会话记录统计', + 'settings.activity.metric.yesterday': '昨天', + 'settings.activity.metric.today': '今天', + 'settings.activity.metric.last4': '近 4 天', + 'settings.activity.metric.last30': '30 天', + 'settings.activity.metric.streak': '连续活跃', + 'settings.activity.metric.bestStreak': '最长 {days} 天', + 'settings.activity.heatmapLabel': '按天统计的 Token 用量', + 'settings.activity.count.sessionOne': '{count} 次会话', + 'settings.activity.count.sessionOther': '{count} 次会话', + 'settings.activity.count.dayOne': '{count} 天', + 'settings.activity.count.dayOther': '{count} 天', + 'settings.activity.weekday.mon': '周一', + 'settings.activity.weekday.wed': '周三', + 'settings.activity.weekday.fri': '周五', + 'settings.activity.selectedDay': '选中日期', + 'settings.activity.sessions': '会话', + 'settings.activity.tokens': 'Token', + 'settings.activity.messages': '消息', + 'settings.activity.tools': '工具', + 'settings.activity.less': '少', + 'settings.activity.more': '多', + // Settings > Terminal 'settings.terminal.title': '终端', 'settings.terminal.description': '直接运行宿主机命令,用于安装插件、Skills、MCP 等扩展。桌面端已内置 claude-haha;文档里的 claude <参数> 可替换成 claude-haha <参数>,例如 claude-haha plugin install ... 或 claude-haha mcp add ...', diff --git a/desktop/src/pages/ActivitySettings.test.tsx b/desktop/src/pages/ActivitySettings.test.tsx new file mode 100644 index 00000000..a21bbbca --- /dev/null +++ b/desktop/src/pages/ActivitySettings.test.tsx @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, fireEvent, render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' + +import { ActivitySettings } from './ActivitySettings' +import { useSettingsStore } from '../stores/settingsStore' + +const { getStatsMock } = vi.hoisted(() => ({ + getStatsMock: vi.fn(), +})) + +vi.mock('../api/activityStats', () => ({ + activityStatsApi: { + getStats: getStatsMock, + }, +})) + +const activityResponse = { + range: 'all', + generatedAt: '2026-05-09T12:00:00.000Z', + totalSessions: 52, + totalMessages: 900, + totalDays: 365, + activeDays: 20, + streaks: { + currentStreak: 9, + longestStreak: 18, + currentStreakStart: '2026-05-01', + longestStreakStart: '2026-03-01', + longestStreakEnd: '2026-03-18', + }, + dailyActivity: [ + { date: '2026-04-20', sessionCount: 38, messageCount: 420, toolCallCount: 160 }, + { date: '2026-05-07', sessionCount: 2, messageCount: 30, toolCallCount: 12 }, + { date: '2026-05-09', sessionCount: 4, messageCount: 58, toolCallCount: 21 }, + ], + dailyModelTokens: [ + { date: '2026-04-20', tokensByModel: { 'claude-sonnet': 2_672_000 } }, + { date: '2026-05-07', tokensByModel: { 'claude-sonnet': 64_000 } }, + { date: '2026-05-09', tokensByModel: { 'claude-sonnet': 128_000 } }, + ], + longestSession: null, + modelUsage: {}, + firstSessionDate: '2025-06-01T10:00:00.000Z', + lastSessionDate: '2026-05-09T11:00:00.000Z', + peakActivityDay: '2026-04-20', + peakActivityHour: 14, + totalSpeculationTimeSavedMs: 0, +} + +async function flushActivityLoad() { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +describe('ActivitySettings', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-05-09T12:00:00')) + getStatsMock.mockReset() + getStatsMock.mockResolvedValue(activityResponse) + useSettingsStore.setState({ locale: 'en' }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('renders summary metrics and a GitHub-style trailing heatmap without future days', async () => { + render() + + await flushActivityLoad() + + expect(getStatsMock).toHaveBeenCalledWith('all') + + expect(screen.getByText('Token usage')).toBeInTheDocument() + expect(screen.getByText('2025.05 - 2026.05')).toBeInTheDocument() + expect(screen.getByText('Based on local Claude Code CLI session transcripts')).toBeInTheDocument() + expect(screen.getByText('Yesterday')).toBeInTheDocument() + expect(screen.getByText('Today')).toBeInTheDocument() + expect(screen.getByText('30 days')).toBeInTheDocument() + expect(screen.getByText('0 tokens')).toBeInTheDocument() + expect(screen.getByText('128K tokens')).toBeInTheDocument() + expect(screen.getByText('2.9M tokens')).toBeInTheDocument() + expect(screen.getAllByText('May').length).toBeGreaterThan(0) + expect(screen.queryByText('5月')).not.toBeInTheDocument() + expect( + screen.getByText('Today').compareDocumentPosition(screen.getByText('Yesterday')) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() + + const todayCell = screen.getByRole('gridcell', { + name: /May 9, 2026: 4 sessions, 128K tokens/i, + }) + expect(todayCell).toBeInTheDocument() + expect(screen.queryByRole('gridcell', { name: /May 10, 2026/i })).not.toBeInTheDocument() + }) + + it('shows a compact hover preview without a persistent selected-day panel', async () => { + render() + + await flushActivityLoad() + + const todayCell = screen.getByRole('gridcell', { + name: /May 9, 2026: 4 sessions, 128K tokens/i, + }) + + fireEvent.mouseEnter(todayCell) + const tooltip = screen.getByRole('tooltip') + expect(tooltip).toHaveTextContent('May 9, 2026') + expect(tooltip).toHaveTextContent('4 sessions · 128K tokens') + expect(tooltip).not.toHaveTextContent(/messages|tools/i) + expect(screen.queryByText('Selected day')).not.toBeInTheDocument() + }) +}) diff --git a/desktop/src/pages/ActivitySettings.tsx b/desktop/src/pages/ActivitySettings.tsx new file mode 100644 index 00000000..3cbd71e3 --- /dev/null +++ b/desktop/src/pages/ActivitySettings.tsx @@ -0,0 +1,433 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { activityStatsApi, type ActivityStatsResponse, type DailyActivity } from '../api/activityStats' +import { type Locale, useTranslation } from '../i18n' +import { useSettingsStore } from '../stores/settingsStore' + +type HeatmapDay = { + date: string + sessionCount: number + messageCount: number + toolCallCount: number + tokens: number + level: number +} + +type SummaryMetric = { + label: string + value: string + detail?: string +} + +const WEEK_COUNT = 52 +const WEEKDAY_LABEL_KEYS = [ + 'settings.activity.weekday.mon', + 'settings.activity.weekday.wed', + 'settings.activity.weekday.fri', +] as const +const HEAT_CELL_GAP = 3 +const HEAT_LABEL_WIDTH = 38 +const HEAT_CELL_MIN = 6 +const HEAT_CELL_MAX = 22 +const TOOLTIP_WIDTH = 172 +const HEAT_COLORS = [ + 'var(--color-surface-container)', + '#f5d4c4', + '#eea47f', + '#df6b47', + '#bd432c', +] +const DATE_LOCALES: Record = { + en: 'en-US', + zh: 'zh-CN', +} + +function localDateKey(date: Date) { + const year = date.getFullYear() + const month = `${date.getMonth() + 1}`.padStart(2, '0') + const day = `${date.getDate()}`.padStart(2, '0') + return `${year}-${month}-${day}` +} + +function parseLocalDate(dateKey: string) { + return new Date(`${dateKey}T00:00:00`) +} + +function addDays(date: Date, days: number) { + const next = new Date(date) + next.setDate(next.getDate() + days) + return next +} + +function startOfWeek(date: Date) { + const next = new Date(date) + next.setHours(0, 0, 0, 0) + next.setDate(next.getDate() - next.getDay()) + return next +} + +function formatDateLabel(dateKey: string, locale: Locale) { + return parseLocalDate(dateKey).toLocaleDateString(DATE_LOCALES[locale], { + month: 'short', + day: 'numeric', + year: 'numeric', + }) +} + +function formatMonthKey(dateKey: string) { + const date = parseLocalDate(dateKey) + return `${date.getFullYear()}.${`${date.getMonth() + 1}`.padStart(2, '0')}` +} + +function formatDateRange(start: string, end: string) { + return `${formatMonthKey(start)} - ${formatMonthKey(end)}` +} + +function formatTokens(tokens: number) { + if (tokens >= 1_000_000_000) return `${(tokens / 1_000_000_000).toFixed(tokens >= 10_000_000_000 ? 0 : 1)}B` + if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(tokens >= 10_000_000 ? 0 : 1)}M` + if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}K` + return `${tokens}` +} + +function formatSessionCount(value: number, t: ReturnType) { + return t(value === 1 ? 'settings.activity.count.sessionOne' : 'settings.activity.count.sessionOther', { count: value }) +} + +function calculateHeatCellSize(width: number) { + const available = width - HEAT_LABEL_WIDTH - (WEEK_COUNT - 1) * HEAT_CELL_GAP + return Math.max(HEAT_CELL_MIN, Math.min(HEAT_CELL_MAX, Math.floor(available / WEEK_COUNT))) +} + +function sumDailyUsage(days: HeatmapDay[]) { + return days.reduce( + (sum, day) => ({ + sessions: sum.sessions + day.sessionCount, + tokens: sum.tokens + day.tokens, + }), + { sessions: 0, tokens: 0 }, + ) +} + +function getDailyTokenMap(stats: ActivityStatsResponse | null) { + const map = new Map() + for (const day of stats?.dailyModelTokens ?? []) { + const total = Object.values(day.tokensByModel).reduce((sum, tokens) => sum + tokens, 0) + map.set(day.date, total) + } + return map +} + +function getHeatLevel(day: DailyActivity | undefined, tokens: number, maxScore: number) { + const sessionCount = day?.sessionCount ?? 0 + if (sessionCount === 0 && tokens === 0) return 0 + if (maxScore <= 0) return 1 + + const score = sessionCount * 3 + Math.ceil(tokens / 50_000) + const ratio = score / maxScore + if (ratio >= 0.78) return 4 + if (ratio >= 0.5) return 3 + if (ratio >= 0.24) return 2 + return 1 +} + +function buildHeatmapDays(stats: ActivityStatsResponse | null) { + const today = new Date() + today.setHours(0, 0, 0, 0) + + const finalWeekStart = startOfWeek(today) + const start = addDays(finalWeekStart, -(WEEK_COUNT - 1) * 7) + const activityMap = new Map((stats?.dailyActivity ?? []).map((day) => [day.date, day])) + const tokenMap = getDailyTokenMap(stats) + + const scores: number[] = [] + for (let cursor = new Date(start); cursor <= today; cursor = addDays(cursor, 1)) { + const dateKey = localDateKey(cursor) + const day = activityMap.get(dateKey) + const tokens = tokenMap.get(dateKey) ?? 0 + scores.push((day?.sessionCount ?? 0) * 3 + Math.ceil(tokens / 50_000)) + } + const maxScore = Math.max(...scores, 0) + + const days: HeatmapDay[] = [] + for (let cursor = new Date(start); cursor <= today; cursor = addDays(cursor, 1)) { + const dateKey = localDateKey(cursor) + const day = activityMap.get(dateKey) + const tokens = tokenMap.get(dateKey) ?? 0 + days.push({ + date: dateKey, + sessionCount: day?.sessionCount ?? 0, + messageCount: day?.messageCount ?? 0, + toolCallCount: day?.toolCallCount ?? 0, + tokens, + level: getHeatLevel(day, tokens, maxScore), + }) + } + + return days +} + +function buildMonthLabels(days: HeatmapDay[], locale: Locale) { + if (days.length === 0) return [] + const labels: Array<{ week: number; label: string }> = [] + const firstDay = days[0] + const lastDay = days[days.length - 1] + if (!firstDay || !lastDay) return labels + + const firstDate = parseLocalDate(firstDay.date) + const lastDate = parseLocalDate(lastDay.date) + let previousMonth = -1 + + for (let week = 0; week < WEEK_COUNT; week += 1) { + const weekDate = addDays(firstDate, week * 7) + if (weekDate > lastDate) break + if (weekDate.getMonth() !== previousMonth) { + labels.push({ + week, + label: weekDate.toLocaleDateString(DATE_LOCALES[locale], { month: 'short' }), + }) + previousMonth = weekDate.getMonth() + } + } + + return labels +} + +export function ActivitySettings() { + const t = useTranslation() + const locale = useSettingsStore((state) => state.locale) + const heatmapMeasureRef = useRef(null) + const [stats, setStats] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const [hoveredDate, setHoveredDate] = useState(null) + const [focusedDate, setFocusedDate] = useState(null) + const [heatCellSize, setHeatCellSize] = useState(10) + + useEffect(() => { + let cancelled = false + setIsLoading(true) + setError(null) + + activityStatsApi.getStats('all') + .then((nextStats) => { + if (cancelled) return + setStats(nextStats) + }) + .catch((err) => { + if (cancelled) return + setError(err instanceof Error ? err.message : String(err)) + }) + .finally(() => { + if (!cancelled) setIsLoading(false) + }) + + return () => { + cancelled = true + } + }, []) + + useEffect(() => { + if (isLoading || error) return + const element = heatmapMeasureRef.current + if (!element) return + + const updateCellSize = () => { + const nextSize = calculateHeatCellSize(element.clientWidth) + setHeatCellSize((current) => (current === nextSize ? current : nextSize)) + } + + updateCellSize() + + if (typeof ResizeObserver !== 'undefined') { + const observer = new ResizeObserver(updateCellSize) + observer.observe(element) + return () => observer.disconnect() + } + + window.addEventListener('resize', updateCellSize) + return () => window.removeEventListener('resize', updateCellSize) + }, [error, isLoading]) + + const days = useMemo(() => buildHeatmapDays(stats), [stats]) + const monthLabels = useMemo(() => buildMonthLabels(days, locale), [days, locale]) + const today = days.length > 0 ? days[days.length - 1] : null + const activeTooltipDate = hoveredDate ?? focusedDate + const tooltipDay = days.find((day) => day.date === activeTooltipDate) ?? null + const tooltipIndex = tooltipDay ? days.findIndex((day) => day.date === tooltipDay.date) : -1 + const heatGridWidth = WEEK_COUNT * heatCellSize + (WEEK_COUNT - 1) * HEAT_CELL_GAP + const heatGridHeight = 7 * heatCellSize + 6 * HEAT_CELL_GAP + const heatmapWidth = HEAT_LABEL_WIDTH + heatGridWidth + const tooltipStyle = tooltipIndex >= 0 + ? { + left: Math.max( + HEAT_LABEL_WIDTH, + Math.min( + heatmapWidth - TOOLTIP_WIDTH, + HEAT_LABEL_WIDTH + Math.floor(tooltipIndex / 7) * (heatCellSize + HEAT_CELL_GAP) - 52, + ), + ), + top: Math.max(28, 30 + (tooltipIndex % 7) * (heatCellSize + HEAT_CELL_GAP) - 50), + } + : undefined + const dateRange = days.length > 0 && days[0] && today ? formatDateRange(days[0].date, today.date) : '' + const yesterdayDate = today ? localDateKey(addDays(parseLocalDate(today.date), -1)) : null + const yesterday = days.find((day) => day.date === yesterdayDate) ?? null + const last30Usage = sumDailyUsage(days.slice(-30)) + + const metrics: SummaryMetric[] = [ + { + label: t('settings.activity.metric.today'), + value: today ? `${formatTokens(today.tokens)} tokens` : '0 tokens', + detail: today ? formatSessionCount(today.sessionCount, t) : formatSessionCount(0, t), + }, + { + label: t('settings.activity.metric.yesterday'), + value: `${formatTokens(yesterday?.tokens ?? 0)} tokens`, + detail: formatSessionCount(yesterday?.sessionCount ?? 0, t), + }, + { + label: t('settings.activity.metric.last30'), + value: `${formatTokens(last30Usage.tokens)} tokens`, + detail: formatSessionCount(last30Usage.sessions, t), + }, + ] + + return ( +
+
+
+

{t('settings.activity.title')}

+
+ {dateRange &&
{dateRange}
} +
{t('settings.activity.subtitleLoading')}
+
+
+ +
+ {metrics.map((metric) => ( +
+
{metric.label}
+
{metric.value}
+ {metric.detail &&
{metric.detail}
} +
+ ))} +
+
+ +
+ {isLoading ? ( +
+ progress_activity + {t('common.loading')} +
+ ) : error ? ( +
+ {error} +
+ ) : ( + <> +
+
+
+ {monthLabels.map((month) => ( +
+ {month.label} +
+ ))} +
+ +
+
+
{t(WEEKDAY_LABEL_KEYS[0])}
+
{t(WEEKDAY_LABEL_KEYS[1])}
+
{t(WEEKDAY_LABEL_KEYS[2])}
+
+ +
setHoveredDate(null)} + > + {days.map((day) => { + const isSelected = activeTooltipDate === day.date + const tooltipId = `activity-day-tooltip-${day.date}` + return ( +
+
+ + {tooltipDay && ( + + )} +
+
+ +
+ {t('settings.activity.less')} + {HEAT_COLORS.map((color) => ( +
+ + )} +
+
+ ) +} diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 909af0c2..6b32c9c1 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -27,6 +27,7 @@ import { ComputerUseSettings } from './ComputerUseSettings' import { McpSettings } from './McpSettings' import { TerminalSettings } from './TerminalSettings' import { DiagnosticsSettings } from './DiagnosticsSettings' +import { ActivitySettings } from './ActivitySettings' import { useUIStore, type SettingsTab } from '../stores/uiStore' import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin' import { useUpdateStore } from '../stores/updateStore' @@ -76,6 +77,7 @@ export function Settings() { setActiveTab('skills')} /> setActiveTab('plugins')} /> setActiveTab('computerUse')} /> + setActiveTab('activity')} /> setActiveTab('diagnostics')} />
@@ -87,6 +89,7 @@ export function Settings() {
{activeTab === 'providers' && } {activeTab === 'permissions' && } + {activeTab === 'activity' && } {activeTab === 'general' && } {activeTab === 'adapters' && } {activeTab === 'terminal' && } diff --git a/desktop/src/stores/uiStore.ts b/desktop/src/stores/uiStore.ts index 0d9630b8..1e4f8cfc 100644 --- a/desktop/src/stores/uiStore.ts +++ b/desktop/src/stores/uiStore.ts @@ -31,6 +31,7 @@ export type Toast = { export type SettingsTab = | 'providers' | 'permissions' + | 'activity' | 'general' | 'adapters' | 'terminal' diff --git a/src/server/__tests__/settings.test.ts b/src/server/__tests__/settings.test.ts index 035d69a6..1bc3bbc1 100644 --- a/src/server/__tests__/settings.test.ts +++ b/src/server/__tests__/settings.test.ts @@ -2,7 +2,7 @@ * Unit tests for Settings, Models, and Status APIs */ -import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'bun:test' import * as fs from 'fs/promises' import * as path from 'path' import * as os from 'os' @@ -702,3 +702,59 @@ describe('Status API', () => { expect(res.status).toBe(404) }) }) + +// ============================================================================= +// Activity Stats API +// ============================================================================= + +describe('Activity Stats API', () => { + let handleApiRequest: typeof import('../router.js').handleApiRequest + + beforeAll(async () => { + ;({ handleApiRequest } = await import('../router.js')) + }) + + beforeEach(async () => { + await setup() + }) + + afterEach(teardown) + + it('GET /api/activity-stats should default to the all range', async () => { + const { req, url } = makeRequest('GET', '/api/activity-stats') + const res = await handleApiRequest(req, url) + + expect(res.status).toBe(200) + + const body = await res.json() + expect(body.range).toBe('all') + expect(body.stats.totalSessions).toBe(0) + expect(new Date(body.generatedAt).toString()).not.toBe('Invalid Date') + }) + + it('GET /api/activity-stats/:range should return stats for supported ranges', async () => { + for (const range of ['7d', '30d', 'all'] as const) { + const { req, url } = makeRequest('GET', `/api/activity-stats/${range}`) + const res = await handleApiRequest(req, url) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.range).toBe(range) + expect(body.stats).toBeDefined() + } + }) + + it('should reject non-GET methods', async () => { + const { req, url } = makeRequest('POST', '/api/activity-stats') + const res = await handleApiRequest(req, url) + + expect(res.status).toBe(405) + }) + + it('should reject unknown activity stats ranges', async () => { + const { req, url } = makeRequest('GET', '/api/activity-stats/90d') + const res = await handleApiRequest(req, url) + + expect(res.status).toBe(400) + }) +}) diff --git a/src/server/api/activityStats.ts b/src/server/api/activityStats.ts new file mode 100644 index 00000000..9829dfb3 --- /dev/null +++ b/src/server/api/activityStats.ts @@ -0,0 +1,43 @@ +import { + aggregateClaudeCodeStatsForRange, + type StatsDateRange, +} from '../../utils/stats.js' +import { ApiError, errorResponse } from '../middleware/errorHandler.js' + +const VALID_RANGES = new Set(['7d', '30d', 'all']) + +export async function handleActivityStatsApi( + req: Request, + _url: URL, + segments: string[], +): Promise { + try { + if (req.method !== 'GET') { + throw methodNotAllowed(req.method) + } + + const requestedRange = segments[2] + const range: StatsDateRange = requestedRange === undefined ? 'all' : parseRange(requestedRange) + const stats = await aggregateClaudeCodeStatsForRange(range) + + return Response.json({ + stats, + range, + generatedAt: new Date().toISOString(), + }) + } catch (error) { + return errorResponse(error) + } +} + +function parseRange(range: string): StatsDateRange { + if (VALID_RANGES.has(range as StatsDateRange)) { + return range as StatsDateRange + } + + throw ApiError.badRequest(`Unknown activity stats range: ${range}`) +} + +function methodNotAllowed(method: string): ApiError { + return new ApiError(405, `Method ${method} not allowed`, 'METHOD_NOT_ALLOWED') +} diff --git a/src/server/router.ts b/src/server/router.ts index 614a8880..5db9be15 100644 --- a/src/server/router.ts +++ b/src/server/router.ts @@ -23,6 +23,7 @@ import { handleMcpApi } from './api/mcp.js' import { handleDiagnosticsApi } from './api/diagnostics.js' import { handleDoctorApi } from './api/doctor.js' import { handleH5AccessApi } from './api/h5-access.js' +import { handleActivityStatsApi } from './api/activityStats.js' export async function handleApiRequest(req: Request, url: URL): Promise { const path = url.pathname @@ -103,6 +104,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise JSON.stringify(entry)).join('\n')}\n`, + 'utf-8', + ) +} + +function projectFile(sessionId: string): string { + return join(tmpConfigDir, 'projects', 'test-project', `${sessionId}.jsonl`) +} + +function subagentFile(sessionId: string, agentId: string): string { + return join( + tmpConfigDir, + 'projects', + 'test-project', + sessionId, + 'subagents', + `agent-${agentId}.jsonl`, + ) +} + +function totalForDate( + dailyModelTokens: Array<{ + date: string + tokensByModel: { [model: string]: number } + }>, + date: string, +): number { + return Object.values( + dailyModelTokens.find(day => day.date === date)?.tokensByModel ?? {}, + ).reduce((sum, tokens) => sum + tokens, 0) +} + +describe('activity stats token accounting', () => { + beforeEach(async () => { + originalConfigDir = process.env.CLAUDE_CONFIG_DIR + tmpConfigDir = await mkdtemp(join(tmpdir(), 'cc-haha-stats-')) + process.env.CLAUDE_CONFIG_DIR = tmpConfigDir + }) + + afterEach(async () => { + if (originalConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = originalConfigDir + } + await rm(tmpConfigDir, { recursive: true, force: true }) + }) + + it('buckets assistant usage by message date and includes cache tokens', async () => { + const sessionStart = dateKey(-2) + const nextDay = dateKey(-1) + + await writeJsonl(projectFile('cross-midnight'), [ + userEntry('user-1', at(sessionStart, '23:55:00')), + assistantEntry( + 'assistant-1', + at(sessionStart, '23:58:00'), + { input_tokens: 1, output_tokens: 2 }, + { parentUuid: 'user-1' }, + ), + assistantEntry( + 'assistant-2', + at(nextDay, '00:05:00'), + { + input_tokens: 10, + output_tokens: 20, + cache_read_input_tokens: 30, + cache_creation_input_tokens: 40, + }, + { parentUuid: 'assistant-1' }, + ), + ]) + + const stats = await aggregateClaudeCodeStatsForRange('all') + + expect(totalForDate(stats.dailyModelTokens, sessionStart)).toBe(3) + expect(totalForDate(stats.dailyModelTokens, nextDay)).toBe(100) + expect(stats.dailyActivity.find(day => day.date === sessionStart)).toMatchObject({ + sessionCount: 1, + }) + expect(stats.dailyActivity.find(day => day.date === nextDay)).toMatchObject({ + sessionCount: 1, + }) + expect(stats.modelUsage['claude-test']).toMatchObject({ + inputTokens: 11, + outputTokens: 22, + cacheReadInputTokens: 30, + cacheCreationInputTokens: 40, + }) + }) + + it('counts subagent tokens without counting subagent transcripts as sessions', async () => { + const today = dateKey(0) + + await writeJsonl(projectFile('parent-session'), [ + userEntry('parent-user', at(today, '10:00:00')), + assistantEntry( + 'parent-assistant', + at(today, '10:01:00'), + { input_tokens: 5, output_tokens: 5 }, + { parentUuid: 'parent-user' }, + ), + ]) + await writeJsonl(subagentFile('parent-session', '001'), [ + userEntry('agent-user', at(today, '10:02:00'), true), + assistantEntry( + 'agent-assistant', + at(today, '10:03:00'), + { + input_tokens: 7, + output_tokens: 8, + cache_read_input_tokens: 9, + }, + { isSidechain: true, parentUuid: 'agent-user' }, + ), + ]) + + const stats = await aggregateClaudeCodeStatsForRange('all') + + expect(stats.totalSessions).toBe(1) + expect(stats.dailyActivity.find(day => day.date === today)).toMatchObject({ + sessionCount: 1, + messageCount: 2, + }) + expect(totalForDate(stats.dailyModelTokens, today)).toBe(34) + }) + + it('keeps resumed old sessions in range token totals with active daily session counts', async () => { + const oldDate = dateKey(-50) + const inRangeDate = dateKey(-1) + + await writeJsonl(projectFile('resumed-old-session'), [ + userEntry('old-user', at(oldDate, '08:00:00')), + assistantEntry( + 'recent-assistant', + at(inRangeDate, '09:00:00'), + { input_tokens: 12, output_tokens: 13 }, + { parentUuid: 'old-user' }, + ), + ]) + + const stats = await aggregateClaudeCodeStatsForRange('30d') + + expect(stats.totalSessions).toBe(0) + expect(totalForDate(stats.dailyModelTokens, inRangeDate)).toBe(25) + expect(totalForDate(stats.dailyModelTokens, oldDate)).toBe(0) + expect(stats.dailyActivity.find(day => day.date === inRangeDate)).toMatchObject({ + sessionCount: 1, + }) + expect(stats.dailyActivity.find(day => day.date === oldDate)).toBeUndefined() + }) + + it('does not include future transcript timestamps in bounded ranges', async () => { + const today = dateKey(0) + const future = dateKey(3) + + await writeJsonl(projectFile('future-session'), [ + userEntry('today-user', at(today, '10:00:00')), + assistantEntry( + 'future-assistant', + at(future, '10:00:00'), + { input_tokens: 100, output_tokens: 100 }, + { parentUuid: 'today-user' }, + ), + ]) + + const stats = await aggregateClaudeCodeStatsForRange('7d') + + expect(totalForDate(stats.dailyModelTokens, future)).toBe(0) + }) + + it('invalidates pre-v5 stats caches because daily activity accounting changed', async () => { + await mkdir(tmpConfigDir, { recursive: true }) + await writeFile( + join(tmpConfigDir, 'stats-cache.json'), + JSON.stringify({ + version: 3, + lastComputedDate: dateKey(-1), + dailyActivity: [{ date: dateKey(-1), messageCount: 1, sessionCount: 1, toolCallCount: 0 }], + dailyModelTokens: [{ date: dateKey(-1), tokensByModel: { stale: 1 } }], + modelUsage: {}, + totalSessions: 1, + totalMessages: 1, + longestSession: null, + firstSessionDate: null, + hourCounts: {}, + totalSpeculationTimeSavedMs: 0, + }), + 'utf-8', + ) + + const cache = await loadStatsCache() + + expect(STATS_CACHE_VERSION).toBe(5) + expect(cache.dailyModelTokens).toEqual([]) + expect(cache.totalSessions).toBe(0) + }) +}) diff --git a/src/utils/stats.ts b/src/utils/stats.ts index 7b8434ae..f5ee0ed6 100644 --- a/src/utils/stats.ts +++ b/src/utils/stats.ts @@ -32,7 +32,7 @@ export type DailyActivity = { export type DailyModelTokens = { date: string // YYYY-MM-DD format - tokensByModel: { [modelName: string]: number } // total tokens (input + output) per model + tokensByModel: { [modelName: string]: number } // total tokens (input + output + cache read + cache creation) per model } export type StreakInfo = { @@ -110,6 +110,40 @@ type ProcessOptions = { toDate?: string } +type UsageLike = { + input_tokens?: number + output_tokens?: number + cache_read_input_tokens?: number + cache_creation_input_tokens?: number +} + +function getTotalUsageTokens(usage: UsageLike): number { + return ( + (usage.input_tokens || 0) + + (usage.output_tokens || 0) + + (usage.cache_read_input_tokens || 0) + + (usage.cache_creation_input_tokens || 0) + ) +} + +function isDateInRange( + date: string, + fromDate?: string, + toDate?: string, +): boolean { + if (fromDate && isDateBefore(date, fromDate)) return false + if (toDate && isDateBefore(toDate, date)) return false + return true +} + +function getMessageDateKey( + message: Pick, +): string | null { + const messageTimestamp = new Date(message.timestamp) + if (isNaN(messageTimestamp.getTime())) return null + return toDateString(messageTimestamp) +} + /** * Process session files and extract stats. * Can filter by date range. @@ -122,6 +156,7 @@ async function processSessionFiles( const fs = getFsImplementation() const dailyActivityMap = new Map() + const dailySessionIdsMap = new Map>() const dailyModelTokensMap = new Map() const sessions: SessionStats[] = [] const hourCounts = new Map() @@ -134,6 +169,30 @@ async function processSessionFiles( // Track parent sessions that already recorded a shot count (dedup across subagents) const sessionsWithShotCount = new Set() + const getDailyActivity = (date: string): DailyActivity => { + let activity = dailyActivityMap.get(date) + if (!activity) { + activity = { + date, + messageCount: 0, + sessionCount: 0, + toolCallCount: 0, + } + dailyActivityMap.set(date, activity) + } + return activity + } + + const markSessionActiveOnDate = (date: string, parentSessionId: string) => { + let sessionIds = dailySessionIdsMap.get(date) + if (!sessionIds) { + sessionIds = new Set() + dailySessionIdsMap.set(date, sessionIds) + } + sessionIds.add(parentSessionId) + getDailyActivity(date) + } + // Process session files in parallel batches for better performance const BATCH_SIZE = 20 for (let i = 0; i < sessionFiles.length; i += BATCH_SIZE) { @@ -143,7 +202,6 @@ async function processSessionFiles( try { // If we have a fromDate filter, skip files that haven't been modified since then if (fromDate) { - let fileSize = 0 try { const fileStat = await fs.stat(sessionFile) const fileModifiedDate = toDateString(fileStat.mtime) @@ -155,24 +213,9 @@ async function processSessionFiles( skipped: true, } } - fileSize = fileStat.size } catch { // If we can't stat the file, try to read it anyway } - // For large files, peek at the session start date before reading everything. - // Sessions that pass the mtime filter but started before fromDate are skipped - // (e.g. a month-old session resumed today gets a new mtime write but old start date). - if (fileSize > 65536) { - const startDate = await readSessionStartDate(sessionFile) - if (startDate && isDateBefore(startDate, fromDate)) { - return { - sessionFile, - entries: null, - error: null, - skipped: true, - } - } - } } const entries = await readJSONLFile(sessionFile) return { sessionFile, entries, error: null, skipped: false } @@ -207,15 +250,14 @@ async function processSessionFiles( // Subagent transcripts mark all messages as sidechain. We still want // their token usage counted, but not as separate sessions. const isSubagentFile = sessionFile.includes(`${sep}subagents${sep}`) + const parentSessionId = isSubagentFile + ? basename(dirname(dirname(sessionFile))) + : sessionId // Extract shot count from PR attribution in gh pr create calls (ant-only) // This must run before the sidechain filter since subagent transcripts // mark all messages as sidechain if (feature('SHOT_STATS') && shotDistributionMap) { - const parentSessionId = isSubagentFile - ? basename(dirname(dirname(sessionFile))) - : sessionId - if (!sessionsWithShotCount.has(parentSessionId)) { const shotCount = extractShotCountFromMessages(messages) if (shotCount !== null) { @@ -253,21 +295,12 @@ async function processSessionFiles( } const dateKey = toDateString(firstTimestamp) + const includeSessionInRange = isDateInRange(dateKey, fromDate, toDate) - // Apply date filters - if (fromDate && isDateBefore(dateKey, fromDate)) continue - if (toDate && isDateBefore(toDate, dateKey)) continue - - // Track daily activity (use first message date as session date) - const existing = dailyActivityMap.get(dateKey) || { - date: dateKey, - messageCount: 0, - sessionCount: 0, - toolCallCount: 0, - } - - // Subagent files contribute tokens and tool calls, but aren't sessions. - if (!isSubagentFile) { + // Session-level aggregates still represent newly started top-level + // sessions. Daily activity is tracked below by each message date so token + // totals and visible per-day session counts share one date bucket. + if (!isSubagentFile && includeSessionInRange) { const duration = lastTimestamp.getTime() - firstTimestamp.getTime() sessions.push({ @@ -279,28 +312,34 @@ async function processSessionFiles( totalMessages += mainMessages.length - existing.sessionCount++ - existing.messageCount += mainMessages.length - const hour = firstTimestamp.getHours() hourCounts.set(hour, (hourCounts.get(hour) || 0) + 1) } - if (!isSubagentFile || dailyActivityMap.has(dateKey)) { - dailyActivityMap.set(dateKey, existing) - } - // Process messages for tool usage and model stats for (const message of mainMessages) { + const messageDateKey = getMessageDateKey(message) + const includeMessageInRange = + messageDateKey !== null && + isDateInRange(messageDateKey, fromDate, toDate) + + if (includeMessageInRange && messageDateKey !== null) { + markSessionActiveOnDate(messageDateKey, parentSessionId) + if (!isSubagentFile) { + getDailyActivity(messageDateKey).messageCount++ + } + } + if (message.type === 'assistant') { const content = message.message?.content - if (Array.isArray(content)) { + if ( + includeMessageInRange && + messageDateKey !== null && + Array.isArray(content) + ) { for (const block of content) { if (block.type === 'tool_use') { - const activity = dailyActivityMap.get(dateKey) - if (activity) { - activity.toolCallCount++ - } + getDailyActivity(messageDateKey).toolCallCount++ } } } @@ -315,6 +354,10 @@ async function processSessionFiles( continue } + if (!includeMessageInRange || messageDateKey === null) { + continue + } + if (!modelUsageAgg[model]) { modelUsageAgg[model] = { inputTokens: 0, @@ -336,12 +379,12 @@ async function processSessionFiles( usage.cache_creation_input_tokens || 0 // Track daily tokens per model - const totalTokens = - (usage.input_tokens || 0) + (usage.output_tokens || 0) + const totalTokens = getTotalUsageTokens(usage) if (totalTokens > 0) { - const dayTokens = dailyModelTokensMap.get(dateKey) || {} + const tokenDateKey = messageDateKey + const dayTokens = dailyModelTokensMap.get(tokenDateKey) || {} dayTokens[model] = (dayTokens[model] || 0) + totalTokens - dailyModelTokensMap.set(dateKey, dayTokens) + dailyModelTokensMap.set(tokenDateKey, dayTokens) } } } @@ -349,6 +392,10 @@ async function processSessionFiles( } } + for (const [date, sessionIds] of dailySessionIdsMap) { + getDailyActivity(date).sessionCount = sessionIds.size + } + return { dailyActivity: Array.from(dailyActivityMap.values()).sort((a, b) => a.date.localeCompare(b.date), @@ -733,10 +780,12 @@ export async function aggregateClaudeCodeStatsForRange( const fromDate = new Date(today) fromDate.setDate(today.getDate() - daysBack + 1) // +1 to include today const fromDateStr = toDateString(fromDate) + const toDateStr = toDateString(today) // Process session files for the date range const stats = await processSessionFiles(allSessionFiles, { fromDate: fromDateStr, + toDate: toDateStr, }) return processedStatsToClaudeCodeStats(stats) diff --git a/src/utils/statsCache.ts b/src/utils/statsCache.ts index 109268d7..4bafe631 100644 --- a/src/utils/statsCache.ts +++ b/src/utils/statsCache.ts @@ -11,8 +11,8 @@ import { logError } from './log.js' import { jsonParse, jsonStringify } from './slowOperations.js' import type { DailyActivity, DailyModelTokens, SessionStats } from './stats.js' -export const STATS_CACHE_VERSION = 3 -const MIN_MIGRATABLE_VERSION = 1 +export const STATS_CACHE_VERSION = 5 +const MIN_MIGRATABLE_VERSION = 5 const STATS_CACHE_FILENAME = 'stats-cache.json' /** @@ -99,10 +99,10 @@ function getEmptyCache(): PersistedStatsCache { * Migrate an older cache to the current schema. * Returns null if the version is unknown or too old to migrate. * - * Preserves historical aggregates that would otherwise be lost when - * transcript files have already aged out past cleanupPeriodDays. - * Pre-migration days may undercount (e.g. v2 lacked subagent tokens); - * we accept that rather than drop the history. + * 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. */ function migrateStatsCache( parsed: Partial & { version: number },