diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index 604a1a37..cf9cecfe 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -144,6 +144,7 @@ describe('Settings > General tab', () => { skipWebFetchPreflight: true, desktopNotificationsEnabled: true, responseLanguage: '', + uiZoom: 1, webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' }, h5Access: { enabled: false, @@ -167,6 +168,9 @@ describe('Settings > General tab', () => { setResponseLanguage: vi.fn().mockImplementation(async (language: string) => { useSettingsStore.setState({ responseLanguage: language }) }), + setUiZoom: vi.fn().mockImplementation((uiZoom: number) => { + useSettingsStore.setState({ uiZoom }) + }), setWebSearch: vi.fn().mockImplementation(async (webSearch) => { useSettingsStore.setState({ webSearch }) }), @@ -251,6 +255,26 @@ describe('Settings > General tab', () => { expect(screen.getByRole('button', { name: 'Warm Classic' })).toHaveAttribute('aria-pressed', 'false') }) + it('updates the shared UI zoom setting from the General display control', async () => { + render() + + fireEvent.click(screen.getByText('General')) + await act(async () => { + fireEvent.change(screen.getByLabelText('UI Zoom'), { + target: { value: '1.25', valueAsNumber: 1.25 }, + }) + }) + + expect(useSettingsStore.getState().setUiZoom).toHaveBeenCalledWith(1.25) + expect(screen.getByText('125%')).toBeInTheDocument() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Reset UI zoom to 100%' })) + }) + + expect(useSettingsStore.getState().setUiZoom).toHaveBeenLastCalledWith(1) + }) + it('opens the Token usage tab from Settings navigation above Diagnostics', () => { render() diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index 2ce5b470..7c9247c5 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -593,7 +593,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { )} {!isMobile && ( -
+
{ document.documentElement.removeAttribute('data-app-zoom-percent') document.documentElement.style.removeProperty('--app-zoom') document.body.style.removeProperty('zoom') + useSettingsStore.setState({ uiZoom: 1 }) setNavigatorPlatform('Win32') }) @@ -41,6 +43,7 @@ describe('useKeyboardShortcuts app zoom', () => { await waitFor(() => { expect(window.localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe('1.1') }) + expect(useSettingsStore.getState().uiZoom).toBe(1.1) expect(document.documentElement.getAttribute('data-app-zoom-percent')).toBe('110') fireEvent.keyDown(document, { @@ -52,6 +55,7 @@ describe('useKeyboardShortcuts app zoom', () => { await waitFor(() => { expect(window.localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe('1') }) + expect(useSettingsStore.getState().uiZoom).toBe(1) fireEvent.keyDown(document, { code: 'NumpadAdd', diff --git a/desktop/src/hooks/useKeyboardShortcuts.ts b/desktop/src/hooks/useKeyboardShortcuts.ts index c58d2705..282dc99e 100644 --- a/desktop/src/hooks/useKeyboardShortcuts.ts +++ b/desktop/src/hooks/useKeyboardShortcuts.ts @@ -4,12 +4,10 @@ import { useChatStore } from '../stores/chatStore' import { useTabStore } from '../stores/tabStore' import { useUIStore } from '../stores/uiStore' import { - applyAppZoomLevel, getAppZoomKeyboardAction, - initializeAppZoom, nextAppZoomLevel, - readStoredAppZoomLevel, } from '../lib/appZoom' +import { useSettingsStore } from '../stores/settingsStore' export function useKeyboardShortcuts() { const setActiveSession = useSessionStore((s) => s.setActiveSession) @@ -20,6 +18,8 @@ export function useKeyboardShortcuts() { const stopGeneration = useChatStore((s) => s.stopGeneration) const activeTabId = useTabStore((s) => s.activeTabId) const chatState = useChatStore((s) => activeTabId ? s.sessions[activeTabId]?.chatState ?? 'idle' : 'idle') + const uiZoom = useSettingsStore((s) => s.uiZoom) + const setUiZoom = useSettingsStore((s) => s.setUiZoom) const activeModalRef = useRef(activeModal) activeModalRef.current = activeModal @@ -27,18 +27,17 @@ export function useKeyboardShortcuts() { chatStateRef.current = chatState const activeTabIdRef = useRef(activeTabId) activeTabIdRef.current = activeTabId - const appZoomLevelRef = useRef(readStoredAppZoomLevel()) + const appZoomLevelRef = useRef(uiZoom) + appZoomLevelRef.current = uiZoom useEffect(() => { - void initializeAppZoom() - const handler = (e: KeyboardEvent) => { const zoomAction = getAppZoomKeyboardAction(e) if (zoomAction) { e.preventDefault() const nextZoom = nextAppZoomLevel(appZoomLevelRef.current, zoomAction) appZoomLevelRef.current = nextZoom - void applyAppZoomLevel(nextZoom) + setUiZoom(nextZoom) return } @@ -80,5 +79,5 @@ export function useKeyboardShortcuts() { document.addEventListener('keydown', handler) return () => document.removeEventListener('keydown', handler) - }, [closeModal, setActiveSession, setActiveView, setSidebarOpen, stopGeneration]) + }, [closeModal, setActiveSession, setActiveView, setSidebarOpen, setUiZoom, stopGeneration]) } diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index bc566043..66c8f129 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -801,6 +801,9 @@ export const en = { 'settings.general.webSearchBraveFreeHint': 'Create an account to generate a Search API key with free usage for testing.', 'settings.general.webSearchHint': 'Auto uses native Claude web search for Claude model names, then falls back to Tavily and Brave keys.', 'settings.general.webSearchSave': 'Save', + 'settings.general.uiZoom': 'UI Zoom', + 'settings.general.uiZoomDescription': 'Adjust the size of the entire interface.', + 'settings.general.uiZoomReset': 'Reset UI zoom to 100%', // ─── Empty Session ────────────────────────────────────── 'empty.title': 'New session', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index ccad1be7..704e5bc3 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -803,6 +803,9 @@ export const zh: Record = { 'settings.general.webSearchBraveFreeHint': '注册账号后可创建 Search API Key,免费额度可用于测试。', 'settings.general.webSearchHint': '自动模式会对 Claude 模型名优先使用原生 WebSearch,失败或非 Claude 模型时再使用 Tavily/Brave。', 'settings.general.webSearchSave': '保存', + 'settings.general.uiZoom': '界面缩放', + 'settings.general.uiZoomDescription': '调整整个界面的显示大小。', + 'settings.general.uiZoomReset': '重置界面缩放到 100%', // ─── Empty Session ────────────────────────────────────── 'empty.title': '新建会话', diff --git a/desktop/src/lib/appZoom.test.ts b/desktop/src/lib/appZoom.test.ts index b641183d..7e5196fc 100644 --- a/desktop/src/lib/appZoom.test.ts +++ b/desktop/src/lib/appZoom.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it } from 'vitest' import { APP_ZOOM_STORAGE_KEY, + LEGACY_UI_ZOOM_STORAGE_KEY, applyAppZoomLevel, getAppZoomKeyboardAction, initializeAppZoom, @@ -39,6 +40,15 @@ describe('appZoom', () => { expect(window.localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe('1.2') }) + it('reads the legacy UI zoom key when the app zoom key is absent', async () => { + window.localStorage.setItem(LEGACY_UI_ZOOM_STORAGE_KEY, '1.25') + + await initializeAppZoom() + + expect(document.documentElement.getAttribute('data-app-zoom-percent')).toBe('125') + expect(window.localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBeNull() + }) + it('persists app zoom changes', async () => { await applyAppZoomLevel(1.3) diff --git a/desktop/src/lib/appZoom.ts b/desktop/src/lib/appZoom.ts index b8ac9d02..3127d42c 100644 --- a/desktop/src/lib/appZoom.ts +++ b/desktop/src/lib/appZoom.ts @@ -1,8 +1,10 @@ export const APP_ZOOM_STORAGE_KEY = 'cc-haha-app-zoom' +export const LEGACY_UI_ZOOM_STORAGE_KEY = 'cc-haha-ui-zoom' export const DEFAULT_APP_ZOOM = 1 export const MIN_APP_ZOOM = 0.5 export const MAX_APP_ZOOM = 2 export const APP_ZOOM_STEP = 0.1 +export const APP_ZOOM_CONTROL_STEP = 0.05 export type AppZoomAction = 'in' | 'out' | 'reset' @@ -45,7 +47,9 @@ export function isValidStoredAppZoomLevel(value: string | null): boolean { export function readStoredAppZoomLevel(storage: StorageLike | null = getDefaultStorage()): number { if (!storage) return DEFAULT_APP_ZOOM try { - return normalizeAppZoomLevel(storage.getItem(APP_ZOOM_STORAGE_KEY)) + const stored = storage.getItem(APP_ZOOM_STORAGE_KEY) + if (stored !== null) return normalizeAppZoomLevel(stored) + return normalizeAppZoomLevel(storage.getItem(LEGACY_UI_ZOOM_STORAGE_KEY)) } catch { return DEFAULT_APP_ZOOM } diff --git a/desktop/src/lib/doctorRepair.ts b/desktop/src/lib/doctorRepair.ts index 55b47907..e287d624 100644 --- a/desktop/src/lib/doctorRepair.ts +++ b/desktop/src/lib/doctorRepair.ts @@ -1,5 +1,5 @@ import { doctorApi, type DoctorReportRepairResponse } from '../api/doctor' -import { APP_ZOOM_STORAGE_KEY } from './appZoom' +import { APP_ZOOM_STORAGE_KEY, LEGACY_UI_ZOOM_STORAGE_KEY } from './appZoom' import { DESKTOP_PERSISTENCE_VERSION_KEY } from './persistenceMigrations' export const SAFE_DOCTOR_STORAGE_KEYS = [ @@ -8,6 +8,7 @@ export const SAFE_DOCTOR_STORAGE_KEYS = [ 'cc-haha-theme', 'cc-haha-locale', APP_ZOOM_STORAGE_KEY, + LEGACY_UI_ZOOM_STORAGE_KEY, DESKTOP_PERSISTENCE_VERSION_KEY, ] as const diff --git a/desktop/src/lib/persistenceMigrations.test.ts b/desktop/src/lib/persistenceMigrations.test.ts index 397f8bc6..de466cb0 100644 --- a/desktop/src/lib/persistenceMigrations.test.ts +++ b/desktop/src/lib/persistenceMigrations.test.ts @@ -81,6 +81,19 @@ describe('desktop persistence migrations', () => { expect(window.localStorage.getItem('cc-haha-app-zoom')).toBeNull() }) + test('migrates the legacy UI zoom key into app zoom storage', () => { + window.localStorage.setItem('cc-haha-ui-zoom', '1.25') + + const report = runDesktopPersistenceMigrations() + + expect(report.migratedKeys).toEqual(expect.arrayContaining([ + 'cc-haha-app-zoom', + 'cc-haha-ui-zoom', + ])) + expect(window.localStorage.getItem('cc-haha-app-zoom')).toBe('1.25') + expect(window.localStorage.getItem('cc-haha-ui-zoom')).toBeNull() + }) + test('does not throw if schema version persistence is blocked', () => { const storage = { getItem: window.localStorage.getItem.bind(window.localStorage), diff --git a/desktop/src/lib/persistenceMigrations.ts b/desktop/src/lib/persistenceMigrations.ts index f10a77b9..b6255cdc 100644 --- a/desktop/src/lib/persistenceMigrations.ts +++ b/desktop/src/lib/persistenceMigrations.ts @@ -1,5 +1,10 @@ import { THEME_MODES } from '../types/settings' -import { APP_ZOOM_STORAGE_KEY, isValidStoredAppZoomLevel } from './appZoom' +import { + APP_ZOOM_STORAGE_KEY, + LEGACY_UI_ZOOM_STORAGE_KEY, + isValidStoredAppZoomLevel, + normalizeAppZoomLevel, +} from './appZoom' export const CURRENT_DESKTOP_PERSISTENCE_SCHEMA_VERSION = 1 export const DESKTOP_PERSISTENCE_VERSION_KEY = 'cc-haha.persistence.schemaVersion' @@ -121,6 +126,17 @@ function normalizeAppZoomKey(storage: StorageLike, report: DesktopMigrationRepor storage.removeItem(APP_ZOOM_STORAGE_KEY) report.migratedKeys.push(APP_ZOOM_STORAGE_KEY) } + + const currentValue = storage.getItem(APP_ZOOM_STORAGE_KEY) + const legacyValue = storage.getItem(LEGACY_UI_ZOOM_STORAGE_KEY) + if (currentValue === null && legacyValue !== null && isValidStoredAppZoomLevel(legacyValue)) { + storage.setItem(APP_ZOOM_STORAGE_KEY, String(normalizeAppZoomLevel(legacyValue))) + report.migratedKeys.push(APP_ZOOM_STORAGE_KEY) + } + if (legacyValue !== null) { + storage.removeItem(LEGACY_UI_ZOOM_STORAGE_KEY) + report.migratedKeys.push(LEGACY_UI_ZOOM_STORAGE_KEY) + } } function runMigrationStep( diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index b239923f..c6a55b7c 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -1,9 +1,11 @@ import React from 'react' import ReactDOM from 'react-dom/client' import './theme/globals.css' +import { initializeAppZoom } from './lib/appZoom' import { runDesktopPersistenceMigrations } from './lib/persistenceMigrations' runDesktopPersistenceMigrations() +void initializeAppZoom() const [{ App }, { ErrorBoundary }, { installClientDiagnosticsCapture }, { initializeTheme }] = await Promise.all([ import('./App'), import('./components/ErrorBoundary'), diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 4c1ccd6f..01eb6e26 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useMemo, useRef, type ReactNode } from 'react' import QRCode from 'qrcode' import { Copy, Eye, EyeOff, PowerOff, QrCode, RotateCw } from 'lucide-react' -import { useSettingsStore } from '../stores/settingsStore' +import { useSettingsStore, UI_ZOOM_DEFAULT, UI_ZOOM_MIN, UI_ZOOM_MAX, UI_ZOOM_STEP } from '../stores/settingsStore' import { useProviderStore } from '../stores/providerStore' import { useTranslation } from '../i18n' import { Modal } from '../components/shared/Modal' @@ -1385,6 +1385,8 @@ function GeneralSettings() { setWebSearch, responseLanguage, setResponseLanguage, + uiZoom, + setUiZoom, } = useSettingsStore() const t = useTranslation() const [webSearchDraft, setWebSearchDraft] = useState(webSearch) @@ -1533,6 +1535,45 @@ function GeneralSettings() { ))}
+ {/* UI Zoom */} +
+
+
+

{t('settings.general.uiZoom')}

+

{t('settings.general.uiZoomDescription')}

+
+
+ + {Math.round(uiZoom * 100)}% + + +
+
+
+ {Math.round(UI_ZOOM_MIN * 100)}% + setUiZoom(e.currentTarget.valueAsNumber)} + className="flex-1" + /> + {Math.round(UI_ZOOM_MAX * 100)}% +
+
+ {/* Language selector */}

{t('settings.general.languageTitle')}

{t('settings.general.languageDescription')}

@@ -1778,7 +1819,6 @@ function GeneralSettings() {
- ) } diff --git a/desktop/src/stores/settingsStore.test.ts b/desktop/src/stores/settingsStore.test.ts index 77e7d8d4..f632dc79 100644 --- a/desktop/src/stores/settingsStore.test.ts +++ b/desktop/src/stores/settingsStore.test.ts @@ -22,6 +22,47 @@ describe('settingsStore locale defaults', () => { }) }) +describe('settingsStore UI zoom', () => { + beforeEach(() => { + vi.resetModules() + window.localStorage.clear() + document.documentElement.removeAttribute('data-app-zoom-percent') + document.documentElement.style.removeProperty('--app-zoom') + document.body.style.removeProperty('zoom') + }) + + it('hydrates from the app zoom storage key', async () => { + window.localStorage.setItem('cc-haha-app-zoom', '1.25') + + const { useSettingsStore } = await import('./settingsStore') + + expect(useSettingsStore.getState().uiZoom).toBe(1.25) + }) + + it('applies and persists UI zoom changes through the shared app zoom controller', async () => { + const { useSettingsStore } = await import('./settingsStore') + + useSettingsStore.getState().setUiZoom(1.25) + + await vi.waitFor(() => { + expect(window.localStorage.getItem('cc-haha-app-zoom')).toBe('1.25') + }) + expect(useSettingsStore.getState().uiZoom).toBe(1.25) + expect(document.documentElement.getAttribute('data-app-zoom-percent')).toBe('125') + }) + + it('clamps UI zoom changes to the supported range', async () => { + const { useSettingsStore } = await import('./settingsStore') + + useSettingsStore.getState().setUiZoom(9) + + await vi.waitFor(() => { + expect(window.localStorage.getItem('cc-haha-app-zoom')).toBe('2') + }) + expect(useSettingsStore.getState().uiZoom).toBe(2) + }) +}) + describe('settingsStore desktop notification persistence', () => { beforeEach(() => { vi.resetModules() diff --git a/desktop/src/stores/settingsStore.ts b/desktop/src/stores/settingsStore.ts index afdd4a9d..088319cb 100644 --- a/desktop/src/stores/settingsStore.ts +++ b/desktop/src/stores/settingsStore.ts @@ -5,9 +5,22 @@ import { modelsApi } from '../api/models' import { h5AccessApi } from '../api/h5Access' import { isThemeMode, type H5AccessSettings, type PermissionMode, type EffortLevel, type ModelInfo, type ThemeMode, type WebSearchSettings } from '../types/settings' import type { Locale } from '../i18n' +import { + APP_ZOOM_CONTROL_STEP, + DEFAULT_APP_ZOOM, + MAX_APP_ZOOM, + MIN_APP_ZOOM, + applyAppZoomLevel, + normalizeAppZoomLevel, + readStoredAppZoomLevel, +} from '../lib/appZoom' import { useUIStore } from './uiStore' const LOCALE_STORAGE_KEY = 'cc-haha-locale' +export const UI_ZOOM_MIN = MIN_APP_ZOOM +export const UI_ZOOM_MAX = MAX_APP_ZOOM +export const UI_ZOOM_STEP = APP_ZOOM_CONTROL_STEP +export const UI_ZOOM_DEFAULT = DEFAULT_APP_ZOOM let desktopNotificationsSaveQueue: Promise = Promise.resolve() function getStoredLocale(): Locale { @@ -33,6 +46,7 @@ type SettingsStore = { h5Access: H5AccessSettings h5AccessError: string | null responseLanguage: string + uiZoom: number isLoading: boolean error: string | null @@ -55,6 +69,7 @@ type SettingsStore = { publicBaseUrl?: string | null }) => Promise setResponseLanguage: (language: string) => Promise + setUiZoom: (zoom: number) => void } const DEFAULT_H5_ACCESS_SETTINGS: H5AccessSettings = { @@ -79,9 +94,16 @@ export const useSettingsStore = create((set, get) => ({ h5Access: DEFAULT_H5_ACCESS_SETTINGS, h5AccessError: null, responseLanguage: '', + uiZoom: readStoredAppZoomLevel(), isLoading: false, error: null, + setUiZoom: (zoom: number) => { + const level = normalizeAppZoomLevel(zoom) + set({ uiZoom: level }) + void applyAppZoomLevel(level) + }, + fetchAll: async () => { set({ isLoading: true, error: null }) try {