diff --git a/ui/desktop/eslint.config.js b/ui/desktop/eslint.config.js index 2241702d0e63..391d45a90ffa 100644 --- a/ui/desktop/eslint.config.js +++ b/ui/desktop/eslint.config.js @@ -85,6 +85,7 @@ module.exports = [ HeadersInit: 'readonly', KeyboardEvent: 'readonly', MouseEvent: 'readonly', // Add MouseEvent + Event: 'readonly', // Add Event Node: 'readonly', // Add Node React: 'readonly', handleAction: 'readonly', diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 5fa3fde7a047..12be1661b463 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -508,6 +508,47 @@ 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); + }; + }, []); + if (fatalError) { return ; } diff --git a/ui/desktop/src/components/GooseSidebar/ThemeSelector.tsx b/ui/desktop/src/components/GooseSidebar/ThemeSelector.tsx index f9499fb59916..7fd1d438f9f3 100644 --- a/ui/desktop/src/components/GooseSidebar/ThemeSelector.tsx +++ b/ui/desktop/src/components/GooseSidebar/ThemeSelector.tsx @@ -8,37 +8,67 @@ 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'>(() => { - const savedUseSystemTheme = localStorage.getItem('use_system_theme') === 'true'; - if (savedUseSystemTheme) { - return 'system'; - } - const savedTheme = localStorage.getItem('theme'); - return savedTheme === 'dark' ? 'dark' : 'light'; - }); - - const [isDarkMode, setDarkMode] = useState(() => { - // First check localStorage to determine the intended theme - const savedUseSystemTheme = localStorage.getItem('use_system_theme') === 'true'; - const savedTheme = localStorage.getItem('theme'); - - if (savedUseSystemTheme) { - // Use system preference - const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; - return systemPrefersDark; - } else if (savedTheme) { - // Use saved theme preference - return savedTheme === 'dark'; - } else { - // Fallback: check current DOM state to maintain consistency - return document.documentElement.classList.contains('dark'); - } - }); + 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)'); @@ -51,14 +81,8 @@ const ThemeSelector: React.FC = ({ mediaQuery.addEventListener('change', handleThemeChange); - if (themeMode === 'system') { - setDarkMode(mediaQuery.matches); - localStorage.setItem('use_system_theme', 'true'); - } else { - setDarkMode(themeMode === 'dark'); - localStorage.setItem('use_system_theme', 'false'); - localStorage.setItem('theme', themeMode); - } + setThemeModeStorage(themeMode); + setDarkMode(getIsDarkMode(themeMode)); return () => mediaQuery.removeEventListener('change', handleThemeChange); }, [themeMode]); diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index c13d472d4b4e..c78b59010557 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -2009,6 +2009,17 @@ async function appMain() { } }); + ipcMain.on('broadcast-theme-change', (event, themeData) => { + const senderWindow = BrowserWindow.fromWebContents(event.sender); + const allWindows = BrowserWindow.getAllWindows(); + + allWindows.forEach((window) => { + if (window.id !== senderWindow?.id) { + window.webContents.send('theme-changed', themeData); + } + }); + }); + ipcMain.on('reload-app', (event) => { // Get the window that sent the event const window = BrowserWindow.fromWebContents(event.sender); diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index 58f1b6c5b794..654c558412fa 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -95,6 +95,11 @@ type ElectronAPI = { callback: (event: Electron.IpcRendererEvent, ...args: unknown[]) => void ) => void; emit: (channel: string, ...args: unknown[]) => void; + broadcastThemeChange: (themeData: { + mode: string; + useSystemTheme: boolean; + theme: string; + }) => void; // Functions for image pasting saveDataUrlToTemp: (dataUrl: string, uniqueId: string) => Promise; deleteTempFile: (filePath: string) => void; @@ -209,6 +214,9 @@ const electronAPI: ElectronAPI = { emit: (channel: string, ...args: unknown[]) => { ipcRenderer.emit(channel, ...args); }, + broadcastThemeChange: (themeData: { mode: string; useSystemTheme: boolean; theme: string }) => { + ipcRenderer.send('broadcast-theme-change', themeData); + }, saveDataUrlToTemp: (dataUrl: string, uniqueId: string): Promise => { return ipcRenderer.invoke('save-data-url-to-temp', dataUrl, uniqueId); }, diff --git a/ui/desktop/src/utils/ollamaDetection.test.ts b/ui/desktop/src/utils/ollamaDetection.test.ts index 8048b20187c0..07da6071b074 100644 --- a/ui/desktop/src/utils/ollamaDetection.test.ts +++ b/ui/desktop/src/utils/ollamaDetection.test.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -/* global AbortSignal, TextEncoder, Event, EventListener */ +/* global AbortSignal, TextEncoder, EventListener */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import {