mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-23 10:08:38 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
<ThemeProvider>
|
||||
<AppContent />
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
@@ -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<string, string> = {
|
||||
claude: '◉',
|
||||
}
|
||||
|
||||
type ThemeMode = 'light' | 'dark' | 'system'
|
||||
|
||||
export function Settings({ isOpen, onClose, currentAgentId, onSwitchAgent }: SettingsProps) {
|
||||
const [agents, setAgents] = useState<AgentCheckResult[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [switching, setSwitching] = useState<string | null>(null)
|
||||
const [selectedAgent, setSelectedAgent] = useState<string | null>(null)
|
||||
const { mode, setMode } = useTheme()
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -79,68 +83,138 @@ export function Settings({ isOpen, onClose, currentAgentId, onSwitchAgent }: Set
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
|
||||
<div className="w-full max-w-md rounded-xl bg-[var(--color-background)] p-8">
|
||||
<div className="w-full max-w-md rounded-xl bg-[var(--color-background)] p-8 max-h-[90vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-semibold text-[var(--color-text)]">Set up agents</h1>
|
||||
<p className="mt-2 text-sm text-[var(--color-text-muted)]">
|
||||
Select a coding agent to use. Agents use local authentication.
|
||||
</p>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-semibold text-[var(--color-text)]">Settings</h1>
|
||||
</div>
|
||||
|
||||
{/* Agent Cards */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-[var(--color-accent)] border-t-transparent" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{agents.map((agent) => (
|
||||
<AgentCard
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
isSelected={agent.id === selectedAgent}
|
||||
onSelect={() => agent.installed && setSelectedAgent(agent.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Appearance Section */}
|
||||
<div className="mb-8">
|
||||
<h2 className="mb-3 text-sm font-medium text-[var(--color-text-muted)]">Appearance</h2>
|
||||
<ThemeSelector value={mode} onChange={setMode} />
|
||||
</div>
|
||||
|
||||
{/* Agent Section */}
|
||||
<div className="mb-6">
|
||||
<h2 className="mb-3 text-sm font-medium text-[var(--color-text-muted)]">Coding Agent</h2>
|
||||
<p className="mb-4 text-xs text-[var(--color-text-muted)]">
|
||||
Select a coding agent. Agents use local authentication.
|
||||
</p>
|
||||
|
||||
{/* Agent Cards */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-[var(--color-accent)] border-t-transparent" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{agents.map((agent) => (
|
||||
<AgentCard
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
isSelected={agent.id === selectedAgent}
|
||||
onSelect={() => agent.installed && setSelectedAgent(agent.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<div className="flex items-center justify-between border-t border-[var(--color-border)] pt-6">
|
||||
<button
|
||||
onClick={loadAgents}
|
||||
disabled={loading}
|
||||
className="text-sm text-[var(--color-text-muted)] hover:text-[var(--color-text)] disabled:opacity-50"
|
||||
>
|
||||
Refresh
|
||||
Refresh agents
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleContinue}
|
||||
disabled={!selectedAgent || !!switching || installedCount === 0}
|
||||
className="flex items-center gap-2 rounded-lg bg-[var(--color-primary)] px-5 py-2.5 font-medium text-[var(--color-primary-text)] transition-colors hover:bg-[var(--color-primary-dark)] disabled:opacity-50"
|
||||
>
|
||||
{switching ? (
|
||||
<>
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
Switching...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Continue
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg px-4 py-2 text-[var(--color-text-muted)] hover:bg-[var(--color-surface)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleContinue}
|
||||
disabled={!selectedAgent || !!switching || installedCount === 0}
|
||||
className="flex items-center gap-2 rounded-lg bg-[var(--color-primary)] px-5 py-2 font-medium text-[var(--color-primary-text)] transition-colors hover:bg-[var(--color-primary-dark)] disabled:opacity-50"
|
||||
>
|
||||
{switching ? (
|
||||
<>
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
Switching...
|
||||
</>
|
||||
) : (
|
||||
'Done'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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: (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
mode: 'dark',
|
||||
label: 'Dark',
|
||||
icon: (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
mode: 'system',
|
||||
label: 'System',
|
||||
icon: (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{options.map(({ mode, label, icon }) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => onChange(mode)}
|
||||
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border px-3 py-2.5 text-sm transition-all ${
|
||||
value === mode
|
||||
? 'border-[var(--color-accent)] bg-[var(--color-accent-muted)] text-[var(--color-text)]'
|
||||
: 'border-[var(--color-border)] text-[var(--color-text-muted)] hover:border-[var(--color-text-muted)]'
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface AgentCardProps {
|
||||
agent: AgentCheckResult
|
||||
isSelected: boolean
|
||||
|
||||
73
src/renderer/src/contexts/ThemeContext.tsx
Normal file
73
src/renderer/src/contexts/ThemeContext.tsx
Normal file
@@ -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<ThemeContextValue | null>(null)
|
||||
|
||||
const STORAGE_KEY = 'multica-theme'
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [mode, setModeState] = useState<ThemeMode>(() => {
|
||||
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 (
|
||||
<ThemeContext.Provider value={{ mode, setMode, resolvedTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext)
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user