feat: add activity profile and themed usage heatmap

Add a local profile surface to the desktop token usage page, including avatar
persistence, display-name editing, cumulative usage metrics, and daily, weekly,
and cumulative heatmap views. Align heatmap color ramps and hover treatment with
the supported white, warm, and dark desktop themes.

Constraint: User profile data must stay local under cc-haha desktop preferences.
Rejected: Keep a separate profile edit panel | the requested design favors a compact profile header with modal editing.
Rejected: Use a blue heatmap ramp | it conflicted with the app theme palettes.
Confidence: high
Scope-risk: moderate
Tested: bun run check:desktop
Tested: bun test src/server/__tests__/desktop-ui-preferences.test.ts
Tested: bun run check:persistence-upgrade
Tested: bun run check:coverage
Not-tested: Full bun run verify
This commit is contained in:
程序员阿江(Relakkes) 2026-05-31 00:29:05 +08:00
parent eede5568d2
commit 3b1b96022f
11 changed files with 1486 additions and 104 deletions

View File

@ -0,0 +1,112 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getDefaultBaseUrl, setAuthToken, setBaseUrl } from './client'
import { desktopUiPreferencesApi, getProfileAvatarUrl } from './desktopUiPreferences'
const preferences = {
schemaVersion: 2,
profile: {
displayName: 'cc-haha',
avatarFile: null,
avatarUpdatedAt: null,
},
sidebar: {
projectOrder: [],
pinnedProjects: [],
hiddenProjects: [],
projectOrganization: 'recentProject',
projectSortBy: 'updatedAt',
},
}
describe('desktopUiPreferencesApi', () => {
afterEach(() => {
setAuthToken(null)
setBaseUrl(getDefaultBaseUrl())
vi.restoreAllMocks()
})
it('wraps preference reads and profile updates with the configured API base URL', async () => {
setBaseUrl('http://127.0.0.1:49237')
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock
.mockResolvedValueOnce(new Response(JSON.stringify({ exists: true, preferences }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true, preferences }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true, preferences }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
await expect(desktopUiPreferencesApi.getPreferences()).resolves.toEqual({ exists: true, preferences })
await expect(desktopUiPreferencesApi.updateProfilePreferences({ displayName: 'Local Captain' })).resolves.toEqual({
ok: true,
preferences,
})
await expect(desktopUiPreferencesApi.deleteProfileAvatar()).resolves.toEqual({ ok: true, preferences })
expect(fetchMock).toHaveBeenNthCalledWith(1, 'http://127.0.0.1:49237/api/desktop-ui/preferences', expect.objectContaining({
method: 'GET',
}))
expect(fetchMock).toHaveBeenNthCalledWith(2, 'http://127.0.0.1:49237/api/desktop-ui/preferences/profile', expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ displayName: 'Local Captain' }),
}))
expect(fetchMock).toHaveBeenNthCalledWith(3, 'http://127.0.0.1:49237/api/desktop-ui/preferences/profile/avatar', expect.objectContaining({
method: 'DELETE',
}))
})
it('uploads profile avatars with the file content type and auth token', async () => {
setBaseUrl('http://127.0.0.1:49237')
setAuthToken('h5_avatar_token')
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true, preferences }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
const file = new File([new Uint8Array([137, 80, 78, 71])], 'avatar.png', { type: 'image/png' })
await expect(desktopUiPreferencesApi.uploadProfileAvatar(file)).resolves.toEqual({ ok: true, preferences })
const [url, init] = fetchMock.mock.calls[0]!
expect(url).toBe('http://127.0.0.1:49237/api/desktop-ui/preferences/profile/avatar')
expect(init).toMatchObject({
method: 'PUT',
headers: {
'Content-Type': 'image/png',
Authorization: 'Bearer h5_avatar_token',
},
body: file,
})
})
it('surfaces avatar upload API errors and builds cache-busted avatar URLs', async () => {
setBaseUrl('http://127.0.0.1:49237')
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ message: 'Too large' }), {
status: 413,
headers: { 'Content-Type': 'application/json' },
}))
const file = new File(['bad'], 'avatar.bin', { type: '' })
await expect(desktopUiPreferencesApi.uploadProfileAvatar(file)).rejects.toThrow('Too large')
const [, init] = fetchMock.mock.calls[0]!
expect(init).toMatchObject({
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
},
body: file,
})
expect(getProfileAvatarUrl('2026-05-30T15:37:51.649Z')).toBe(
'http://127.0.0.1:49237/api/desktop-ui/preferences/profile/avatar?v=2026-05-30T15%3A37%3A51.649Z',
)
expect(getProfileAvatarUrl(null)).toBe('http://127.0.0.1:49237/api/desktop-ui/preferences/profile/avatar')
})
})

View File

@ -1,4 +1,4 @@
import { api } from './client'
import { ApiError, api, getApiUrl, getAuthToken } from './client'
export type SidebarProjectPreferences = {
projectOrder: string[]
@ -8,9 +8,16 @@ export type SidebarProjectPreferences = {
projectSortBy: 'createdAt' | 'updatedAt'
}
export type DesktopProfilePreferences = {
displayName: string
avatarFile: string | null
avatarUpdatedAt: string | null
}
export type DesktopUiPreferences = {
schemaVersion: number
sidebar: SidebarProjectPreferences
profile: DesktopProfilePreferences
}
export type DesktopUiPreferencesResponse = {
@ -29,4 +36,49 @@ export const desktopUiPreferencesApi = {
sidebar,
)
},
updateProfilePreferences(profile: Pick<DesktopProfilePreferences, 'displayName'>) {
return api.put<{ ok: true; preferences: DesktopUiPreferences }>(
'/api/desktop-ui/preferences/profile',
profile,
)
},
async uploadProfileAvatar(file: File) {
return uploadProfileAvatar(file)
},
deleteProfileAvatar() {
return api.delete<{ ok: true; preferences: DesktopUiPreferences }>(
'/api/desktop-ui/preferences/profile/avatar',
)
},
}
export function getProfileAvatarUrl(updatedAt: string | null | undefined) {
const suffix = updatedAt ? `?v=${encodeURIComponent(updatedAt)}` : ''
return getApiUrl(`/api/desktop-ui/preferences/profile/avatar${suffix}`)
}
async function uploadProfileAvatar(file: File): Promise<{ ok: true; preferences: DesktopUiPreferences }> {
const headers: Record<string, string> = {
'Content-Type': file.type || 'application/octet-stream',
}
const token = getAuthToken()
if (token) {
headers.Authorization = `Bearer ${token}`
}
const res = await fetch(getApiUrl('/api/desktop-ui/preferences/profile/avatar'), {
method: 'PUT',
headers,
body: file,
})
if (!res.ok) {
const body = await res.json().catch(() => res.text())
throw new ApiError(res.status, body)
}
return res.json() as Promise<{ ok: true; preferences: DesktopUiPreferences }>
}

View File

