From cb10cd8326a09c2f3ae591f4ff7c97be48e24dd5 Mon Sep 17 00:00:00 2001 From: Andrew Harvard Date: Tue, 16 Dec 2025 11:41:03 -0500 Subject: [PATCH 1/5] feat: centralize theme management with ThemeContext - Create ThemeContext with single source of truth for theme state - userThemePreference: what the user chose ('light' | 'dark' | 'system') - resolvedTheme: the actual theme to apply ('light' | 'dark') - Update ThemeSelector to use useTheme() hook - Update App.tsx to wrap with ThemeProvider - Update MCPUIResourceRenderer to use resolvedTheme (fixes bug where system theme was ignored) - Remove duplicated theme handling logic from App.tsx This eliminates brittle localStorage watching and provides type-safe, reactive theme updates via React context. --- ui/desktop/src/App.tsx | 58 ++----- .../components/GooseSidebar/ThemeSelector.tsx | 103 ++---------- .../src/components/MCPUIResourceRenderer.tsx | 14 +- ui/desktop/src/contexts/ThemeContext.tsx | 146 ++++++++++++++++++ 4 files changed, 171 insertions(+), 150 deletions(-) create mode 100644 ui/desktop/src/contexts/ThemeContext.tsx diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 13ca9aaef7c1..7da97a6a395e 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -33,6 +33,7 @@ import LauncherView from './components/LauncherView'; import 'react-toastify/dist/ReactToastify.css'; import { useConfig } from './components/ConfigContext'; import { ModelAndProviderProvider } from './components/ModelAndProviderContext'; +import { ThemeProvider } from './contexts/ThemeContext'; import PermissionSettingsView from './components/settings/permission/PermissionSetting'; import ExtensionsView, { ExtensionsViewOptions } from './components/extensions/ExtensionsView'; @@ -547,47 +548,6 @@ export function AppInner() { }; }, []); - useEffect(() => { - if (!window.electron) return; - - const handleThemeChanged = (_event: unknown, ...args: unknown[]) => { - const themeData = args[0] as { mode: string; useSystemTheme: boolean; theme: string }; - - if (themeData.useSystemTheme) { - localStorage.setItem('use_system_theme', 'true'); - } else { - localStorage.setItem('use_system_theme', 'false'); - localStorage.setItem('theme', themeData.theme); - } - - const isDark = themeData.useSystemTheme - ? window.matchMedia('(prefers-color-scheme: dark)').matches - : themeData.mode === 'dark'; - - if (isDark) { - document.documentElement.classList.add('dark'); - document.documentElement.classList.remove('light'); - } else { - document.documentElement.classList.remove('dark'); - document.documentElement.classList.add('light'); - } - - const storageEvent = new Event('storage') as Event & { - key: string | null; - newValue: string | null; - }; - storageEvent.key = themeData.useSystemTheme ? 'use_system_theme' : 'theme'; - storageEvent.newValue = themeData.useSystemTheme ? 'true' : themeData.theme; - window.dispatchEvent(storageEvent); - }; - - window.electron.on('theme-changed', handleThemeChanged); - - return () => { - window.electron.off('theme-changed', handleThemeChanged); - }; - }, []); - // Handle initial message from launcher useEffect(() => { const handleSetInitialMessage = (_event: IpcRendererEvent, ...args: unknown[]) => { @@ -698,12 +658,14 @@ export function AppInner() { export default function App() { return ( - - - - - - - + + + + + + + + + ); } diff --git a/ui/desktop/src/components/GooseSidebar/ThemeSelector.tsx b/ui/desktop/src/components/GooseSidebar/ThemeSelector.tsx index 7fd1d438f9f3..1b2d22a72891 100644 --- a/ui/desktop/src/components/GooseSidebar/ThemeSelector.tsx +++ b/ui/desktop/src/components/GooseSidebar/ThemeSelector.tsx @@ -1,6 +1,7 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { Moon, Sliders, Sun } from 'lucide-react'; import { Button } from '../ui/button'; +import { useTheme } from '../../contexts/ThemeContext'; interface ThemeSelectorProps { className?: string; @@ -8,98 +9,12 @@ interface ThemeSelectorProps { horizontal?: boolean; } -const getIsDarkMode = (mode: 'light' | 'dark' | 'system'): boolean => { - if (mode === 'system') { - return window.matchMedia('(prefers-color-scheme: dark)').matches; - } - return mode === 'dark'; -}; - -const getThemeMode = (): 'light' | 'dark' | 'system' => { - const savedUseSystemTheme = localStorage.getItem('use_system_theme'); - if (savedUseSystemTheme === 'true') { - return 'system'; - } - - const savedTheme = localStorage.getItem('theme'); - if (savedTheme) { - return savedTheme === 'dark' ? 'dark' : 'light'; - } - - return getIsDarkMode('system') ? 'dark' : 'light'; -}; - -const setThemeModeStorage = (mode: 'light' | 'dark' | 'system') => { - if (mode === 'system') { - localStorage.setItem('use_system_theme', 'true'); - } else { - localStorage.setItem('use_system_theme', 'false'); - localStorage.setItem('theme', mode); - } - - const themeData = { - mode, - useSystemTheme: mode === 'system', - theme: mode === 'system' ? '' : mode, - }; - - window.electron?.broadcastThemeChange(themeData); -}; - const ThemeSelector: React.FC = ({ className = '', hideTitle = false, horizontal = false, }) => { - const [themeMode, setThemeMode] = useState<'light' | 'dark' | 'system'>(getThemeMode); - const [isDarkMode, setDarkMode] = useState(() => getIsDarkMode(getThemeMode())); - - useEffect(() => { - const handleStorageChange = (e: { key: string | null; newValue: string | null }) => { - if (e.key === 'use_system_theme' || e.key === 'theme') { - const newThemeMode = getThemeMode(); - setThemeMode(newThemeMode); - setDarkMode(getIsDarkMode(newThemeMode)); - } - }; - - window.addEventListener('storage', handleStorageChange); - - return () => { - window.removeEventListener('storage', handleStorageChange); - }; - }, []); - - useEffect(() => { - const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); - - const handleThemeChange = (e: { matches: boolean }) => { - if (themeMode === 'system') { - setDarkMode(e.matches); - } - }; - - mediaQuery.addEventListener('change', handleThemeChange); - - setThemeModeStorage(themeMode); - setDarkMode(getIsDarkMode(themeMode)); - - return () => mediaQuery.removeEventListener('change', handleThemeChange); - }, [themeMode]); - - useEffect(() => { - if (isDarkMode) { - document.documentElement.classList.add('dark'); - document.documentElement.classList.remove('light'); - } else { - document.documentElement.classList.remove('dark'); - document.documentElement.classList.add('light'); - } - }, [isDarkMode]); - - const handleThemeChange = (newTheme: 'light' | 'dark' | 'system') => { - setThemeMode(newTheme); - }; + const { userThemePreference, setUserThemePreference } = useTheme(); return (
@@ -109,9 +24,9 @@ const ThemeSelector: React.FC = ({ >