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