Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ui/desktop/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
41 changes: 41 additions & 0 deletions ui/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ErrorUI error={new Error(fatalError)} />;
}
Expand Down
92 changes: 58 additions & 34 deletions ui/desktop/src/components/GooseSidebar/ThemeSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ThemeSelectorProps> = ({
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)');
Expand All @@ -51,14 +81,8 @@ const ThemeSelector: React.FC<ThemeSelectorProps> = ({

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]);
Expand Down
11 changes: 11 additions & 0 deletions ui/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions ui/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SaveDataUrlResponse>;
deleteTempFile: (filePath: string) => void;
Expand Down Expand Up @@ -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<SaveDataUrlResponse> => {
return ipcRenderer.invoke('save-data-url-to-temp', dataUrl, uniqueId);
},
Expand Down
2 changes: 1 addition & 1 deletion ui/desktop/src/utils/ollamaDetection.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down