mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
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:
parent
eede5568d2
commit
3b1b96022f
112
desktop/src/api/desktopUiPreferences.test.ts
Normal file
112
desktop/src/api/desktopUiPreferences.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@ -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 }>
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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} 天',
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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); }
|
||||
|
||||
@ -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')
|
||||
|
||||
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user