From 587922ba75c7b79b219e6ab0088b1f7ba0d4c75d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sun, 31 May 2026 15:42:44 +0800 Subject: [PATCH] fix(desktop): refine activity profile editing The token usage profile header had too much top spacing, the edit action competed with the content from the top-right corner, and the profile subtitle was fixed to the default project link. This keeps the profile controls close to the identity block, moves the modal scrim to the document body so it covers the whole desktop shell, and persists a user-editable second line with URL auto-linking. Constraint: Preserve existing profile preferences and backfill older records that do not have a subtitle field. Rejected: Keep the top-right edit button | it kept the action visually detached from the profile content. Rejected: Store the second line only in desktop local state | the preference already has a server-backed profile shape. Confidence: high Scope-risk: narrow Directive: Keep profile preference schema changes backward-compatible with older desktop-ui preference files. Tested: cd desktop && bun run test --run src/pages/ActivitySettings.test.tsx src/api/desktopUiPreferences.test.ts Tested: bun test src/server/__tests__/desktop-ui-preferences.test.ts Tested: bun run check:persistence-upgrade Tested: bun run check:server Tested: bun run check:desktop Tested: git diff --check Not-tested: Full Tauri native shell smoke; renderer flow was smoke-tested in browser only. --- desktop/src/api/desktopUiPreferences.test.ts | 8 +- desktop/src/api/desktopUiPreferences.ts | 3 +- desktop/src/i18n/locales/en.ts | 3 +- desktop/src/i18n/locales/zh.ts | 3 +- desktop/src/pages/ActivitySettings.test.tsx | 18 ++- desktop/src/pages/ActivitySettings.tsx | 115 ++++++++++++------ .../__tests__/desktop-ui-preferences.test.ts | 10 ++ .../services/desktopUiPreferencesService.ts | 15 +++ 8 files changed, 131 insertions(+), 44 deletions(-) diff --git a/desktop/src/api/desktopUiPreferences.test.ts b/desktop/src/api/desktopUiPreferences.test.ts index 709cbaa8..10a2ed27 100644 --- a/desktop/src/api/desktopUiPreferences.test.ts +++ b/desktop/src/api/desktopUiPreferences.test.ts @@ -6,6 +6,7 @@ const preferences = { schemaVersion: 2, profile: { displayName: 'cc-haha', + subtitle: 'github.com/NanmiCoder/cc-haha', avatarFile: null, avatarUpdatedAt: null, }, @@ -43,7 +44,10 @@ describe('desktopUiPreferencesApi', () => { })) await expect(desktopUiPreferencesApi.getPreferences()).resolves.toEqual({ exists: true, preferences }) - await expect(desktopUiPreferencesApi.updateProfilePreferences({ displayName: 'Local Captain' })).resolves.toEqual({ + await expect(desktopUiPreferencesApi.updateProfilePreferences({ + displayName: 'Local Captain', + subtitle: 'local.example', + })).resolves.toEqual({ ok: true, preferences, }) @@ -54,7 +58,7 @@ describe('desktopUiPreferencesApi', () => { })) 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' }), + body: JSON.stringify({ displayName: 'Local Captain', subtitle: 'local.example' }), })) expect(fetchMock).toHaveBeenNthCalledWith(3, 'http://127.0.0.1:49237/api/desktop-ui/preferences/profile/avatar', expect.objectContaining({ method: 'DELETE', diff --git a/desktop/src/api/desktopUiPreferences.ts b/desktop/src/api/desktopUiPreferences.ts index 1b913aa8..8ce4223b 100644 --- a/desktop/src/api/desktopUiPreferences.ts +++ b/desktop/src/api/desktopUiPreferences.ts @@ -10,6 +10,7 @@ export type SidebarProjectPreferences = { export type DesktopProfilePreferences = { displayName: string + subtitle: string avatarFile: string | null avatarUpdatedAt: string | null } @@ -37,7 +38,7 @@ export const desktopUiPreferencesApi = { ) }, - updateProfilePreferences(profile: Pick) { + updateProfilePreferences(profile: Pick) { return api.put<{ ok: true; preferences: DesktopUiPreferences }>( '/api/desktop-ui/preferences/profile', profile, diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 14a89160..bf497e40 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -185,7 +185,8 @@ export const en = { '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.subtitle': 'Second line', + 'settings.activity.displayNameHelper': 'Edit the display name and second line; URLs in the second line open as links.', 'settings.activity.avatar': 'Avatar', 'settings.activity.avatarHelper': 'PNG, JPEG, or WebP up to 2 MB.', 'settings.activity.changeAvatar': 'Change avatar', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index c2a4b673..c91d3038 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -187,7 +187,8 @@ export const zh: Record = { 'settings.activity.defaultHandle': 'github.com/NanmiCoder/cc-haha', 'settings.activity.editProfile': '编辑个人资料', 'settings.activity.displayName': '显示名称', - 'settings.activity.displayNameHelper': '这里只修改主名称,第二行保持为项目链接。', + 'settings.activity.subtitle': '第二行', + 'settings.activity.displayNameHelper': '修改主名称和第二行;第二行如果是网址会自动作为链接打开。', 'settings.activity.avatar': '头像', 'settings.activity.avatarHelper': '支持 PNG、JPEG 或 WebP,最大 2 MB。', 'settings.activity.changeAvatar': '更换头像', diff --git a/desktop/src/pages/ActivitySettings.test.tsx b/desktop/src/pages/ActivitySettings.test.tsx index 40c74a14..b2dcb1bd 100644 --- a/desktop/src/pages/ActivitySettings.test.tsx +++ b/desktop/src/pages/ActivitySettings.test.tsx @@ -93,6 +93,7 @@ describe('ActivitySettings', () => { schemaVersion: 2, profile: { displayName: 'cc-haha', + subtitle: 'github.com/NanmiCoder/cc-haha', avatarFile: null, avatarUpdatedAt: null, }, @@ -111,6 +112,7 @@ describe('ActivitySettings', () => { schemaVersion: 2, profile: { displayName: profile.displayName, + subtitle: profile.subtitle, avatarFile: null, avatarUpdatedAt: null, }, @@ -129,6 +131,7 @@ describe('ActivitySettings', () => { schemaVersion: 2, profile: { displayName: 'cc-haha', + subtitle: 'github.com/NanmiCoder/cc-haha', avatarFile: 'profile/avatar.png', avatarUpdatedAt: '2026-05-09T12:00:00.000Z', }, @@ -147,6 +150,7 @@ describe('ActivitySettings', () => { schemaVersion: 2, profile: { displayName: 'cc-haha', + subtitle: 'github.com/NanmiCoder/cc-haha', avatarFile: null, avatarUpdatedAt: null, }, @@ -238,12 +242,17 @@ describe('ActivitySettings', () => { fireEvent.click(screen.getByRole('button', { name: '编辑个人资料' })) const input = screen.getByLabelText('显示名称') fireEvent.change(input, { target: { value: '本地舰长' } }) + fireEvent.change(screen.getByLabelText('第二行'), { target: { value: 'relakkes.dev' } }) fireEvent.click(screen.getByRole('button', { name: '保存' })) await flushActivityLoad() - expect(updateProfilePreferencesMock).toHaveBeenCalledWith({ displayName: '本地舰长' }) + expect(updateProfilePreferencesMock).toHaveBeenCalledWith({ + displayName: '本地舰长', + subtitle: 'relakkes.dev', + }) expect(screen.getByText('本地舰长')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'relakkes.dev' })).toHaveAttribute('href', 'https://relakkes.dev') }) it('handles avatar upload, fallback, removal, save failure, and cancel reset', async () => { @@ -253,6 +262,7 @@ describe('ActivitySettings', () => { schemaVersion: 2, profile: { displayName: 'Local Captain', + subtitle: 'Local workspace', avatarFile: 'profile/avatar.webp', avatarUpdatedAt: '2026-05-09T12:00:00.000Z', }, @@ -266,7 +276,7 @@ describe('ActivitySettings', () => { }, }) updateProfilePreferencesMock.mockRejectedValueOnce(new Error('display name rejected')) - const { container } = render() + render() await flushActivityLoad() @@ -286,12 +296,14 @@ describe('ActivitySettings', () => { expect(screen.getByText('display name rejected')).toBeInTheDocument() fireEvent.change(screen.getByLabelText('Display name'), { target: { value: 'Unsaved Name' } }) + fireEvent.change(screen.getByLabelText('Second line'), { target: { value: 'Unsaved subtitle' } }) 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') + expect(screen.getByLabelText('Second line')).toHaveValue('Local workspace') - const input = container.querySelector('input[type="file"]') as HTMLInputElement + const input = document.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] } }) diff --git a/desktop/src/pages/ActivitySettings.tsx b/desktop/src/pages/ActivitySettings.tsx index b40a6d4c..47c77223 100644 --- a/desktop/src/pages/ActivitySettings.tsx +++ b/desktop/src/pages/ActivitySettings.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState, type ChangeEvent } from 'react' +import { createPortal } from 'react-dom' import { activityStatsApi, type ActivityStatsResponse, type DailyActivity } from '../api/activityStats' import { desktopUiPreferencesApi, @@ -52,10 +53,10 @@ const DATE_LOCALES: Record = { } const DEFAULT_PROFILE: DesktopProfilePreferences = { displayName: 'cc-haha', + subtitle: 'github.com/NanmiCoder/cc-haha', avatarFile: null, avatarUpdatedAt: null, } -const PROFILE_LINK_URL = 'https://github.com/NanmiCoder/cc-haha' function localDateKey(date: Date) { const year = date.getFullYear() @@ -125,6 +126,16 @@ function formatMessageCount(value: number, t: ReturnType) return `${value} ${t('settings.activity.messages')}` } +function withProfileDefaults(profile: Partial | null | undefined): DesktopProfilePreferences { + return { ...DEFAULT_PROFILE, ...profile } +} + +function getProfileSubtitleHref(subtitle: string) { + if (/^https?:\/\//i.test(subtitle)) return subtitle + if (/^[\w.-]+\.[a-z]{2,}(?:\/.*)?$/i.test(subtitle)) return `https://${subtitle}` + return null +} + 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))) @@ -341,6 +352,7 @@ export function ActivitySettings() { const [isEditingProfile, setIsEditingProfile] = useState(false) const [isSavingProfile, setIsSavingProfile] = useState(false) const [draftDisplayName, setDraftDisplayName] = useState(DEFAULT_PROFILE.displayName) + const [draftSubtitle, setDraftSubtitle] = useState(DEFAULT_PROFILE.subtitle) const [heatmapMode, setHeatmapMode] = useState('daily') const [hoveredDate, setHoveredDate] = useState(null) const [focusedDate, setFocusedDate] = useState(null) @@ -377,9 +389,10 @@ export function ActivitySettings() { desktopUiPreferencesApi.getPreferences() .then((result) => { if (cancelled) return - const nextProfile = result.preferences.profile ?? DEFAULT_PROFILE + const nextProfile = withProfileDefaults(result.preferences.profile) setProfile(nextProfile) setDraftDisplayName(nextProfile.displayName) + setDraftSubtitle(nextProfile.subtitle) }) .catch((err) => { if (cancelled) return @@ -480,6 +493,7 @@ export function ActivitySettings() { const avatarClassName = profile.avatarFile ? 'h-full w-full object-cover' : 'h-full w-full scale-[1.28] object-contain transition-transform' + const profileSubtitleHref = getProfileSubtitleHref(profile.subtitle) 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') }, @@ -492,9 +506,14 @@ export function ActivitySettings() { setProfileError(null) setProfileStatus(null) try { - const result = await desktopUiPreferencesApi.updateProfilePreferences({ displayName: draftDisplayName }) - setProfile(result.preferences.profile) - setDraftDisplayName(result.preferences.profile.displayName) + const result = await desktopUiPreferencesApi.updateProfilePreferences({ + displayName: draftDisplayName, + subtitle: draftSubtitle, + }) + const nextProfile = withProfileDefaults(result.preferences.profile) + setProfile(nextProfile) + setDraftDisplayName(nextProfile.displayName) + setDraftSubtitle(nextProfile.subtitle) setIsEditingProfile(false) setProfileStatus(t('settings.activity.profileSaved')) } catch (err) { @@ -513,8 +532,10 @@ export function ActivitySettings() { setProfileStatus(null) try { const result = await desktopUiPreferencesApi.uploadProfileAvatar(file) - setProfile(result.preferences.profile) - setDraftDisplayName(result.preferences.profile.displayName) + const nextProfile = withProfileDefaults(result.preferences.profile) + setProfile(nextProfile) + setDraftDisplayName(nextProfile.displayName) + setDraftSubtitle(nextProfile.subtitle) setProfileStatus(t('settings.activity.profileSaved')) } catch (err) { setProfileError(err instanceof Error ? err.message : t('settings.activity.profileSaveFailed')) @@ -529,7 +550,7 @@ export function ActivitySettings() { setProfileStatus(null) try { const result = await desktopUiPreferencesApi.deleteProfileAvatar() - setProfile(result.preferences.profile) + setProfile(withProfileDefaults(result.preferences.profile)) setProfileStatus(t('settings.activity.profileSaved')) } catch (err) { setProfileError(err instanceof Error ? err.message : t('settings.activity.profileSaveFailed')) @@ -540,21 +561,8 @@ export function ActivitySettings() { return (
-
- - -
+
+
{`${profile.displayName}
-

{profile.displayName}

- - {t('settings.activity.defaultHandle')} - +
+

{profile.displayName}

+ +
+ {profileSubtitleHref ? ( + + {profile.subtitle} + + ) : ( +
{profile.subtitle}
+ )} {profileStatus &&
{profileStatus}
} {profileError && !isEditingProfile &&
{profileError}
}
-
+
{isLoading ? (
{Array.from({ length: 5 }).map((_, index) => ( @@ -602,8 +630,8 @@ export function ActivitySettings() { )}
- {isEditingProfile && ( -
+ {isEditingProfile && createPortal( +
@@ -616,6 +644,7 @@ export function ActivitySettings() { onClick={() => { setIsEditingProfile(false) setDraftDisplayName(profile.displayName) + setDraftSubtitle(profile.subtitle) setProfileError(null) }} aria-label={t('settings.activity.cancelEdit')} @@ -637,6 +666,18 @@ export function ActivitySettings() { />
+
+ + setDraftSubtitle(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)]" + /> +
+
{t('settings.activity.avatar')}

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

@@ -678,6 +719,7 @@ export function ActivitySettings() { onClick={() => { setIsEditingProfile(false) setDraftDisplayName(profile.displayName) + setDraftSubtitle(profile.subtitle) setProfileError(null) }} > @@ -693,7 +735,8 @@ export function ActivitySettings() {
-
+
, + document.body, )}
diff --git a/src/server/__tests__/desktop-ui-preferences.test.ts b/src/server/__tests__/desktop-ui-preferences.test.ts index f8c1a74f..65360cc1 100644 --- a/src/server/__tests__/desktop-ui-preferences.test.ts +++ b/src/server/__tests__/desktop-ui-preferences.test.ts @@ -59,6 +59,7 @@ describe('DesktopUiPreferencesService', () => { schemaVersion: 2, profile: { displayName: 'cc-haha', + subtitle: 'github.com/NanmiCoder/cc-haha', avatarFile: null, avatarUpdatedAt: null, }, @@ -101,6 +102,7 @@ describe('DesktopUiPreferencesService', () => { futureField: { keep: true }, profile: { displayName: 'cc-haha', + subtitle: 'github.com/NanmiCoder/cc-haha', avatarFile: null, avatarUpdatedAt: null, }, @@ -117,6 +119,7 @@ describe('DesktopUiPreferencesService', () => { futureField: { keep: true }, profile: { displayName: 'cc-haha', + subtitle: 'github.com/NanmiCoder/cc-haha', avatarFile: null, avatarUpdatedAt: null, }, @@ -149,6 +152,7 @@ describe('DesktopUiPreferencesService', () => { const service = new DesktopUiPreferencesService() const after = await service.updateProfilePreferences({ displayName: ' Claude Captain ', + subtitle: ' local.example/profile ', avatarFile: '../escape.png', avatarUpdatedAt: 42, }) @@ -157,6 +161,7 @@ describe('DesktopUiPreferencesService', () => { schemaVersion: 2, profile: { displayName: 'Claude Captain', + subtitle: 'local.example/profile', avatarFile: null, avatarUpdatedAt: null, }, @@ -226,6 +231,7 @@ describe('desktop UI preferences API', () => { schemaVersion: 2, profile: { displayName: 'cc-haha', + subtitle: 'github.com/NanmiCoder/cc-haha', avatarFile: null, avatarUpdatedAt: null, }, @@ -250,6 +256,7 @@ describe('desktop UI preferences API', () => { schemaVersion: 2, profile: { displayName: 'cc-haha', + subtitle: 'github.com/NanmiCoder/cc-haha', avatarFile: null, avatarUpdatedAt: null, }, @@ -267,6 +274,7 @@ 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 ', + subtitle: ' operator.example ', }) const profileRes = await handleDesktopUiApi(profileReq.req, profileReq.url, profileReq.segments) const profileBody = await profileRes.json() as Record @@ -277,6 +285,7 @@ describe('desktop UI preferences API', () => { preferences: { profile: { displayName: 'Local Operator', + subtitle: 'operator.example', avatarFile: null, }, }, @@ -297,6 +306,7 @@ describe('desktop UI preferences API', () => { preferences: { profile: { displayName: 'Local Operator', + subtitle: 'operator.example', avatarFile: 'profile/avatar.jpg', }, }, diff --git a/src/server/services/desktopUiPreferencesService.ts b/src/server/services/desktopUiPreferencesService.ts index 07b1bdb5..f72ccd51 100644 --- a/src/server/services/desktopUiPreferencesService.ts +++ b/src/server/services/desktopUiPreferencesService.ts @@ -9,7 +9,9 @@ import { ensurePersistentStorageUpgraded } from './persistentStorageMigrations.j 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_SUBTITLE_LENGTH = 160 const MAX_PROFILE_AVATAR_BYTES = 2_000_000 +const DEFAULT_PROFILE_SUBTITLE = 'github.com/NanmiCoder/cc-haha' const AVATAR_CONTENT_TYPES = { 'image/png': { extension: 'png', mediaType: 'image/png' }, @@ -27,6 +29,7 @@ export type SidebarProjectPreferences = { export type DesktopProfilePreferences = { displayName: string + subtitle: string avatarFile: string | null avatarUpdatedAt: string | null } @@ -53,6 +56,7 @@ const DEFAULT_SIDEBAR_PROJECT_PREFERENCES: SidebarProjectPreferences = { const DEFAULT_PROFILE_PREFERENCES: DesktopProfilePreferences = { displayName: 'cc-haha', + subtitle: DEFAULT_PROFILE_SUBTITLE, avatarFile: null, avatarUpdatedAt: null, } @@ -102,6 +106,13 @@ function normalizeProfileDisplayName(value: unknown): string { return trimmed.slice(0, MAX_PROFILE_DISPLAY_NAME_LENGTH) } +function normalizeProfileSubtitle(value: unknown): string { + if (typeof value !== 'string') return DEFAULT_PROFILE_PREFERENCES.subtitle + const trimmed = value.trim().replace(/\s+/g, ' ') + if (trimmed.length === 0) return DEFAULT_PROFILE_PREFERENCES.subtitle + return trimmed.slice(0, MAX_PROFILE_SUBTITLE_LENGTH) +} + function normalizeAvatarFile(value: unknown): string | null { if (typeof value !== 'string') return null if (!/^profile\/avatar\.(png|jpg|webp)$/.test(value)) return null @@ -116,6 +127,7 @@ function normalizeProfilePreferences(value: unknown): DesktopProfilePreferences const record = value as Record return { displayName: normalizeProfileDisplayName(record.displayName), + subtitle: normalizeProfileSubtitle(record.subtitle), avatarFile: normalizeAvatarFile(record.avatarFile), avatarUpdatedAt: typeof record.avatarUpdatedAt === 'string' ? record.avatarUpdatedAt : null, } @@ -265,6 +277,9 @@ export class DesktopUiPreferencesService { displayName: Object.prototype.hasOwnProperty.call(patch, 'displayName') ? patch.displayName : currentProfile.displayName, + subtitle: Object.prototype.hasOwnProperty.call(patch, 'subtitle') + ? patch.subtitle + : currentProfile.subtitle, }) const nextPreferences: DesktopUiPreferences = { ...preferences,