From a381bf0412aeb0d04eb757bc04fc3fc3e8816b2a 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: Tue, 12 May 2026 20:37:21 +0800 Subject: [PATCH] Make pure white theme complete across desktop surfaces The pure white appearance option needed to avoid warm-theme leakage while preserving the existing warm classic brand theme. This adds the white theme mode, keeps local browser startup from reusing stale H5 server URLs in dev, and moves visible legacy warm surfaces onto theme tokens. Constraint: H5 server auth policy, CORS policy, SDK routes, adapter routes, and IM access paths must not change for a visual theme fix Rejected: Rename the original light theme to pure white | the original theme is a warm classic palette, not a neutral white workspace Confidence: high Scope-risk: moderate Directive: Keep structural white-theme borders neutral; reserve the warm brand color for selected states, primary actions, and small accents Tested: cd desktop && bun run test -- src/__tests__/generalSettings.test.tsx src/lib/desktopRuntime.test.ts src/lib/persistenceMigrations.test.ts src/stores/uiStore.test.ts src/stores/settingsStore.test.ts Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Tested: bun test src/server/__tests__/settings.test.ts src/server/__tests__/h5-access-policy.test.ts src/server/__tests__/h5-access-auth.test.ts src/server/middleware/cors.test.ts Tested: Browser smoke at http://127.0.0.1:5173 with data-theme=white, no H5 token prompt, /status inspector visible, inspector border #DDE3EA Not-tested: Full bun run verify gate --- desktop/index.html | 4 +- .../src/__tests__/generalSettings.test.tsx | 24 ++ .../chat/LocalSlashCommandPanel.tsx | 14 +- desktop/src/i18n/locales/en.ts | 5 +- desktop/src/i18n/locales/zh.ts | 5 +- desktop/src/lib/desktopRuntime.test.ts | 20 ++ desktop/src/lib/desktopRuntime.ts | 3 +- desktop/src/lib/persistenceMigrations.test.ts | 9 + desktop/src/lib/persistenceMigrations.ts | 4 +- desktop/src/pages/ActivitySettings.tsx | 14 +- desktop/src/pages/McpSettings.tsx | 8 +- desktop/src/pages/NewTaskModal.tsx | 42 ++-- desktop/src/pages/ScheduledTasksEmpty.tsx | 94 ++++---- desktop/src/pages/ScheduledTasksList.tsx | 220 +++++++++--------- desktop/src/pages/SessionControls.tsx | 54 ++--- desktop/src/pages/Settings.tsx | 2 + desktop/src/stores/settingsStore.test.ts | 57 +++++ desktop/src/stores/settingsStore.ts | 4 +- desktop/src/stores/uiStore.test.ts | 37 +++ desktop/src/stores/uiStore.ts | 9 +- desktop/src/theme/globals.css | 150 +++++++++++- desktop/src/types/settings.ts | 8 +- src/server/__tests__/settings.test.ts | 8 + 23 files changed, 555 insertions(+), 240 deletions(-) create mode 100644 desktop/src/stores/uiStore.test.ts diff --git a/desktop/index.html b/desktop/index.html index 05d212eb..c03e7509 100644 --- a/desktop/index.html +++ b/desktop/index.html @@ -6,8 +6,8 @@ Claude Code Companion diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index f06f23ca..604a1a37 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -8,6 +8,7 @@ import { useUIStore } from '../stores/uiStore' import { useUpdateStore } from '../stores/updateStore' import type { SavedProvider } from '../types/provider' import type { ProviderPreset } from '../types/providerPreset' +import type { ThemeMode } from '../types/settings' const MOCK_DELETE_PROVIDER = vi.fn() const MOCK_GET_SETTINGS = vi.fn() @@ -138,6 +139,7 @@ describe('Settings > General tab', () => { useSettingsStore.setState({ locale: 'en', + theme: 'light', thinkingEnabled: true, skipWebFetchPreflight: true, desktopNotificationsEnabled: true, @@ -153,6 +155,9 @@ describe('Settings > General tab', () => { setThinkingEnabled: vi.fn().mockImplementation(async (enabled: boolean) => { useSettingsStore.setState({ thinkingEnabled: enabled }) }), + setTheme: vi.fn().mockImplementation(async (theme: ThemeMode) => { + useSettingsStore.setState({ theme }) + }), setSkipWebFetchPreflight: vi.fn().mockImplementation(async (enabled: boolean) => { useSettingsStore.setState({ skipWebFetchPreflight: enabled }) }), @@ -227,6 +232,25 @@ describe('Settings > General tab', () => { expect(toggle).toBeChecked() }) + it('offers the pure white appearance theme', () => { + render() + + fireEvent.click(screen.getByText('General')) + fireEvent.click(screen.getByRole('button', { name: 'Pure White' })) + + expect(useSettingsStore.getState().setTheme).toHaveBeenCalledWith('white') + }) + + it('marks the pure white appearance theme as selected', () => { + useSettingsStore.setState({ theme: 'white' }) + render() + + fireEvent.click(screen.getByText('General')) + + expect(screen.getByRole('button', { name: 'Pure White' })).toHaveAttribute('aria-pressed', 'true') + expect(screen.getByRole('button', { name: 'Warm Classic' })).toHaveAttribute('aria-pressed', 'false') + }) + it('opens the Token usage tab from Settings navigation above Diagnostics', () => { render() diff --git a/desktop/src/components/chat/LocalSlashCommandPanel.tsx b/desktop/src/components/chat/LocalSlashCommandPanel.tsx index 65612bb8..ef0aa179 100644 --- a/desktop/src/components/chat/LocalSlashCommandPanel.tsx +++ b/desktop/src/components/chat/LocalSlashCommandPanel.tsx @@ -32,11 +32,11 @@ type Translate = ReturnType function toneForStatus(status: McpServerRecord['status']) { switch (status) { case 'connected': - return 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20' + return 'bg-[var(--color-inspector-success-bg)] text-[var(--color-inspector-success)] border-[var(--color-inspector-border)]' case 'needs-auth': - return 'bg-amber-500/10 text-amber-600 border-amber-500/20' + return 'bg-[var(--color-surface-container-low)] text-[var(--color-warning)] border-[var(--color-border)]' case 'failed': - return 'bg-rose-500/10 text-rose-600 border-rose-500/20' + return 'bg-[var(--color-inspector-danger-bg)] text-[var(--color-inspector-danger)] border-[var(--color-inspector-border)]' case 'disabled': return 'bg-[var(--color-surface-hover)] text-[var(--color-text-secondary)] border-[var(--color-border)]' default: @@ -114,7 +114,7 @@ function EmptyState({ title, body }: { title: string; body: string }) { function ErrorState({ message }: { message: string }) { return ( -
+
{message}
) @@ -270,7 +270,7 @@ function UsageTab({
)} {usage.hasUnknownModelCost && ( -
+
{t('slash.inspector.usage.unknownCost')}
)} @@ -593,9 +593,7 @@ function StatusTab({ {mcpServers.map((server) => (
diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 46de2bb2..7589cc80 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -677,9 +677,10 @@ export const en = { // Settings > General 'settings.general.appearanceTitle': 'Appearance', - 'settings.general.appearanceDescription': 'Switch between the original light workspace and the new dark workspace.', - 'settings.general.appearance.light': 'Light', + 'settings.general.appearanceDescription': 'Switch between the warm classic workspace, dark workspace, and a pure white workspace.', + 'settings.general.appearance.light': 'Warm Classic', 'settings.general.appearance.dark': 'Dark', + 'settings.general.appearance.white': 'Pure White', 'settings.general.languageTitle': 'Language', 'settings.general.languageDescription': 'Choose the display language for the application.', 'settings.general.responseLangTitle': 'Response Language', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index c2e46a9e..d8ec51f9 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -679,9 +679,10 @@ export const zh: Record = { // Settings > General 'settings.general.appearanceTitle': '配色主题', - 'settings.general.appearanceDescription': '在亮色与暗色工作区之间切换,不影响原有亮色主题。', - 'settings.general.appearance.light': '亮色', + 'settings.general.appearanceDescription': '在经典暖色、暗色与纯白工作区之间切换。', + 'settings.general.appearance.light': '经典暖色', 'settings.general.appearance.dark': '暗色', + 'settings.general.appearance.white': '纯白', 'settings.general.languageTitle': '语言', 'settings.general.languageDescription': '选择应用程序的显示语言。', 'settings.general.responseLangTitle': '回复语言', diff --git a/desktop/src/lib/desktopRuntime.test.ts b/desktop/src/lib/desktopRuntime.test.ts index 867d68d3..0e39c865 100644 --- a/desktop/src/lib/desktopRuntime.test.ts +++ b/desktop/src/lib/desktopRuntime.test.ts @@ -158,6 +158,26 @@ describe('desktopRuntime browser H5 bootstrap', () => { }) }) + it('prefers an explicit Vite desktop server URL over a remembered H5 server URL', async () => { + clientMocks.defaultBaseUrl = 'http://127.0.0.1:55189' + clientMocks.explicitDefaultBaseUrl = true + window.history.pushState({}, '', '/') + window.localStorage.setItem(H5_SERVER_URL_STORAGE_KEY, 'http://192.168.0.102:3456') + window.localStorage.setItem(H5_TOKEN_STORAGE_KEY, 'stale-h5-token') + globalThis.fetch = vi.fn().mockResolvedValue( + healthOkResponse(), + ) as typeof fetch + + await expect(initializeDesktopServerUrl()).resolves.toBe('http://127.0.0.1:55189') + + expect(clientMocks.setBaseUrl).toHaveBeenLastCalledWith('http://127.0.0.1:55189') + expect(clientMocks.setAuthToken).toHaveBeenLastCalledWith(null) + expect(clientMocks.postVerify).not.toHaveBeenCalled() + expect(globalThis.fetch).toHaveBeenCalledWith('http://127.0.0.1:55189/api/status', { + cache: 'no-store', + }) + }) + it('normalizes unreachable remote browser startup into a recoverable H5 error', async () => { vi.useFakeTimers() globalThis.fetch = vi.fn().mockRejectedValue(new TypeError('Failed to fetch')) as typeof fetch diff --git a/desktop/src/lib/desktopRuntime.ts b/desktop/src/lib/desktopRuntime.ts index 07d850ae..f39ff09b 100644 --- a/desktop/src/lib/desktopRuntime.ts +++ b/desktop/src/lib/desktopRuntime.ts @@ -137,10 +137,11 @@ async function initializeBrowserServerUrl(fallbackUrl: string) { const queryUrl = query?.get('serverUrl') ?? null const queryToken = normalizeToken(query?.get('h5Token') ?? query?.get('token')) const stored = readStoredH5Connection() + const configuredUrl = getConfiguredBrowserServerUrl(fallbackUrl) const requestedUrl = normalizeServerUrl(queryUrl) ?? + configuredUrl ?? stored.serverUrl ?? - getConfiguredBrowserServerUrl(fallbackUrl) ?? fallbackUrl const token = queryToken ?? stored.token const browserH5Runtime = requiresH5AuthForServerUrl(requestedUrl) diff --git a/desktop/src/lib/persistenceMigrations.test.ts b/desktop/src/lib/persistenceMigrations.test.ts index 107ca10e..a461ed91 100644 --- a/desktop/src/lib/persistenceMigrations.test.ts +++ b/desktop/src/lib/persistenceMigrations.test.ts @@ -56,6 +56,15 @@ describe('desktop persistence migrations', () => { expect(window.localStorage.getItem('cc-haha-theme')).toBeNull() }) + test('preserves the pure white theme as a valid persisted theme', () => { + window.localStorage.setItem('cc-haha-theme', 'white') + + const report = runDesktopPersistenceMigrations() + + expect(report.migratedKeys).not.toContain('cc-haha-theme') + expect(window.localStorage.getItem('cc-haha-theme')).toBe('white') + }) + 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 a8c4cf7d..5c889ebd 100644 --- a/desktop/src/lib/persistenceMigrations.ts +++ b/desktop/src/lib/persistenceMigrations.ts @@ -1,3 +1,5 @@ +import { THEME_MODES } from '../types/settings' + export const CURRENT_DESKTOP_PERSISTENCE_SCHEMA_VERSION = 1 export const DESKTOP_PERSISTENCE_VERSION_KEY = 'cc-haha.persistence.schemaVersion' @@ -138,7 +140,7 @@ export function runDesktopPersistenceMigrations(storage: StorageLike | null = ge runMigrationStep(report, TAB_STORAGE_KEY, () => migrateTabs(storage, report)) runMigrationStep(report, SESSION_RUNTIME_STORAGE_KEY, () => migrateSessionRuntime(storage, report)) - runMigrationStep(report, THEME_STORAGE_KEY, () => normalizeEnumKey(storage, THEME_STORAGE_KEY, ['light', 'dark'], report)) + runMigrationStep(report, THEME_STORAGE_KEY, () => normalizeEnumKey(storage, THEME_STORAGE_KEY, [...THEME_MODES], report)) runMigrationStep(report, LOCALE_STORAGE_KEY, () => normalizeEnumKey(storage, LOCALE_STORAGE_KEY, ['zh', 'en'], report)) try { storage.setItem(DESKTOP_PERSISTENCE_VERSION_KEY, String(CURRENT_DESKTOP_PERSISTENCE_SCHEMA_VERSION)) diff --git a/desktop/src/pages/ActivitySettings.tsx b/desktop/src/pages/ActivitySettings.tsx index 3cbd71e3..88d0c594 100644 --- a/desktop/src/pages/ActivitySettings.tsx +++ b/desktop/src/pages/ActivitySettings.tsx @@ -31,10 +31,10 @@ const HEAT_CELL_MAX = 22 const TOOLTIP_WIDTH = 172 const HEAT_COLORS = [ 'var(--color-surface-container)', - '#f5d4c4', - '#eea47f', - '#df6b47', - '#bd432c', + 'var(--color-primary-fixed)', + 'var(--color-primary-fixed-dim)', + 'var(--color-primary-container)', + 'var(--color-primary)', ] const DATE_LOCALES: Record = { en: 'en-US', @@ -401,11 +401,11 @@ export function ActivitySettings() {