From 4b62d354d63ae43c8dd24bfb73bc5c7b57003f85 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: Thu, 14 May 2026 17:49:57 +0800 Subject: [PATCH] Unify desktop zoom controls PR #428 added a General Settings zoom slider after the desktop shortcut work already introduced a native-first app zoom controller. Keeping both paths would create double scaling and stale UI state, so the slider now routes through the existing controller and store state while the app shell no longer applies a second CSS zoom. Constraint: UI zoom is device-local display state and should not be written into shared user settings. Rejected: Keep cc-haha-ui-zoom plus AppShell style.zoom | it conflicts with shortcut zoom and multiplies visual scale. Rejected: Persist zoom through /api/settings/user | it would sync display-specific state across machines. Confidence: high Scope-risk: moderate Directive: Keep app zoom behind desktop/src/lib/appZoom.ts; do not add another storage key or DOM zoom application point without migration and shortcut sync tests. Tested: cd desktop && bun run test -- --run src/lib/appZoom.test.ts src/hooks/useKeyboardShortcuts.test.tsx src/stores/settingsStore.test.ts src/__tests__/generalSettings.test.tsx src/lib/persistenceMigrations.test.ts src/lib/doctorRepair.test.ts Tested: bun run check:desktop Not-tested: Real Windows/Linux desktop runtime smoke. --- .../src/__tests__/generalSettings.test.tsx | 24 +++++++ desktop/src/components/layout/AppShell.tsx | 3 +- .../src/hooks/useKeyboardShortcuts.test.tsx | 4 ++ desktop/src/hooks/useKeyboardShortcuts.ts | 15 ++-- desktop/src/i18n/locales/en.ts | 3 +- desktop/src/i18n/locales/zh.ts | 3 +- desktop/src/lib/appZoom.test.ts | 10 +++ desktop/src/lib/appZoom.ts | 6 +- desktop/src/lib/doctorRepair.ts | 3 +- desktop/src/lib/persistenceMigrations.test.ts | 13 ++++ desktop/src/lib/persistenceMigrations.ts | 18 ++++- desktop/src/main.tsx | 2 + desktop/src/pages/Settings.tsx | 68 +++++++++++-------- desktop/src/stores/settingsStore.test.ts | 41 +++++++++++ desktop/src/stores/settingsStore.ts | 36 +++++----- desktop/src/types/settings.ts | 1 - 16 files changed, 187 insertions(+), 63 deletions(-) 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/AppShell.tsx b/desktop/src/components/layout/AppShell.tsx index af09547b..c9e578ab 100644 --- a/desktop/src/components/layout/AppShell.tsx +++ b/desktop/src/components/layout/AppShell.tsx @@ -28,7 +28,6 @@ function isChatTab(tab: Tab | undefined) { export function AppShell() { const fetchSettings = useSettingsStore((s) => s.fetchAll) - const uiZoom = useSettingsStore((s) => s.uiZoom) const sidebarOpen = useUIStore((s) => s.sidebarOpen) const toggleSidebar = useUIStore((s) => s.toggleSidebar) const setSidebarOpen = useUIStore((s) => s.setSidebarOpen) @@ -195,7 +194,7 @@ export function AppShell() { } return ( -
+
{isMobileShell && effectiveSidebarOpen ? (
+ {/* 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')}

@@ -1777,33 +1816,6 @@ function GeneralSettings() {
- {/* UI Zoom */} -
-

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

-

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

-
-
- - {Math.round(uiZoom * 100)}% - -
-
- {Math.round(UI_ZOOM_MIN * 100)}% - setUiZoom(parseFloat(e.target.value))} - onMouseUp={(e) => e.currentTarget.blur()} - className="flex-1" - /> - {Math.round(UI_ZOOM_MAX * 100)}% -
-
-
- ) } 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 81b7e9ee..088319cb 100644 --- a/desktop/src/stores/settingsStore.ts +++ b/desktop/src/stores/settingsStore.ts @@ -5,13 +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' -const UI_ZOOM_STORAGE_KEY = 'cc-haha-ui-zoom' -export const UI_ZOOM_MIN = 0.5 -export const UI_ZOOM_MAX = 2.0 -export const UI_ZOOM_STEP = 0.05 +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 { @@ -22,17 +31,6 @@ function getStoredLocale(): Locale { return 'zh' } -function getStoredUiZoom(): number { - try { - const stored = localStorage.getItem(UI_ZOOM_STORAGE_KEY) - if (stored === null) return 1.0 - const parsed = parseFloat(stored) - if (isNaN(parsed)) return 1.0 - return Math.min(UI_ZOOM_MAX, Math.max(UI_ZOOM_MIN, parsed)) - } catch { /* localStorage unavailable */ } - return 1.0 -} - type SettingsStore = { permissionMode: PermissionMode currentModel: ModelInfo | null @@ -96,14 +94,14 @@ export const useSettingsStore = create((set, get) => ({ h5Access: DEFAULT_H5_ACCESS_SETTINGS, h5AccessError: null, responseLanguage: '', - uiZoom: getStoredUiZoom(), + uiZoom: readStoredAppZoomLevel(), isLoading: false, error: null, setUiZoom: (zoom: number) => { - const clamped = Math.min(UI_ZOOM_MAX, Math.max(UI_ZOOM_MIN, zoom)) - set({ uiZoom: clamped }) - try { localStorage.setItem(UI_ZOOM_STORAGE_KEY, String(clamped)) } catch { /* noop */ } + const level = normalizeAppZoomLevel(zoom) + set({ uiZoom: level }) + void applyAppZoomLevel(level) }, fetchAll: async () => { diff --git a/desktop/src/types/settings.ts b/desktop/src/types/settings.ts index 7a2e5393..4a5b247a 100644 --- a/desktop/src/types/settings.ts +++ b/desktop/src/types/settings.ts @@ -43,6 +43,5 @@ export type UserSettings = { desktopNotificationsEnabled?: boolean webSearch?: WebSearchSettings language?: string - uiZoom?: number [key: string]: unknown }