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,