@ -180,7 +180,36 @@ export const en = {
// Settings > Usage
'settings.activity.title': 'Token usage',
'settings.activity.profileTitle': 'Profile',
'settings.activity.profilePrivacy': 'Local only',
'settings.activity.defaultHandle': 'github.com/NanmiCoder/cc-haha',
'settings.activity.editProfile': 'Edit profile',
'settings.activity.displayName': 'Display name',
'settings.activity.displayNameHelper': 'Only the display name changes here; the second line stays as the project link.',
'settings.activity.avatar': 'Avatar',
'settings.activity.avatarHelper': 'PNG, JPEG, or WebP up to 2 MB.',
'settings.activity.changeAvatar': 'Change avatar',
'settings.activity.removeAvatar': 'Remove avatar',
'settings.activity.saveProfile': 'Save',
'settings.activity.cancelEdit': 'Cancel',
'settings.activity.profileSaved': 'Saved locally',
'settings.activity.profileSaveFailed': 'Could not save profile',
'settings.activity.subtitleLoading': 'Based on local Claude Code CLI session transcripts',
'settings.activity.totalTokens': 'Total tokens',
'settings.activity.peakTokens': 'Peak tokens',
'settings.activity.longestTask': 'Longest task',
'settings.activity.currentStreak': 'Current streak',
'settings.activity.longestStreak': 'Longest streak',
'settings.activity.noDuration': '0m',
'settings.activity.tokenActivity': 'Token Activity',
'settings.activity.mode.daily': 'Daily',
'settings.activity.mode.weekly': 'Weekly',
'settings.activity.mode.cumulative': 'Cumulative',
'settings.activity.modeHelp.daily': "Daily: each square is that day's token usage.",
'settings.activity.modeHelp.weekly': "Weekly: each column is one week's total token usage.",
'settings.activity.modeHelp.cumulative': 'Cumulative: each column is total tokens up to that week.',
'settings.activity.emptyTitle': 'No local usage yet',
'settings.activity.emptyBody': 'Start a CLI or desktop session and completed model replies will appear here.',
'settings.activity.metric.yesterday': 'Yesterday',
'settings.activity.metric.today': 'Today',
'settings.activity.metric.last4': 'Last 4 days',
@ -188,6 +217,9 @@ export const en = {
'settings.activity.metric.streak': 'Streak',
'settings.activity.metric.bestStreak': 'best {days} days',
'settings.activity.heatmapLabel': 'Token usage by day',
'settings.activity.weekRange': '{start} - {end}',
'settings.activity.cumulativeThrough': 'Through {date}',
'settings.activity.tokenValue': '{tokens} Tokens',
'settings.activity.count.sessionOne': '{count} session',
'settings.activity.count.sessionOther': '{count} sessions',
'settings.activity.count.dayOne': '{count} day',

View File

@ -182,7 +182,36 @@ export const zh: Record<TranslationKey, string> = {
// Settings > Usage
'settings.activity.title': 'Token 用量',
'settings.activity.profileTitle': '个人资料',
'settings.activity.profilePrivacy': '仅本地',
'settings.activity.defaultHandle': 'github.com/NanmiCoder/cc-haha',
'settings.activity.editProfile': '编辑个人资料',
'settings.activity.displayName': '显示名称',
'settings.activity.displayNameHelper': '这里只修改主名称,第二行保持为项目链接。',
'settings.activity.avatar': '头像',
'settings.activity.avatarHelper': '支持 PNG、JPEG 或 WebP最大 2 MB。',
'settings.activity.changeAvatar': '更换头像',
'settings.activity.removeAvatar': '移除头像',
'settings.activity.saveProfile': '保存',
'settings.activity.cancelEdit': '取消',
'settings.activity.profileSaved': '已保存到本地',
'settings.activity.profileSaveFailed': '保存个人资料失败',
'settings.activity.subtitleLoading': '基于本机 Claude Code CLI 会话记录统计',
'settings.activity.totalTokens': '累计 Token 数',
'settings.activity.peakTokens': '峰值 Token 数',
'settings.activity.longestTask': '最长任务时长',
'settings.activity.currentStreak': '当前连续天数',
'settings.activity.longestStreak': '最长连续天数',
'settings.activity.noDuration': '0 分钟',
'settings.activity.tokenActivity': 'Token 活动',
'settings.activity.mode.daily': '每日',
'settings.activity.mode.weekly': '每周',
'settings.activity.mode.cumulative': '累计',
'settings.activity.modeHelp.daily': '每日:每个格子表示当天 Token 用量。',
'settings.activity.modeHelp.weekly': '每周:每一列表示这一周的 Token 总量。',
'settings.activity.modeHelp.cumulative': '累计:每一列表示截至该周的累计 Token 总量。',
'settings.activity.emptyTitle': '暂无本地用量',
'settings.activity.emptyBody': '启动 CLI 或桌面会话并完成模型回复后,这里会显示统计。',
'settings.activity.metric.yesterday': '昨天',
'settings.activity.metric.today': '今天',
'settings.activity.metric.last4': '近 4 天',
@ -190,6 +219,9 @@ export const zh: Record<TranslationKey, string> = {
'settings.activity.metric.streak': '连续活跃',
'settings.activity.metric.bestStreak': '最长 {days} 天',
'settings.activity.heatmapLabel': '按天统计的 Token 用量',
'settings.activity.weekRange': '{start} - {end}',
'settings.activity.cumulativeThrough': '截至 {date}',
'settings.activity.tokenValue': '{tokens} Token',
'settings.activity.count.sessionOne': '{count} 次会话',
'settings.activity.count.sessionOther': '{count} 次会话',
'settings.activity.count.dayOne': '{count} 天',

View File

@ -9,12 +9,34 @@ const { getStatsMock } = vi.hoisted(() => ({
getStatsMock: vi.fn(),
}))
const {
getPreferencesMock,
updateProfilePreferencesMock,
uploadProfileAvatarMock,
deleteProfileAvatarMock,
} = vi.hoisted(() => ({
getPreferencesMock: vi.fn(),
updateProfilePreferencesMock: vi.fn(),
uploadProfileAvatarMock: vi.fn(),
deleteProfileAvatarMock: vi.fn(),
}))
vi.mock('../api/activityStats', () => ({
activityStatsApi: {
getStats: getStatsMock,
},
}))
vi.mock('../api/desktopUiPreferences', () => ({
desktopUiPreferencesApi: {
getPreferences: getPreferencesMock,
updateProfilePreferences: updateProfilePreferencesMock,
uploadProfileAvatar: uploadProfileAvatarMock,
deleteProfileAvatar: deleteProfileAvatarMock,
},
getProfileAvatarUrl: () => '/api/desktop-ui/preferences/profile/avatar?mock=1',
}))
const activityResponse = {
range: 'all',
generatedAt: '2026-05-09T12:00:00.000Z',
@ -61,6 +83,82 @@ describe('ActivitySettings', () => {
vi.setSystemTime(new Date('2026-05-09T12:00:00'))
getStatsMock.mockReset()
getStatsMock.mockResolvedValue(activityResponse)
getPreferencesMock.mockReset()
updateProfilePreferencesMock.mockReset()
uploadProfileAvatarMock.mockReset()
deleteProfileAvatarMock.mockReset()
getPreferencesMock.mockResolvedValue({
exists: false,
preferences: {
schemaVersion: 2,
profile: {
displayName: 'cc-haha',
avatarFile: null,
avatarUpdatedAt: null,
},
sidebar: {
projectOrder: [],
pinnedProjects: [],
hiddenProjects: [],
projectOrganization: 'recentProject',
projectSortBy: 'updatedAt',
},
},
})
updateProfilePreferencesMock.mockImplementation((profile) => Promise.resolve({
ok: true,
preferences: {
schemaVersion: 2,
profile: {
displayName: profile.displayName,
avatarFile: null,
avatarUpdatedAt: null,
},
sidebar: {
projectOrder: [],
pinnedProjects: [],
hiddenProjects: [],
projectOrganization: 'recentProject',
projectSortBy: 'updatedAt',
},
},
}))
uploadProfileAvatarMock.mockImplementation(() => Promise.resolve({
ok: true,
preferences: {
schemaVersion: 2,
profile: {
displayName: 'cc-haha',
avatarFile: 'profile/avatar.png',
avatarUpdatedAt: '2026-05-09T12:00:00.000Z',
},
sidebar: {
projectOrder: [],
pinnedProjects: [],
hiddenProjects: [],
projectOrganization: 'recentProject',
projectSortBy: 'updatedAt',
},
},
}))
deleteProfileAvatarMock.mockImplementation(() => Promise.resolve({
ok: true,
preferences: {
schemaVersion: 2,
profile: {
displayName: 'cc-haha',
avatarFile: null,
avatarUpdatedAt: null,
},
sidebar: {
projectOrder: [],
pinnedProjects: [],
hiddenProjects: [],
projectOrganization: 'recentProject',
projectSortBy: 'updatedAt',
},
},
}))
useSettingsStore.setState({ locale: 'en' })
})
@ -75,24 +173,29 @@ describe('ActivitySettings', () => {
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.getByText('cc-haha')).toBeInTheDocument()
expect(screen.getByAltText('cc-haha avatar')).toHaveAttribute('src', '/app-icon.png')
expect(screen.getByAltText('cc-haha avatar')).toHaveClass('scale-[1.28]')
expect(screen.getByRole('link', { name: 'github.com/NanmiCoder/cc-haha' })).toHaveAttribute(
'href',
'https://github.com/NanmiCoder/cc-haha',
)
expect(screen.getByText('Token Activity')).toBeInTheDocument()
expect(screen.getByText('Total tokens')).toBeInTheDocument()
expect(screen.getByText('Peak tokens')).toBeInTheDocument()
expect(screen.getByText('Longest task')).toBeInTheDocument()
expect(screen.getByText('Current streak')).toBeInTheDocument()
expect(screen.getByText('Longest streak')).toBeInTheDocument()
expect(screen.getByText('2.9M')).toBeInTheDocument()
expect(screen.getByText('2.7M')).toBeInTheDocument()
expect(screen.getByText('0m')).toBeInTheDocument()
expect(screen.getByText('9 days')).toBeInTheDocument()
expect(screen.getByText('18 days')).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,
name: /May 9, 2026: 4 sessions · 128K Tokens/i,
})
expect(todayCell).toBeInTheDocument()
expect(screen.queryByRole('gridcell', { name: /May 10, 2026/i })).not.toBeInTheDocument()
@ -104,17 +207,138 @@ describe('ActivitySettings', () => {
await flushActivityLoad()
const todayCell = screen.getByRole('gridcell', {
name: /May 9, 2026: 4 sessions, 128K tokens/i,
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).toHaveTextContent('4 sessions · 128K Tokens')
expect(tooltip).not.toHaveTextContent(/messages|tools/i)
expect(tooltip.className).toContain('--color-activity-tooltip-surface')
expect(tooltip.className).toContain('--color-activity-tooltip-border')
expect(todayCell.className).toContain('activity-heat-cell')
expect(todayCell.className).toContain('is-active')
expect(todayCell.className).toContain('--color-activity-cell-border')
expect(screen.queryByText('Selected day')).not.toBeInTheDocument()
})
it('supports localized heatmap mode switches and persisted display name edits', async () => {
useSettingsStore.setState({ locale: 'zh' })
render(<ActivitySettings />)
await flushActivityLoad()
expect(screen.getByText('Token 活动')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '每周' }))
expect(screen.getByRole('button', { name: '每周' })).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(screen.getByRole('button', { name: '累计' }))
expect(screen.getByRole('button', { name: '累计' })).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(screen.getByRole('button', { name: '编辑个人资料' }))
const input = screen.getByLabelText('显示名称')
fireEvent.change(input, { target: { value: '本地舰长' } })
fireEvent.click(screen.getByRole('button', { name: '保存' }))
await flushActivityLoad()
expect(updateProfilePreferencesMock).toHaveBeenCalledWith({ displayName: '本地舰长' })
expect(screen.getByText('本地舰长')).toBeInTheDocument()
})
it('handles avatar upload, fallback, removal, save failure, and cancel reset', async () => {
getPreferencesMock.mockResolvedValueOnce({
exists: true,
preferences: {
schemaVersion: 2,
profile: {
displayName: 'Local Captain',
avatarFile: 'profile/avatar.webp',
avatarUpdatedAt: '2026-05-09T12:00:00.000Z',
},
sidebar: {
projectOrder: [],
pinnedProjects: [],
hiddenProjects: [],
projectOrganization: 'recentProject',
projectSortBy: 'updatedAt',
},
},
})
updateProfilePreferencesMock.mockRejectedValueOnce(new Error('display name rejected'))
const { container } = render(<ActivitySettings />)
await flushActivityLoad()
const avatar = screen.getByAltText('Local Captain avatar')
expect(avatar).toHaveAttribute('src', '/api/desktop-ui/preferences/profile/avatar?mock=1')
expect(avatar).not.toHaveClass('scale-[1.28]')
fireEvent.error(avatar)
expect(avatar).toHaveAttribute('src', '/app-icon.png')
expect(avatar).toHaveClass('scale-[1.28]')
fireEvent.click(screen.getByRole('button', { name: 'Edit profile' }))
fireEvent.change(screen.getByLabelText('Display name'), { target: { value: 'Rejected Name' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await flushActivityLoad()
expect(screen.getByText('display name rejected')).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('Display name'), { target: { value: 'Unsaved Name' } })
const cancelButtons = screen.getAllByRole('button', { name: 'Cancel' })
fireEvent.click(cancelButtons[cancelButtons.length - 1]!)
fireEvent.click(screen.getByRole('button', { name: 'Edit profile' }))
expect(screen.getByLabelText('Display name')).toHaveValue('Local Captain')
const input = container.querySelector('input[type="file"]') as HTMLInputElement
const file = new File([new Uint8Array([1, 2, 3])], 'avatar.png', { type: 'image/png' })
fireEvent.change(input, { target: { files: [file] } })
await flushActivityLoad()
expect(uploadProfileAvatarMock).toHaveBeenCalledWith(file)
expect(screen.getByText('Saved locally')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Remove avatar' }))
await flushActivityLoad()
expect(deleteProfileAvatarMock).toHaveBeenCalled()
expect(screen.getByAltText('cc-haha avatar')).toHaveAttribute('src', '/app-icon.png')
})
it('shows localized duration details and the empty usage state', async () => {
useSettingsStore.setState({ locale: 'zh' })
getStatsMock.mockResolvedValueOnce({
...activityResponse,
totalSessions: 0,
totalMessages: 0,
activeDays: 0,
dailyActivity: [],
dailyModelTokens: [],
longestSession: {
id: 'session-1',
startedAt: '2026-05-09T08:00:00.000Z',
endedAt: '2026-05-09T09:30:00.000Z',
duration: 90 * 60_000,
messageCount: 12,
toolCallCount: 4,
},
peakActivityDay: null,
streaks: {
currentStreak: 0,
longestStreak: 0,
currentStreakStart: null,
longestStreakStart: null,
longestStreakEnd: null,
},
})
render(<ActivitySettings />)
await flushActivityLoad()
expect(screen.getByText('1 小时 30 分钟')).toBeInTheDocument()
expect(screen.getByText('12 消息')).toBeInTheDocument()
expect(screen.getByText('暂无本地用量')).toBeInTheDocument()
})
})

View File

@ -1,5 +1,10 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from 'react'
import { activityStatsApi, type ActivityStatsResponse, type DailyActivity } from '../api/activityStats'
import {
desktopUiPreferencesApi,
getProfileAvatarUrl,
type DesktopProfilePreferences,
} from '../api/desktopUiPreferences'
import { type Locale, useTranslation } from '../i18n'
import { useSettingsStore } from '../stores/settingsStore'
@ -10,6 +15,9 @@ type HeatmapDay = {
toolCallCount: number
tokens: number
level: number
mode: HeatmapMode
rangeStart?: string
rangeEnd?: string
}
type SummaryMetric = {
@ -18,6 +26,8 @@ type SummaryMetric = {
detail?: string
}
type HeatmapMode = 'daily' | 'weekly' | 'cumulative'
const WEEK_COUNT = 52
const WEEKDAY_LABEL_KEYS = [
'settings.activity.weekday.mon',
@ -40,6 +50,12 @@ const DATE_LOCALES: Record<Locale, string> = {
en: 'en-US',
zh: 'zh-CN',
}
const DEFAULT_PROFILE: DesktopProfilePreferences = {
displayName: 'cc-haha',
avatarFile: null,
avatarUpdatedAt: null,
}
const PROFILE_LINK_URL = 'https://github.com/NanmiCoder/cc-haha'
function localDateKey(date: Date) {
const year = date.getFullYear()
@ -73,15 +89,6 @@ function formatDateLabel(dateKey: string, locale: Locale) {
})
}
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`
@ -89,10 +96,35 @@ function formatTokens(tokens: number) {
return `${tokens}`
}
function formatDayCount(value: number, t: ReturnType<typeof useTranslation>) {
return t(value === 1 ? 'settings.activity.count.dayOne' : 'settings.activity.count.dayOther', { count: value })
}
function formatTaskDuration(duration: number | undefined, locale: Locale, t: ReturnType<typeof useTranslation>) {
if (!duration || duration <= 0) return t('settings.activity.noDuration')
const totalMinutes = Math.max(1, Math.round(duration / 60_000))
const hours = Math.floor(totalMinutes / 60)
const minutes = totalMinutes % 60
if (locale === 'zh') {
if (hours > 0 && minutes > 0) return `${hours} 小时 ${minutes} 分钟`
if (hours > 0) return `${hours} 小时`
return `${minutes} 分钟`
}
if (hours > 0 && minutes > 0) return `${hours}h ${minutes}m`
if (hours > 0) return `${hours}h`
return `${minutes}m`
}
function formatSessionCount(value: number, t: ReturnType<typeof useTranslation>) {
return t(value === 1 ? 'settings.activity.count.sessionOne' : 'settings.activity.count.sessionOther', { count: value })
}
function formatMessageCount(value: number, t: ReturnType<typeof useTranslation>) {
return `${value} ${t('settings.activity.messages')}`
}
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)))
@ -130,7 +162,22 @@ function getHeatLevel(day: DailyActivity | undefined, tokens: number, maxScore:
return 1
}
function buildHeatmapDays(stats: ActivityStatsResponse | null) {
function getBarHeight(value: number, maxValue: number) {
if (value <= 0 || maxValue <= 0) return 0
return Math.max(1, Math.min(7, Math.ceil((value / maxValue) * 7)))
}
function getBarLevel(value: number, maxValue: number) {
if (value <= 0) return 0
if (maxValue <= 0) return 1
const ratio = value / maxValue
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, mode: HeatmapMode) {
const today = new Date()
today.setHours(0, 0, 0, 0)
@ -138,21 +185,27 @@ function buildHeatmapDays(stats: ActivityStatsResponse | null) {
const start = addDays(finalWeekStart, -(WEEK_COUNT - 1) * 7)
const activityMap = new Map((stats?.dailyActivity ?? []).map((day) => [day.date, day]))
const tokenMap = getDailyTokenMap(stats)
const dates: string[] = []
for (let cursor = new Date(start); cursor <= today; cursor = addDays(cursor, 1)) {
dates.push(localDateKey(cursor))
}
const scores: number[] = []
for (let cursor = new Date(start); cursor <= today; cursor = addDays(cursor, 1)) {
const dateKey = localDateKey(cursor)
let cumulativeTokens = 0
for (const dateKey of dates) {
const day = activityMap.get(dateKey)
const tokens = tokenMap.get(dateKey) ?? 0
cumulativeTokens += tokens
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)
cumulativeTokens = 0
for (const dateKey of dates) {
const day = activityMap.get(dateKey)
const tokens = tokenMap.get(dateKey) ?? 0
cumulativeTokens += tokens
days.push({
date: dateKey,
sessionCount: day?.sessionCount ?? 0,
@ -160,10 +213,66 @@ function buildHeatmapDays(stats: ActivityStatsResponse | null) {
toolCallCount: day?.toolCallCount ?? 0,
tokens,
level: getHeatLevel(day, tokens, maxScore),
mode: 'daily',
})
}
return days
if (mode === 'daily') return days
const weeks = Array.from({ length: WEEK_COUNT }, (_, index) => {
const rangeStart = dates[index * 7] ?? ''
const rangeEnd = dates[Math.min(index * 7 + 6, dates.length - 1)] ?? rangeStart
return {
rangeStart,
rangeEnd,
sessionCount: 0,
messageCount: 0,
toolCallCount: 0,
tokens: 0,
cumulativeTokens: 0,
}
})
dates.forEach((dateKey, index) => {
const week = weeks[Math.floor(index / 7)]
const day = activityMap.get(dateKey)
if (!week) return
week.sessionCount += day?.sessionCount ?? 0
week.messageCount += day?.messageCount ?? 0
week.toolCallCount += day?.toolCallCount ?? 0
week.tokens += tokenMap.get(dateKey) ?? 0
})
let runningTotal = 0
for (const week of weeks) {
runningTotal += week.tokens
week.cumulativeTokens = runningTotal
}
const maxValue = Math.max(
...weeks.map((week) => (mode === 'weekly' ? week.tokens : week.cumulativeTokens)),
0,
)
return dates.map((dateKey, index) => {
const week = weeks[Math.floor(index / 7)]
const row = index % 7
const tokens = mode === 'weekly' ? week?.tokens ?? 0 : week?.cumulativeTokens ?? 0
const height = getBarHeight(tokens, maxValue)
const isFilled = height > 0 && row >= 7 - height
return {
date: dateKey,
sessionCount: week?.sessionCount ?? 0,
messageCount: week?.messageCount ?? 0,
toolCallCount: week?.toolCallCount ?? 0,
tokens,
level: isFilled ? getBarLevel(tokens, maxValue) : 0,
mode,
rangeStart: week?.rangeStart,
rangeEnd: week?.rangeEnd,
}
})
}
function buildMonthLabels(days: HeatmapDay[], locale: Locale) {
@ -192,13 +301,47 @@ function buildMonthLabels(days: HeatmapDay[], locale: Locale) {
return labels
}
function getHeatmapCellTitle(day: HeatmapDay, locale: Locale, t: ReturnType<typeof useTranslation>) {
if (day.mode === 'weekly') {
return t('settings.activity.weekRange', {
start: formatDateLabel(day.rangeStart ?? day.date, locale),
end: formatDateLabel(day.rangeEnd ?? day.date, locale),
})
}
if (day.mode === 'cumulative') {
return t('settings.activity.cumulativeThrough', {
date: formatDateLabel(day.rangeEnd ?? day.date, locale),
})
}
return formatDateLabel(day.date, locale)
}
function getHeatmapCellDetail(day: HeatmapDay, t: ReturnType<typeof useTranslation>) {
if (day.mode === 'cumulative') {
return t('settings.activity.tokenValue', { tokens: formatTokens(day.tokens) })
}
return `${formatSessionCount(day.sessionCount, t)} · ${formatTokens(day.tokens)} ${t('settings.activity.tokens')}`
}
export function ActivitySettings() {
const t = useTranslation()
const locale = useSettingsStore((state) => state.locale)
const heatmapMeasureRef = useRef<HTMLDivElement | null>(null)
const avatarInputRef = useRef<HTMLInputElement | null>(null)
const [stats, setStats] = useState<ActivityStatsResponse | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [profile, setProfile] = useState<DesktopProfilePreferences>(DEFAULT_PROFILE)
const [profileError, setProfileError] = useState<string | null>(null)
const [profileStatus, setProfileStatus] = useState<string | null>(null)
const [isProfileLoading, setIsProfileLoading] = useState(true)
const [isEditingProfile, setIsEditingProfile] = useState(false)
const [isSavingProfile, setIsSavingProfile] = useState(false)
const [draftDisplayName, setDraftDisplayName] = useState(DEFAULT_PROFILE.displayName)
const [heatmapMode, setHeatmapMode] = useState<HeatmapMode>('daily')
const [hoveredDate, setHoveredDate] = useState<string | null>(null)
const [focusedDate, setFocusedDate] = useState<string | null>(null)
const [heatCellSize, setHeatCellSize] = useState(10)
@ -226,6 +369,31 @@ export function ActivitySettings() {
}
}, [])
useEffect(() => {
let cancelled = false
setIsProfileLoading(true)
setProfileError(null)
desktopUiPreferencesApi.getPreferences()
.then((result) => {
if (cancelled) return
const nextProfile = result.preferences.profile ?? DEFAULT_PROFILE
setProfile(nextProfile)
setDraftDisplayName(nextProfile.displayName)
})
.catch((err) => {
if (cancelled) return
setProfileError(err instanceof Error ? err.message : String(err))
})
.finally(() => {
if (!cancelled) setIsProfileLoading(false)
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
if (isLoading || error) return
const element = heatmapMeasureRef.current
@ -248,7 +416,7 @@ export function ActivitySettings() {
return () => window.removeEventListener('resize', updateCellSize)
}, [error, isLoading])
const days = useMemo(() => buildHeatmapDays(stats), [stats])
const days = useMemo(() => buildHeatmapDays(stats, heatmapMode), [heatmapMode, stats])
const monthLabels = useMemo(() => buildMonthLabels(days, locale), [days, locale])
const today = days.length > 0 ? days[days.length - 1] : null
const activeTooltipDate = hoveredDate ?? focusedDate
@ -267,66 +435,319 @@ export function ActivitySettings() {
),
),
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 totalTokens = useMemo(() => {
return (stats?.dailyModelTokens ?? []).reduce((sum, day) => (
sum + Object.values(day.tokensByModel).reduce((daySum, tokens) => daySum + tokens, 0)
), 0)
}, [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 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.totalTokens'),
value: formatTokens(totalTokens),
detail: formatDayCount(stats?.activeDays ?? 0, t),
},
{
label: t('settings.activity.metric.yesterday'),
value: `${formatTokens(yesterday?.tokens ?? 0)} tokens`,
detail: formatSessionCount(yesterday?.sessionCount ?? 0, t),
label: t('settings.activity.peakTokens'),
value: formatTokens(peakTokens),
detail: stats?.peakActivityDay ? formatDateLabel(stats.peakActivityDay, locale) : undefined,
},
{
label: t('settings.activity.metric.last30'),
value: `${formatTokens(last30Usage.tokens)} tokens`,
label: t('settings.activity.longestTask'),
value: formatTaskDuration(stats?.longestSession?.duration, locale, t),
detail: stats?.longestSession ? formatMessageCount(stats.longestSession.messageCount, t) : undefined,
},
{
label: t('settings.activity.currentStreak'),
value: formatDayCount(stats?.streaks.currentStreak ?? 0, t),
detail: today ? `${formatTokens(today.tokens)} ${t('settings.activity.tokens')}` : undefined,
},
{
label: t('settings.activity.longestStreak'),
value: formatDayCount(stats?.streaks.longestStreak ?? 0, t),
detail: formatSessionCount(last30Usage.sessions, t),
},
]
const avatarSrc = profile.avatarFile ? getProfileAvatarUrl(profile.avatarUpdatedAt) : '/app-icon.png'
const avatarClassName = profile.avatarFile
? 'h-full w-full object-cover'
: 'h-full w-full scale-[1.28] object-contain transition-transform'
const hasUsage = Boolean(stats && (stats.totalSessions > 0 || totalTokens > 0))
const modeOptions: Array<{ mode: HeatmapMode; label: string; help: string }> = [
{ mode: 'daily', label: t('settings.activity.mode.daily'), help: t('settings.activity.modeHelp.daily') },
{ mode: 'weekly', label: t('settings.activity.mode.weekly'), help: t('settings.activity.modeHelp.weekly') },
{ mode: 'cumulative', label: t('settings.activity.mode.cumulative'), help: t('settings.activity.modeHelp.cumulative') },
]
const saveProfile = async () => {
setIsSavingProfile(true)
setProfileError(null)
setProfileStatus(null)
try {
const result = await desktopUiPreferencesApi.updateProfilePreferences({ displayName: draftDisplayName })
setProfile(result.preferences.profile)
setDraftDisplayName(result.preferences.profile.displayName)
setIsEditingProfile(false)
setProfileStatus(t('settings.activity.profileSaved'))
} catch (err) {
setProfileError(err instanceof Error ? err.message : t('settings.activity.profileSaveFailed'))
} finally {
setIsSavingProfile(false)
}
}
const handleAvatarChange = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
event.target.value = ''
if (!file) return
setIsSavingProfile(true)
setProfileError(null)
setProfileStatus(null)
try {
const result = await desktopUiPreferencesApi.uploadProfileAvatar(file)
setProfile(result.preferences.profile)
setDraftDisplayName(result.preferences.profile.displayName)
setProfileStatus(t('settings.activity.profileSaved'))
} catch (err) {
setProfileError(err instanceof Error ? err.message : t('settings.activity.profileSaveFailed'))
} finally {
setIsSavingProfile(false)
}
}
const removeAvatar = async () => {
setIsSavingProfile(true)
setProfileError(null)
setProfileStatus(null)
try {
const result = await desktopUiPreferencesApi.deleteProfileAvatar()
setProfile(result.preferences.profile)
setProfileStatus(t('settings.activity.profileSaved'))
} catch (err) {
setProfileError(err instanceof Error ? err.message : t('settings.activity.profileSaveFailed'))
} finally {
setIsSavingProfile(false)
}
}
return (
<div className="w-full max-w-[1400px] min-w-0">
<div className="mb-5 flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div className="min-w-0 pt-1">
<h2 className="text-base font-semibold tracking-normal text-[var(--color-text-primary)]">{t('settings.activity.title')}</h2>
<div className="mt-1 text-sm leading-5 text-[var(--color-text-tertiary)]">
{dateRange && <div>{dateRange}</div>}
<div>{t('settings.activity.subtitleLoading')}</div>
<div className="mx-auto w-full max-w-[1160px] min-w-0 pb-12">
<section className="relative flex min-h-[310px] flex-col items-center justify-end pt-12 text-center">
<button
type="button"
className="absolute right-0 top-0 inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-sm font-medium text-[var(--color-text-secondary)] transition-[background-color,transform] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] active:translate-y-[1px] disabled:opacity-50"
onClick={() => {
setIsEditingProfile(true)
setDraftDisplayName(profile.displayName)
}}
disabled={isProfileLoading}
>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">edit</span>
{t('settings.activity.editProfile')}
</button>
<div className="relative h-24 w-24 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)]">
<img
src={avatarSrc}
alt={`${profile.displayName} avatar`}
className={avatarClassName}
onError={(event) => {
event.currentTarget.src = '/app-icon.png'
event.currentTarget.className = 'h-full w-full scale-[1.28] object-contain transition-transform'
}}
/>
</div>
<h1 className="mt-8 max-w-full truncate text-4xl font-semibold tracking-tight text-[var(--color-text-primary)] sm:text-[44px]">{profile.displayName}</h1>
<a
href={PROFILE_LINK_URL}
target="_blank"
rel="noreferrer"
className="mt-4 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)]"
>
<span>{t('settings.activity.defaultHandle')}</span>
</a>
{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="mx-auto mt-10 overflow-hidden rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
{isLoading ? (
<div className="grid gap-0 sm:grid-cols-2 xl:grid-cols-5">
{Array.from({ length: 5 }).map((_, index) => (
<div key={index} className="h-[88px] animate-pulse border-t border-[var(--color-border)] bg-[var(--color-surface)] sm:border-l sm:border-t-0 first:sm:border-l-0" />
))}
</div>
) : (
<div className="grid gap-0 sm:grid-cols-2 xl:grid-cols-5">
{metrics.map((metric, index) => (
<div
key={metric.label}
className="min-w-0 border-t border-[var(--color-border)] px-5 py-5 text-center opacity-0 [animation:activity-reveal_420ms_cubic-bezier(0.16,1,0.3,1)_forwards] sm:border-l sm:border-t-0 first:sm:border-l-0"
style={{ animationDelay: `${index * 45}ms` }}
>
<div className="truncate text-2xl font-semibold tracking-tight text-[var(--color-text-primary)]">{metric.value}</div>
<div className="mt-1 truncate text-sm font-semibold text-[var(--color-text-secondary)]">{metric.label}</div>
{metric.detail && <div className="mt-0.5 truncate text-xs text-[var(--color-text-tertiary)]">{metric.detail}</div>}
</div>
))}
</div>
)}
</section>
{isEditingProfile && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/25 px-4 py-8" role="dialog" aria-modal="true" aria-labelledby="activity-profile-dialog-title">
<div className="w-full max-w-[420px] rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] p-5 shadow-2xl">
<div className="flex items-start justify-between gap-4">
<div>
<h2 id="activity-profile-dialog-title" className="text-base font-semibold text-[var(--color-text-primary)]">{t('settings.activity.editProfile')}</h2>
<p className="mt-1 text-xs text-[var(--color-text-tertiary)]">{t('settings.activity.displayNameHelper')}</p>
</div>
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--color-text-tertiary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
onClick={() => {
setIsEditingProfile(false)
setDraftDisplayName(profile.displayName)
setProfileError(null)
}}
aria-label={t('settings.activity.cancelEdit')}
>
<span className="material-symbols-outlined text-[17px]" aria-hidden="true">close</span>
</button>
</div>
<div className="mt-5 grid gap-4">
<div className="grid gap-2">
<label htmlFor="activity-profile-display-name" className="text-xs font-medium text-[var(--color-text-secondary)]">
{t('settings.activity.displayName')}
</label>
<input
id="activity-profile-display-name"
value={draftDisplayName}
onChange={(event) => setDraftDisplayName(event.target.value)}
className="h-10 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-sm text-[var(--color-text-primary)] outline-none transition-colors focus:border-[var(--color-border-focus)]"
/>
</div>
<div className="grid gap-2">
<div className="text-xs font-medium text-[var(--color-text-secondary)]">{t('settings.activity.avatar')}</div>
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.activity.avatarHelper')}</p>
<div className="flex flex-wrap gap-2">
<input
ref={avatarInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={handleAvatarChange}
/>
<button
type="button"
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-[var(--color-border)] px-2.5 text-xs font-medium text-[var(--color-text-secondary)] transition-[background-color,transform] hover:bg-[var(--color-surface-hover)] active:translate-y-[1px]"
onClick={() => avatarInputRef.current?.click()}
>
<span className="material-symbols-outlined text-[15px]" aria-hidden="true">upload</span>
{t('settings.activity.changeAvatar')}
</button>
{profile.avatarFile && (
<button
type="button"
className="inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium text-[var(--color-text-tertiary)] transition-[background-color,transform] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] active:translate-y-[1px]"
onClick={removeAvatar}
>
{t('settings.activity.removeAvatar')}
</button>
)}
</div>
</div>
</div>
{profileError && <div className="mt-4 rounded-md border border-[var(--color-error)]/30 bg-[var(--color-error)]/10 px-3 py-2 text-xs text-[var(--color-error)]">{profileError}</div>}
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
className="h-8 rounded-md px-3 text-xs font-medium text-[var(--color-text-secondary)] transition-[background-color,transform] hover:bg-[var(--color-surface-hover)] active:translate-y-[1px]"
onClick={() => {
setIsEditingProfile(false)
setDraftDisplayName(profile.displayName)
setProfileError(null)
}}
>
{t('settings.activity.cancelEdit')}
</button>
<button
type="button"
className="h-8 rounded-md bg-[var(--color-text-primary)] px-3 text-xs font-medium text-[var(--color-surface)] transition-[opacity,transform] active:translate-y-[1px] disabled:opacity-50"
onClick={saveProfile}
disabled={isSavingProfile}
>
{t('settings.activity.saveProfile')}
</button>
</div>
</div>
</div>
)}
<div className="mt-16">
<div className="mb-5 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>
<div className="inline-flex w-fit items-center gap-7">
{modeOptions.map((option) => (
<button
key={option.mode}
type="button"
aria-pressed={heatmapMode === option.mode}
title={option.help}
className={`text-lg font-semibold transition-[color,transform] active:translate-y-[1px] ${
heatmapMode === option.mode
? 'text-[var(--color-text-primary)]'
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]'
}`}
onClick={() => setHeatmapMode(option.mode)}
>
{option.label}
</button>
))}
</div>
</div>
<div
className="grid w-full gap-2 xl:max-w-[640px]"
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))' }}
>
{metrics.map((metric) => (
<div key={metric.label} className="min-w-0 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-container)] px-3 py-2.5">
<div className="truncate text-[11px] font-semibold uppercase tracking-[0.14em] text-[var(--color-text-tertiary)]">{metric.label}</div>
<div className="mt-1 truncate text-xl font-semibold tracking-normal text-[var(--color-text-primary)]">{metric.value}</div>
{metric.detail && <div className="mt-0.5 truncate text-xs text-[var(--color-text-tertiary)]">{metric.detail}</div>}
</div>
))}
</div>
</div>
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4 shadow-sm">
{isLoading ? (
<div className="flex min-h-[190px] items-center justify-center text-sm text-[var(--color-text-tertiary)]">
<span className="material-symbols-outlined mr-2 animate-spin text-[18px]">progress_activity</span>
{t('common.loading')}
<div className="min-h-[190px] space-y-3">
<div className="h-4 w-1/4 animate-pulse rounded bg-[var(--color-surface-container)]" />
<div className="grid grid-flow-col gap-[3px]">
{Array.from({ length: 52 }).map((_, col) => (
<div key={col} className="grid grid-rows-7 gap-[3px]">
{Array.from({ length: 7 }).map((__, row) => (
<div key={row} className="h-2.5 w-2.5 animate-pulse rounded-[3px] bg-[var(--color-surface-container)]" />
))}
</div>
))}
</div>
</div>
) : error ? (
<div className="rounded-md border border-[var(--color-error)]/30 bg-[var(--color-error)]/10 px-4 py-3 text-sm text-[var(--color-error)]">
{error}
</div>
) : !hasUsage ? (
<div className="flex min-h-[190px] items-center justify-center">
<div className="max-w-sm text-center">
<div className="mx-auto flex h-11 w-11 items-center justify-center rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] text-[var(--color-text-tertiary)]">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">monitoring</span>
</div>
<div className="mt-3 text-sm font-medium text-[var(--color-text-primary)]">{t('settings.activity.emptyTitle')}</div>
<p className="mt-1 text-sm leading-5 text-[var(--color-text-tertiary)]">{t('settings.activity.emptyBody')}</p>
</div>
</div>
) : (
<>
<div ref={heatmapMeasureRef} className="min-w-0 pb-2">
@ -371,16 +792,18 @@ export function ActivitySettings() {
{days.map((day) => {
const isSelected = activeTooltipDate === day.date
const tooltipId = `activity-day-tooltip-${day.date}`
const cellTitle = getHeatmapCellTitle(day, locale, t)
const cellDetail = getHeatmapCellDetail(day, t)
return (
<button
key={day.date}
type="button"
role="gridcell"
aria-label={`${formatDateLabel(day.date, locale)}: ${formatSessionCount(day.sessionCount, t)}, ${formatTokens(day.tokens)} tokens`}
aria-label={`${cellTitle}: ${cellDetail}`}
aria-describedby={activeTooltipDate === day.date ? tooltipId : undefined}
className={`rounded-[3px] border transition-[border-color,transform] hover:scale-110 focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface)] ${
className={`activity-heat-cell rounded-[3px] border focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface)] ${
isSelected
? 'border-[var(--color-activity-cell-border-active)]'
? 'is-active border-[var(--color-activity-cell-border-active)]'
: 'border-[var(--color-activity-cell-border)] hover:border-[var(--color-activity-cell-border-hover)]'
}`}
style={{
@ -404,9 +827,9 @@ export function ActivitySettings() {
className="pointer-events-none absolute z-20 min-w-[172px] rounded-md border border-[var(--color-activity-tooltip-border)] bg-[var(--color-activity-tooltip-surface)] px-3 py-2 text-xs shadow-xl"
style={tooltipStyle}
>
<div className="font-medium text-[var(--color-activity-tooltip-text)]">{formatDateLabel(tooltipDay.date, locale)}</div>
<div className="font-medium text-[var(--color-activity-tooltip-text)]">{getHeatmapCellTitle(tooltipDay, locale, t)}</div>
<div className="mt-1 text-[var(--color-activity-tooltip-muted)]">
{formatSessionCount(tooltipDay.sessionCount, t)} · {formatTokens(tooltipDay.tokens)} tokens
{getHeatmapCellDetail(tooltipDay, t)}
</div>
</div>
)}

View File

@ -401,13 +401,14 @@
--color-model-option-selected-bg: var(--color-primary-fixed);
--color-model-option-selected-border: rgba(143, 72, 47, 0.2);
--color-activity-heat-0: var(--color-surface-container);
--color-activity-heat-1: #FFE6DE;
--color-activity-heat-2: #FFBCA8;
--color-activity-heat-3: #E57A58;
--color-activity-heat-4: #8F482F;
--color-activity-heat-1: #FBE9E2;
--color-activity-heat-2: var(--color-primary-fixed);
--color-activity-heat-3: var(--color-primary-fixed-dim);
--color-activity-heat-4: var(--color-primary);
--color-activity-cell-border: rgba(135, 115, 109, 0.3);
--color-activity-cell-border-hover: rgba(84, 67, 62, 0.72);
--color-activity-cell-border-hover: rgba(143, 72, 47, 0.58);
--color-activity-cell-border-active: var(--color-text-primary);
--shadow-activity-cell-hover: 0 0 0 2px rgba(143, 72, 47, 0.12), 0 8px 18px rgba(65, 54, 48, 0.16);
--color-activity-tooltip-surface: #2F312E;
--color-activity-tooltip-border: rgba(255, 255, 255, 0.12);
--color-activity-tooltip-text: #F2F1ED;
@ -608,17 +609,18 @@
--color-model-option-selected-bg: #FFF0EA;
--color-model-option-selected-border: rgba(143, 72, 47, 0.2);
--color-activity-heat-0: #EEF2F6;
--color-activity-heat-1: #FFE9E1;
--color-activity-heat-2: #FFC4B1;
--color-activity-heat-3: #E57A58;
--color-activity-heat-4: #8F482F;
--color-activity-heat-1: #FFF0EA;
--color-activity-heat-2: var(--color-primary-fixed);
--color-activity-heat-3: var(--color-primary-fixed-dim);
--color-activity-heat-4: var(--color-primary);
--color-activity-cell-border: rgba(139, 152, 167, 0.32);
--color-activity-cell-border-hover: rgba(75, 85, 99, 0.72);
--color-activity-cell-border-hover: rgba(143, 72, 47, 0.55);
--color-activity-cell-border-active: var(--color-text-primary);
--shadow-activity-cell-hover: 0 0 0 2px rgba(143, 72, 47, 0.11), 0 8px 18px rgba(15, 23, 42, 0.14);
--color-activity-tooltip-surface: #111827;
--color-activity-tooltip-border: rgba(255, 255, 255, 0.14);
--color-activity-tooltip-text: #F9FAFB;
--color-activity-tooltip-muted: #FFC4B1;
--color-activity-tooltip-muted: #FFB59D;
--color-inspector-surface: #FFFFFF;
--color-inspector-panel: #F7F9FB;
--color-inspector-chip: #EEF3F6;
@ -792,17 +794,18 @@
--color-model-option-selected-bg: rgba(255, 181, 159, 0.13);
--color-model-option-selected-border: rgba(255, 181, 159, 0.34);
--color-activity-heat-0: #242322;
--color-activity-heat-1: #4A2B24;
--color-activity-heat-2: #85432F;
--color-activity-heat-3: #C85F3C;
--color-activity-heat-4: #FF8A5C;
--color-activity-heat-1: #3A2823;
--color-activity-heat-2: #674032;
--color-activity-heat-3: #A76549;
--color-activity-heat-4: var(--color-primary);
--color-activity-cell-border: rgba(229, 226, 225, 0.08);
--color-activity-cell-border-hover: rgba(255, 181, 159, 0.48);
--color-activity-cell-border-hover: rgba(255, 181, 159, 0.58);
--color-activity-cell-border-active: #FBE7E1;
--shadow-activity-cell-hover: 0 0 0 2px rgba(255, 181, 159, 0.16), 0 8px 18px rgba(0, 0, 0, 0.34);
--color-activity-tooltip-surface: #F2F1ED;
--color-activity-tooltip-border: rgba(255, 181, 159, 0.32);
--color-activity-tooltip-border: rgba(255, 181, 159, 0.36);
--color-activity-tooltip-text: #1B1C1A;
--color-activity-tooltip-muted: #8F3217;
--color-activity-tooltip-muted: #8F482F;
--color-inspector-surface: #131211;
--color-inspector-panel: #1C1B1B;
--color-inspector-chip: #252120;
@ -1273,6 +1276,38 @@ button, input, textarea, select, a, [role="button"] {
background: var(--color-outline-a92);
}
.activity-heat-cell {
position: relative;
transform: translateZ(0);
transform-origin: center;
transition:
border-color 150ms ease,
box-shadow 180ms ease,
filter 180ms ease,
transform 180ms cubic-bezier(0.22, 1, 0.36, 1);
}
.activity-heat-cell:hover,
.activity-heat-cell:focus-visible,
.activity-heat-cell.is-active {
z-index: 1;
transform: translateZ(0) scale(1.16);
box-shadow: var(--shadow-activity-cell-hover);
filter: saturate(1.08);
}
@media (prefers-reduced-motion: reduce) {
.activity-heat-cell {
transition-duration: 0.01ms !important;
}
.activity-heat-cell:hover,
.activity-heat-cell:focus-visible,
.activity-heat-cell.is-active {
transform: translateZ(0);
}
}
/* Animations */
@keyframes shimmer {
0%, 100% { opacity: 0.4; }
@ -1285,6 +1320,10 @@ button, input, textarea, select, a, [role="button"] {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
@keyframes activity-reveal {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes composer-drop-fade {
from { opacity: 0; transform: scale(0.985); }
to { opacity: 1; transform: scale(1); }

View File

@ -43,6 +43,7 @@ describe('desktop theme tokens', () => {
'--color-activity-cell-border',
'--color-activity-cell-border-hover',
'--color-activity-cell-border-active',
'--shadow-activity-cell-hover',
'--color-activity-tooltip-surface',
'--color-activity-tooltip-border',
'--color-activity-tooltip-text',
@ -79,6 +80,15 @@ describe('desktop theme tokens', () => {
}
})
it('keeps activity heatmap colors on the app theme accent instead of the old blue ramp', () => {
expect(css).not.toContain('#DCEEFF')
expect(css).not.toContain('#B6D9FF')
expect(css).not.toContain('#2387E8')
expect(css).toContain('--color-activity-heat-4: var(--color-primary);')
expect(css).toContain('.activity-heat-cell:hover')
expect(css).toContain('box-shadow: var(--shadow-activity-cell-hover);')
})
it('avoids color-mix in the startup-critical UI zoom shell chrome for Safari 15 WebView support', () => {
const zoomShellCss = getCssBetween('.settings-zoom-kbd {', '/* ─── Tailwind Theme Override')

View File

@ -26,13 +26,14 @@ async function teardown() {
function makeRequest(
method: string,
urlStr: string,
body?: Record<string, unknown>,
body?: Record<string, unknown> | Uint8Array,
contentType = 'application/json',
): { req: Request; url: URL; segments: string[] } {
const url = new URL(urlStr, 'http://localhost:3456')
const init: RequestInit = { method }
if (body !== undefined) {
init.headers = { 'Content-Type': 'application/json' }
init.body = JSON.stringify(body)
init.headers = { 'Content-Type': contentType }
init.body = body instanceof Uint8Array ? body : JSON.stringify(body)
}
const req = new Request(url.toString(), init)
const segments = url.pathname.split('/').filter(Boolean)
@ -55,7 +56,12 @@ describe('DesktopUiPreferencesService', () => {
expect(result.exists).toBe(false)
expect(result.preferences).toEqual({
schemaVersion: 1,
schemaVersion: 2,
profile: {
displayName: 'cc-haha',
avatarFile: null,
avatarUpdatedAt: null,
},
sidebar: {
projectOrder: [],
pinnedProjects: [],
@ -91,8 +97,13 @@ describe('DesktopUiPreferencesService', () => {
expect(before.exists).toBe(true)
expect(before.preferences).toEqual({
schemaVersion: 1,
schemaVersion: 2,
futureField: { keep: true },
profile: {
displayName: 'cc-haha',
avatarFile: null,
avatarUpdatedAt: null,
},
sidebar: {
projectOrder: ['/workspace/alpha', '/workspace/beta'],
pinnedProjects: ['/workspace/beta'],
@ -102,8 +113,13 @@ describe('DesktopUiPreferencesService', () => {
},
})
expect(after).toEqual({
schemaVersion: 1,
schemaVersion: 2,
futureField: { keep: true },
profile: {
displayName: 'cc-haha',
avatarFile: null,
avatarUpdatedAt: null,
},
sidebar: {
projectOrder: ['/workspace/gamma'],
pinnedProjects: [],
@ -125,8 +141,66 @@ describe('DesktopUiPreferencesService', () => {
expect(result.exists).toBe(false)
expect(result.preferences.sidebar.hiddenProjects).toEqual([])
expect(result.preferences.profile.displayName).toBe('cc-haha')
expect(files.some((name) => name.startsWith('desktop-ui.json.invalid-'))).toBe(true)
})
test('normalizes and persists profile preferences without touching sidebar preferences', async () => {
const service = new DesktopUiPreferencesService()
const after = await service.updateProfilePreferences({
displayName: ' Claude Captain ',
avatarFile: '../escape.png',
avatarUpdatedAt: 42,
})
expect(after).toEqual({
schemaVersion: 2,
profile: {
displayName: 'Claude Captain',
avatarFile: null,
avatarUpdatedAt: null,
},
sidebar: {
projectOrder: [],
pinnedProjects: [],
hiddenProjects: [],
projectOrganization: 'recentProject',
projectSortBy: 'updatedAt',
},
})
expect(await readDesktopUiFile()).toEqual(after)
})
test('stores uploaded profile avatars under cc-haha profile storage', async () => {
const service = new DesktopUiPreferencesService()
const after = await service.updateProfileAvatar(new Uint8Array([137, 80, 78, 71]), 'image/png')
expect(after.profile.avatarFile).toBe('profile/avatar.png')
expect(typeof after.profile.avatarUpdatedAt).toBe('string')
const avatar = await fs.readFile(path.join(tmpDir, 'cc-haha', 'profile', 'avatar.png'))
expect([...avatar]).toEqual([137, 80, 78, 71])
})
test('clears only managed profile avatar files', async () => {
const service = new DesktopUiPreferencesService()
await service.updateProfileAvatar(new Uint8Array([137, 80, 78, 71]), 'image/png')
await fs.writeFile(path.join(tmpDir, 'cc-haha', 'profile', 'local-note.txt'), 'keep me', 'utf-8')
const after = await service.clearProfileAvatar()
expect(after.profile.avatarFile).toBeNull()
expect(after.profile.avatarUpdatedAt).toBeNull()
await expect(fs.readFile(path.join(tmpDir, 'cc-haha', 'profile', 'avatar.png'))).rejects.toThrow()
await expect(fs.readFile(path.join(tmpDir, 'cc-haha', 'profile', 'local-note.txt'), 'utf-8')).resolves.toBe('keep me')
})
test('rejects unsupported or oversized profile avatars', async () => {
const service = new DesktopUiPreferencesService()
await expect(service.updateProfileAvatar(new Uint8Array([1, 2, 3]), 'image/gif')).rejects.toThrow('Unsupported avatar type')
await expect(service.updateProfileAvatar(new Uint8Array(2_000_001), 'image/png')).rejects.toThrow('Avatar image is too large')
})
})
describe('desktop UI preferences API', () => {
@ -149,7 +223,12 @@ describe('desktop UI preferences API', () => {
expect(putBody).toEqual({
ok: true,
preferences: {
schemaVersion: 1,
schemaVersion: 2,
profile: {
displayName: 'cc-haha',
avatarFile: null,
avatarUpdatedAt: null,
},
sidebar: {
projectOrder: ['/workspace/beta', '/workspace/alpha'],
pinnedProjects: ['/workspace/beta'],
@ -168,7 +247,12 @@ describe('desktop UI preferences API', () => {
expect(getBody).toEqual({
exists: true,
preferences: {
schemaVersion: 1,
schemaVersion: 2,
profile: {
displayName: 'cc-haha',
avatarFile: null,
avatarUpdatedAt: null,
},
sidebar: {
projectOrder: ['/workspace/beta', '/workspace/alpha'],
pinnedProjects: ['/workspace/beta'],
@ -179,4 +263,138 @@ describe('desktop UI preferences API', () => {
},
})
})
test('persists profile preferences and avatar uploads through the API', async () => {
const profileReq = makeRequest('PUT', '/api/desktop-ui/preferences/profile', {
displayName: ' Local Operator ',
})
const profileRes = await handleDesktopUiApi(profileReq.req, profileReq.url, profileReq.segments)
const profileBody = await profileRes.json() as Record<string, unknown>
expect(profileRes.status).toBe(200)
expect(profileBody).toMatchObject({
ok: true,
preferences: {
profile: {
displayName: 'Local Operator',
avatarFile: null,
},
},
})
const avatarReq = makeRequest(
'PUT',
'/api/desktop-ui/preferences/profile/avatar',
new Uint8Array([255, 216, 255]),
'image/jpeg',
)
const avatarRes = await handleDesktopUiApi(avatarReq.req, avatarReq.url, avatarReq.segments)
const avatarBody = await avatarRes.json() as Record<string, unknown>
expect(avatarRes.status).toBe(200)
expect(avatarBody).toMatchObject({
ok: true,
preferences: {
profile: {
displayName: 'Local Operator',
avatarFile: 'profile/avatar.jpg',
},
},
})
const getAvatarReq = makeRequest('GET', '/api/desktop-ui/preferences/profile/avatar')
const getAvatarRes = await handleDesktopUiApi(getAvatarReq.req, getAvatarReq.url, getAvatarReq.segments)
expect(getAvatarRes.status).toBe(200)
expect(getAvatarRes.headers.get('Content-Type')).toBe('image/jpeg')
expect([...new Uint8Array(await getAvatarRes.arrayBuffer())]).toEqual([255, 216, 255])
})
test('returns API errors for missing avatars, invalid JSON, and unknown routes', async () => {
const missingAvatarReq = makeRequest('GET', '/api/desktop-ui/preferences/profile/avatar')
const missingAvatarRes = await handleDesktopUiApi(
missingAvatarReq.req,
missingAvatarReq.url,
missingAvatarReq.segments,
)
expect(missingAvatarRes.status).toBe(404)
await expect(missingAvatarRes.json()).resolves.toMatchObject({
error: 'NOT_FOUND',
message: 'Profile avatar is not configured',
})
const invalidUrl = new URL('/api/desktop-ui/preferences/profile', 'http://localhost:3456')
const invalidReq = new Request(invalidUrl.toString(), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: '{bad json',
})
const invalidRes = await handleDesktopUiApi(
invalidReq,
invalidUrl,
invalidUrl.pathname.split('/').filter(Boolean),
)
expect(invalidRes.status).toBe(400)
await expect(invalidRes.json()).resolves.toMatchObject({
error: 'BAD_REQUEST',
message: 'Invalid JSON body',
})
const unknownTopReq = makeRequest('GET', '/api/desktop-ui/unknown')
const unknownTopRes = await handleDesktopUiApi(unknownTopReq.req, unknownTopReq.url, unknownTopReq.segments)
expect(unknownTopRes.status).toBe(404)
const unknownProfileReq = makeRequest('GET', '/api/desktop-ui/preferences/profile/banner')
const unknownProfileRes = await handleDesktopUiApi(
unknownProfileReq.req,
unknownProfileReq.url,
unknownProfileReq.segments,
)
expect(unknownProfileRes.status).toBe(404)
const unknownPreferenceReq = makeRequest('GET', '/api/desktop-ui/preferences/theme')
const unknownPreferenceRes = await handleDesktopUiApi(
unknownPreferenceReq.req,
unknownPreferenceReq.url,
unknownPreferenceReq.segments,
)
expect(unknownPreferenceRes.status).toBe(404)
})
test('clears profile avatars and rejects unsupported avatar methods through the API', async () => {
const putReq = makeRequest(
'PUT',
'/api/desktop-ui/preferences/profile/avatar',
new Uint8Array([82, 73, 70, 70]),
'image/webp',
)
const putRes = await handleDesktopUiApi(putReq.req, putReq.url, putReq.segments)
expect(putRes.status).toBe(200)
const deleteReq = makeRequest('DELETE', '/api/desktop-ui/preferences/profile/avatar')
const deleteRes = await handleDesktopUiApi(deleteReq.req, deleteReq.url, deleteReq.segments)
const deleteBody = await deleteRes.json() as Record<string, unknown>
expect(deleteRes.status).toBe(200)
expect(deleteBody).toMatchObject({
ok: true,
preferences: {
profile: {
avatarFile: null,
avatarUpdatedAt: null,
},
},
})
const getAvatarReq = makeRequest('GET', '/api/desktop-ui/preferences/profile/avatar')
const getAvatarRes = await handleDesktopUiApi(getAvatarReq.req, getAvatarReq.url, getAvatarReq.segments)
expect(getAvatarRes.status).toBe(404)
const patchReq = makeRequest('PATCH', '/api/desktop-ui/preferences/profile/avatar')
const patchRes = await handleDesktopUiApi(patchReq.req, patchReq.url, patchReq.segments)
expect(patchRes.status).toBe(405)
await expect(patchRes.json()).resolves.toMatchObject({
error: 'METHOD_NOT_ALLOWED',
message: 'Method PATCH not allowed',
})
})
})

View File

@ -3,6 +3,10 @@
*
* GET /api/desktop-ui/preferences read cc-haha UI preferences
* PUT /api/desktop-ui/preferences/sidebar persist sidebar project preferences
* PUT /api/desktop-ui/preferences/profile persist local profile preferences
* GET /api/desktop-ui/preferences/profile/avatar read local profile avatar
* PUT /api/desktop-ui/preferences/profile/avatar persist local profile avatar
* DELETE /api/desktop-ui/preferences/profile/avatar reset local profile avatar
*/
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
@ -39,6 +43,56 @@ export async function handleDesktopUiApi(
})
}
if (detail === 'profile') {
const action = segments[4]
if (action === undefined) {
if (req.method !== 'PUT') throw methodNotAllowed(req.method)
const body = await parseJsonBody(req)
return Response.json({
ok: true,
preferences: await desktopUiPreferencesService.updateProfilePreferences(body),
})
}
if (action === 'avatar') {
if (req.method === 'GET') {
const avatar = await desktopUiPreferencesService.readProfileAvatar()
if (!avatar) {
throw ApiError.notFound('Profile avatar is not configured')
}
return new Response(avatar.bytes, {
headers: {
'Content-Type': avatar.contentType,
'Cache-Control': 'no-store',
},
})
}
if (req.method === 'PUT') {
const bytes = new Uint8Array(await req.arrayBuffer())
return Response.json({
ok: true,
preferences: await desktopUiPreferencesService.updateProfileAvatar(
bytes,
req.headers.get('Content-Type'),
),
})
}
if (req.method === 'DELETE') {
return Response.json({
ok: true,
preferences: await desktopUiPreferencesService.clearProfileAvatar(),
})
}
throw methodNotAllowed(req.method)
}
throw ApiError.notFound(`Unknown desktop UI profile endpoint: ${action}`)
}
throw ApiError.notFound(`Unknown desktop UI preferences endpoint: ${detail}`)
} catch (error) {
return errorResponse(error)

View File

@ -6,8 +6,16 @@ import { ApiError } from '../middleware/errorHandler.js'
import { readRecoverableJsonFile } from './recoverableJsonFile.js'
import { ensurePersistentStorageUpgraded } from './persistentStorageMigrations.js'
const CURRENT_DESKTOP_UI_PREFERENCES_SCHEMA_VERSION = 1
const CURRENT_DESKTOP_UI_PREFERENCES_SCHEMA_VERSION = 2
const MAX_PROJECT_PREFERENCE_ENTRIES = 2_000
const MAX_PROFILE_DISPLAY_NAME_LENGTH = 80
const MAX_PROFILE_AVATAR_BYTES = 2_000_000
const AVATAR_CONTENT_TYPES = {
'image/png': { extension: 'png', mediaType: 'image/png' },
'image/jpeg': { extension: 'jpg', mediaType: 'image/jpeg' },
'image/webp': { extension: 'webp', mediaType: 'image/webp' },
} as const
export type SidebarProjectPreferences = {
projectOrder: string[]
@ -17,9 +25,16 @@ export type SidebarProjectPreferences = {
projectSortBy: 'createdAt' | 'updatedAt'
}
export type DesktopProfilePreferences = {
displayName: string
avatarFile: string | null
avatarUpdatedAt: string | null
}
export type DesktopUiPreferences = {
schemaVersion: number
sidebar: SidebarProjectPreferences
profile: DesktopProfilePreferences
[key: string]: unknown
}
@ -36,10 +51,17 @@ const DEFAULT_SIDEBAR_PROJECT_PREFERENCES: SidebarProjectPreferences = {
projectSortBy: 'updatedAt',
}
const DEFAULT_PROFILE_PREFERENCES: DesktopProfilePreferences = {
displayName: 'cc-haha',
avatarFile: null,
avatarUpdatedAt: null,
}
function defaultPreferences(): DesktopUiPreferences {
return {
schemaVersion: CURRENT_DESKTOP_UI_PREFERENCES_SCHEMA_VERSION,
sidebar: { ...DEFAULT_SIDEBAR_PROJECT_PREFERENCES },
profile: { ...DEFAULT_PROFILE_PREFERENCES },
}
}
@ -73,6 +95,32 @@ export function normalizeSidebarProjectPreferences(value: unknown): SidebarProje
}
}
function normalizeProfileDisplayName(value: unknown): string {
if (typeof value !== 'string') return DEFAULT_PROFILE_PREFERENCES.displayName
const trimmed = value.trim().replace(/\s+/g, ' ')
if (trimmed.length === 0) return DEFAULT_PROFILE_PREFERENCES.displayName
return trimmed.slice(0, MAX_PROFILE_DISPLAY_NAME_LENGTH)
}
function normalizeAvatarFile(value: unknown): string | null {
if (typeof value !== 'string') return null
if (!/^profile\/avatar\.(png|jpg|webp)$/.test(value)) return null
return value
}
function normalizeProfilePreferences(value: unknown): DesktopProfilePreferences {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return { ...DEFAULT_PROFILE_PREFERENCES }
}
const record = value as Record<string, unknown>
return {
displayName: normalizeProfileDisplayName(record.displayName),
avatarFile: normalizeAvatarFile(record.avatarFile),
avatarUpdatedAt: typeof record.avatarUpdatedAt === 'string' ? record.avatarUpdatedAt : null,
}
}
function normalizeProjectOrganization(value: unknown): SidebarProjectPreferences['projectOrganization'] {
return value === 'project' || value === 'recentProject' || value === 'time' ? value : 'recentProject'
}
@ -91,6 +139,7 @@ function normalizeDesktopUiPreferences(value: unknown): DesktopUiPreferences | n
...record,
schemaVersion: CURRENT_DESKTOP_UI_PREFERENCES_SCHEMA_VERSION,
sidebar: normalizeSidebarProjectPreferences(record.sidebar),
profile: normalizeProfilePreferences(record.profile),
}
}
@ -111,6 +160,18 @@ export class DesktopUiPreferencesService {
return path.join(this.getConfigDir(), 'cc-haha', 'desktop-ui.json')
}
private getProfileDir(): string {
return path.join(this.getConfigDir(), 'cc-haha', 'profile')
}
private getProfileAvatarPath(avatarFile: string): string {
const normalized = normalizeAvatarFile(avatarFile)
if (!normalized) {
throw ApiError.badRequest('Invalid avatar file path')
}
return path.join(this.getConfigDir(), 'cc-haha', normalized)
}
private async fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath)
@ -183,10 +244,135 @@ export class DesktopUiPreferencesService {
...preferences,
schemaVersion: CURRENT_DESKTOP_UI_PREFERENCES_SCHEMA_VERSION,
sidebar: normalizeSidebarProjectPreferences(sidebar),
profile: normalizeProfilePreferences(preferences.profile),
}
await this.writePreferences(nextPreferences)
return nextPreferences
})
}
async updateProfilePreferences(profile: unknown): Promise<DesktopUiPreferences> {
const filePath = this.getPreferencesPath()
return this.withWriteLock(filePath, async () => {
const { preferences } = await this.readPreferences()
const currentProfile = normalizeProfilePreferences(preferences.profile)
const patch = profile && typeof profile === 'object' && !Array.isArray(profile)
? profile as Record<string, unknown>
: {}
const nextProfile = normalizeProfilePreferences({
...currentProfile,
displayName: Object.prototype.hasOwnProperty.call(patch, 'displayName')
? patch.displayName
: currentProfile.displayName,
})
const nextPreferences: DesktopUiPreferences = {
...preferences,
schemaVersion: CURRENT_DESKTOP_UI_PREFERENCES_SCHEMA_VERSION,
sidebar: normalizeSidebarProjectPreferences(preferences.sidebar),
profile: {
...nextProfile,
avatarFile: currentProfile.avatarFile,
avatarUpdatedAt: currentProfile.avatarUpdatedAt,
},
}
await this.writePreferences(nextPreferences)
return nextPreferences
})
}
async updateProfileAvatar(bytes: Uint8Array, contentType: string | null): Promise<DesktopUiPreferences> {
const type = contentType?.split(';')[0]?.trim().toLowerCase()
const avatarType = type ? AVATAR_CONTENT_TYPES[type as keyof typeof AVATAR_CONTENT_TYPES] : undefined
if (!avatarType) {
throw ApiError.badRequest('Unsupported avatar type')
}
if (bytes.byteLength > MAX_PROFILE_AVATAR_BYTES) {
throw ApiError.badRequest('Avatar image is too large')
}
const filePath = this.getPreferencesPath()
return this.withWriteLock(filePath, async () => {
const { preferences } = await this.readPreferences()
const profileDir = this.getProfileDir()
const avatarFile = `profile/avatar.${avatarType.extension}`
const avatarPath = this.getProfileAvatarPath(avatarFile)
const tmpFile = `${avatarPath}.tmp.${process.pid}.${Date.now()}.${randomBytes(6).toString('hex')}`
await fs.mkdir(profileDir, { recursive: true })
try {
await fs.writeFile(tmpFile, bytes)
await fs.rename(tmpFile, avatarPath)
} catch (error) {
await fs.unlink(tmpFile).catch(() => {})
throw ApiError.internal(`Failed to write profile avatar: ${error}`)
}
await Promise.all(
Object.values(AVATAR_CONTENT_TYPES)
.filter((candidate) => candidate.extension !== avatarType.extension)
.map((candidate) => fs.unlink(path.join(profileDir, `avatar.${candidate.extension}`)).catch(() => {})),
)
const nextPreferences: DesktopUiPreferences = {
...preferences,
schemaVersion: CURRENT_DESKTOP_UI_PREFERENCES_SCHEMA_VERSION,
sidebar: normalizeSidebarProjectPreferences(preferences.sidebar),
profile: {
...normalizeProfilePreferences(preferences.profile),
avatarFile,
avatarUpdatedAt: new Date().toISOString(),
},
}
await this.writePreferences(nextPreferences)
return nextPreferences
})
}
async clearProfileAvatar(): Promise<DesktopUiPreferences> {
const filePath = this.getPreferencesPath()
return this.withWriteLock(filePath, async () => {
const { preferences } = await this.readPreferences()
const profileDir = this.getProfileDir()
await Promise.all(
Object.values(AVATAR_CONTENT_TYPES)
.map((candidate) => fs.unlink(path.join(profileDir, `avatar.${candidate.extension}`)).catch(() => {})),
)
const nextPreferences: DesktopUiPreferences = {
...preferences,
schemaVersion: CURRENT_DESKTOP_UI_PREFERENCES_SCHEMA_VERSION,
sidebar: normalizeSidebarProjectPreferences(preferences.sidebar),
profile: {
...normalizeProfilePreferences(preferences.profile),
avatarFile: null,
avatarUpdatedAt: null,
},
}
await this.writePreferences(nextPreferences)
return nextPreferences
})
}
async readProfileAvatar(): Promise<{ bytes: Uint8Array; contentType: string } | null> {
const { preferences } = await this.readPreferences()
const avatarFile = normalizeAvatarFile(preferences.profile.avatarFile)
if (!avatarFile) return null
const extension = path.extname(avatarFile).slice(1)
const contentType = Object.values(AVATAR_CONTENT_TYPES).find((candidate) => candidate.extension === extension)?.mediaType
if (!contentType) return null
try {
return {
bytes: await fs.readFile(this.getProfileAvatarPath(avatarFile)),
contentType,
}
} catch (error) {
if (errnoCode(error) === 'ENOENT') return null
throw ApiError.internal(`Failed to read profile avatar: ${error}`)
}
}
}