From 28fe97f72bb096b77fc92eb0bb41eeeb7e9e0c4d Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Wed, 14 Jan 2026 04:48:09 +0800 Subject: [PATCH] feat: add light/dark/system theme mode switching - Add ThemeContext for managing theme state - Add ThemeSelector with Light/Dark/System options in Settings - Define CSS variables for both light and dark themes - Persist theme preference to localStorage - Listen for system theme changes when in system mode Co-Authored-By: Claude Opus 4.5 --- src/renderer/src/App.tsx | 11 +- src/renderer/src/components/Settings.tsx | 162 +++++++++++++++------ src/renderer/src/contexts/ThemeContext.tsx | 73 ++++++++++ src/renderer/src/styles/index.css | 29 +++- 4 files changed, 225 insertions(+), 50 deletions(-) create mode 100644 src/renderer/src/contexts/ThemeContext.tsx diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index ff0c3a792c..e102e464b2 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -4,8 +4,9 @@ import { useState, useEffect } from 'react' import { useApp } from './hooks/useApp' import { SessionList, ChatView, MessageInput, StatusBar, Settings } from './components' +import { ThemeProvider } from './contexts/ThemeContext' -function App(): React.JSX.Element { +function AppContent(): React.JSX.Element { const { // State sessions, @@ -188,4 +189,12 @@ function App(): React.JSX.Element { ) } +function App(): React.JSX.Element { + return ( + + + + ) +} + export default App diff --git a/src/renderer/src/components/Settings.tsx b/src/renderer/src/components/Settings.tsx index c567b93a63..0729ff0b6f 100644 --- a/src/renderer/src/components/Settings.tsx +++ b/src/renderer/src/components/Settings.tsx @@ -4,6 +4,7 @@ */ import { useState, useEffect } from 'react' import type { AgentCheckResult } from '../../../shared/electron-api' +import { useTheme } from '../contexts/ThemeContext' interface SettingsProps { isOpen: boolean @@ -20,11 +21,14 @@ const AGENT_ICONS: Record = { claude: '◉', } +type ThemeMode = 'light' | 'dark' | 'system' + export function Settings({ isOpen, onClose, currentAgentId, onSwitchAgent }: SettingsProps) { const [agents, setAgents] = useState([]) const [loading, setLoading] = useState(true) const [switching, setSwitching] = useState(null) const [selectedAgent, setSelectedAgent] = useState(null) + const { mode, setMode } = useTheme() useEffect(() => { if (isOpen) { @@ -79,68 +83,138 @@ export function Settings({ isOpen, onClose, currentAgentId, onSwitchAgent }: Set return (
-
+
{/* Header */} -
-

Set up agents

-

- Select a coding agent to use. Agents use local authentication. -

+
+

Settings

- {/* Agent Cards */} - {loading ? ( -
-
-
- ) : ( -
- {agents.map((agent) => ( - agent.installed && setSelectedAgent(agent.id)} - /> - ))} -
- )} + {/* Appearance Section */} +
+

Appearance

+ +
+ + {/* Agent Section */} +
+

Coding Agent

+

+ Select a coding agent. Agents use local authentication. +

+ + {/* Agent Cards */} + {loading ? ( +
+
+
+ ) : ( +
+ {agents.map((agent) => ( + agent.installed && setSelectedAgent(agent.id)} + /> + ))} +
+ )} +
{/* Footer */} -
+
- +
+ + +
) } +interface ThemeSelectorProps { + value: ThemeMode + onChange: (mode: ThemeMode) => void +} + +function ThemeSelector({ value, onChange }: ThemeSelectorProps) { + const options: { mode: ThemeMode; label: string; icon: React.ReactNode }[] = [ + { + mode: 'light', + label: 'Light', + icon: ( + + + + ), + }, + { + mode: 'dark', + label: 'Dark', + icon: ( + + + + ), + }, + { + mode: 'system', + label: 'System', + icon: ( + + + + ), + }, + ] + + return ( +
+ {options.map(({ mode, label, icon }) => ( + + ))} +
+ ) +} + interface AgentCardProps { agent: AgentCheckResult isSelected: boolean diff --git a/src/renderer/src/contexts/ThemeContext.tsx b/src/renderer/src/contexts/ThemeContext.tsx new file mode 100644 index 0000000000..ff2e8429ef --- /dev/null +++ b/src/renderer/src/contexts/ThemeContext.tsx @@ -0,0 +1,73 @@ +/** + * Theme context for managing light/dark/system mode + */ +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' + +type ThemeMode = 'light' | 'dark' | 'system' + +interface ThemeContextValue { + mode: ThemeMode + setMode: (mode: ThemeMode) => void + resolvedTheme: 'light' | 'dark' +} + +const ThemeContext = createContext(null) + +const STORAGE_KEY = 'multica-theme' + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [mode, setModeState] = useState(() => { + if (typeof window !== 'undefined') { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored === 'light' || stored === 'dark' || stored === 'system') { + return stored + } + } + return 'system' + }) + + const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => { + if (typeof window !== 'undefined') { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' + } + return 'dark' + }) + + // Listen for system theme changes + useEffect(() => { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') + const handler = (e: MediaQueryListEvent) => { + setSystemTheme(e.matches ? 'dark' : 'light') + } + mediaQuery.addEventListener('change', handler) + return () => mediaQuery.removeEventListener('change', handler) + }, []) + + const resolvedTheme = mode === 'system' ? systemTheme : mode + + // Apply theme to document + useEffect(() => { + const root = document.documentElement + root.classList.remove('light', 'dark') + root.classList.add(resolvedTheme) + }, [resolvedTheme]) + + const setMode = (newMode: ThemeMode) => { + setModeState(newMode) + localStorage.setItem(STORAGE_KEY, newMode) + } + + return ( + + {children} + + ) +} + +export function useTheme() { + const context = useContext(ThemeContext) + if (!context) { + throw new Error('useTheme must be used within a ThemeProvider') + } + return context +} diff --git a/src/renderer/src/styles/index.css b/src/renderer/src/styles/index.css index 185c03d1f3..223da8e307 100644 --- a/src/renderer/src/styles/index.css +++ b/src/renderer/src/styles/index.css @@ -1,7 +1,8 @@ @import 'tailwindcss'; -/* Custom theme configuration */ -@theme { +/* Dark theme (default) */ +:root, +.dark { --color-primary: #292929; --color-primary-dark: #363636; --color-primary-text: #ffffff; @@ -15,13 +16,30 @@ --color-text-muted: #a1a1aa; } +/* Light theme */ +.light { + --color-primary: #f5f5f5; + --color-primary-dark: #e5e5e5; + --color-primary-text: #171717; + --color-accent: #16a34a; + --color-accent-muted: rgba(22, 163, 74, 0.1); + --color-background: #ffffff; + --color-surface: #f5f5f5; + --color-surface-hover: #e5e5e5; + --color-border: #e5e5e5; + --color-text: #171717; + --color-text-muted: #525252; +} + /* Base styles */ * { box-sizing: border-box; } body { - @apply bg-[var(--color-background)] text-[var(--color-text)] antialiased; + background-color: var(--color-background); + color: var(--color-text); + -webkit-font-smoothing: antialiased; font-family: -apple-system, BlinkMacSystemFont, @@ -52,9 +70,10 @@ body { } ::-webkit-scrollbar-thumb { - @apply bg-[var(--color-border)] rounded-full; + background-color: var(--color-border); + border-radius: 9999px; } ::-webkit-scrollbar-thumb:hover { - @apply bg-[var(--color-text-muted)]; + background-color: var(--color-text-muted); }