diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 92d4bb1..3136faf 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -3,7 +3,7 @@ import { ThreadSidebar } from '@/components/sidebar/ThreadSidebar' import { TabbedPanel, TabBar } from '@/components/tabs' import { RightPanel } from '@/components/panels/RightPanel' import { ResizeHandle } from '@/components/ui/resizable' -import { useAppStore } from '@/lib/store' +import { useAppStore, applyTheme } from '@/lib/store' import { ThreadProvider } from '@/lib/thread-context' // Badge requires ~235 screen pixels to display with comfortable margin @@ -16,7 +16,7 @@ const RIGHT_MAX = 450 const RIGHT_DEFAULT = 320 function App(): React.JSX.Element { - const { currentThreadId, loadThreads, createThread } = useAppStore() + const { currentThreadId, loadThreads, createThread, theme, initializeTheme } = useAppStore() const [isLoading, setIsLoading] = useState(true) const [leftWidth, setLeftWidth] = useState(LEFT_DEFAULT) const [rightWidth, setRightWidth] = useState(RIGHT_DEFAULT) @@ -25,6 +25,24 @@ function App(): React.JSX.Element { // Track drag start widths const dragStartWidths = useRef<{ left: number; right: number } | null>(null) + // Initialize theme on mount + useLayoutEffect(() => { + initializeTheme() + }, [initializeTheme]) + + // Listen for system preference changes when theme is set to 'system' + useEffect(() => { + if (theme !== 'system') return + + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') + const handleChange = (): void => { + applyTheme('system') + } + + mediaQuery.addEventListener('change', handleChange) + return () => mediaQuery.removeEventListener('change', handleChange) + }, [theme]) + // Track zoom level changes and update CSS custom properties for safe areas useLayoutEffect(() => { const updateZoom = (): void => { diff --git a/src/renderer/src/components/chat/ModelSwitcher.tsx b/src/renderer/src/components/chat/ModelSwitcher.tsx index 26eb180..672985a 100644 --- a/src/renderer/src/components/chat/ModelSwitcher.tsx +++ b/src/renderer/src/components/chat/ModelSwitcher.tsx @@ -95,6 +95,8 @@ export function ModelSwitcher({ threadId }: ModelSwitcherProps) { function handleModelSelect(modelId: string) { setCurrentModel(modelId) + // Save as default for new threads and app restarts + window.api.models.setDefault(modelId) setOpen(false) } diff --git a/src/renderer/src/components/settings/SettingsDialog.tsx b/src/renderer/src/components/settings/SettingsDialog.tsx index a2ae31c..342585e 100644 --- a/src/renderer/src/components/settings/SettingsDialog.tsx +++ b/src/renderer/src/components/settings/SettingsDialog.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { Eye, EyeOff, Check, AlertCircle, Loader2 } from 'lucide-react' +import { Eye, EyeOff, Check, AlertCircle, Loader2, Sun, Moon, Monitor } from 'lucide-react' import { Dialog, DialogContent, @@ -10,6 +10,7 @@ import { import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Separator } from '@/components/ui/separator' +import { useAppStore, type Theme } from '@/lib/store' interface SettingsDialogProps { open: boolean @@ -44,7 +45,14 @@ const PROVIDERS: ProviderConfig[] = [ } ] +const THEME_OPTIONS: { id: Theme; label: string; icon: typeof Sun }[] = [ + { id: 'light', label: 'Light', icon: Sun }, + { id: 'dark', label: 'Dark', icon: Moon }, + { id: 'system', label: 'System', icon: Monitor } +] + export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) { + const { theme, setTheme } = useAppStore() const [apiKeys, setApiKeys] = useState>({}) const [savedKeys, setSavedKeys] = useState>({}) const [showKeys, setShowKeys] = useState>({}) @@ -204,6 +212,39 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) { +
+
APPEARANCE
+ +
+ +
+ {THEME_OPTIONS.map((option) => { + const Icon = option.icon + const isSelected = theme === option.id + return ( + + ) + })} +
+

+ Choose how the application appears. System will follow your OS preference. +

+
+
+ + +
+ + {/* Footer with Theme Toggle and Settings */} +
+ + + + + + {THEME_OPTIONS.map((option) => { + const Icon = option.icon + const isSelected = theme === option.id + return ( + + ) + })} + + + + +
) } diff --git a/src/renderer/src/components/tabs/CodeViewer.tsx b/src/renderer/src/components/tabs/CodeViewer.tsx index 8253bd2..a5ac294 100644 --- a/src/renderer/src/components/tabs/CodeViewer.tsx +++ b/src/renderer/src/components/tabs/CodeViewer.tsx @@ -2,9 +2,11 @@ import { useEffect, useState, useMemo } from 'react' import { ScrollArea } from '@/components/ui/scroll-area' import { createHighlighterCore, type HighlighterCore } from 'shiki/core' import { createJavaScriptRegexEngine } from 'shiki/engine/javascript' +import { useAppStore, getResolvedTheme } from '@/lib/store' // Import bundled themes and languages import githubDarkDefault from 'shiki/themes/github-dark-default.mjs' +import githubLightDefault from 'shiki/themes/github-light-default.mjs' import langTypescript from 'shiki/langs/typescript.mjs' import langTsx from 'shiki/langs/tsx.mjs' import langJavascript from 'shiki/langs/javascript.mjs' @@ -24,7 +26,7 @@ let highlighterPromise: Promise | null = null async function getHighlighter(): Promise { if (!highlighterPromise) { highlighterPromise = createHighlighterCore({ - themes: [githubDarkDefault], + themes: [githubDarkDefault, githubLightDefault], langs: [ langTypescript, langTsx, langJavascript, langJsx, langPython, langJson, langCss, langHtml, @@ -76,12 +78,17 @@ function getLanguage(ext: string | undefined): string | null { export function CodeViewer({ filePath, content }: CodeViewerProps) { const [highlightedHtml, setHighlightedHtml] = useState(null) + const theme = useAppStore((state) => state.theme) + const resolvedTheme = getResolvedTheme(theme) // Get file extension for syntax highlighting const fileName = filePath.split('/').pop() || filePath const ext = fileName.includes('.') ? fileName.split('.').pop()?.toLowerCase() : undefined const language = useMemo(() => getLanguage(ext), [ext]) + // Determine Shiki theme based on app theme + const shikiTheme = resolvedTheme === 'dark' ? 'github-dark-default' : 'github-light-default' + // Highlight code with Shiki useEffect(() => { let cancelled = false @@ -93,14 +100,14 @@ export function CodeViewer({ filePath, content }: CodeViewerProps) { } try { - console.log('[CodeViewer] Starting highlight for', language) + console.log('[CodeViewer] Starting highlight for', language, 'with theme', shikiTheme) const highlighter = await getHighlighter() if (cancelled) return const html = highlighter.codeToHtml(content, { lang: language, - theme: 'github-dark-default' + theme: shikiTheme }) if (cancelled) return @@ -118,7 +125,7 @@ export function CodeViewer({ filePath, content }: CodeViewerProps) { return () => { cancelled = true } - }, [content, language]) + }, [content, language, shikiTheme]) const lineCount = content?.split('\n').length ?? 0 diff --git a/src/renderer/src/index.css b/src/renderer/src/index.css index 3923672..b6f3834 100644 --- a/src/renderer/src/index.css +++ b/src/renderer/src/index.css @@ -110,6 +110,55 @@ --sidebar-ring: #3B82F6; } +/* Light theme */ +.light { + /* Foundation */ + --background: #FAFAFA; + --background-elevated: #FFFFFF; + --background-interactive: #F0F0F5; + --foreground: #1A1A1F; + + /* Borders */ + --border: #E0E0E8; + --border-emphasis: #D0D0D8; + --input: #E0E0E8; + --ring: #3B82F6; + + /* Text hierarchy */ + --muted: #F5F5F8; + --muted-foreground: #6B6B78; + --tertiary-foreground: #9A9AA8; + + /* Semantic mapping for shadcn compatibility */ + --card: #FFFFFF; + --card-foreground: #1A1A1F; + --popover: #FFFFFF; + --popover-foreground: #1A1A1F; + --primary: #2563EB; + --primary-foreground: #FFFFFF; + --secondary: #F0F0F5; + --secondary-foreground: #1A1A1F; + --accent: #EA580C; + --accent-foreground: #FFFFFF; + --destructive: #DC2626; + + /* Status colors - slightly adjusted for light background */ + --status-critical: #DC2626; + --status-warning: #D97706; + --status-nominal: #16A34A; + --status-info: #2563EB; + + /* Sidebar */ + --sidebar: #F5F5F8; + --sidebar-foreground: #1A1A1F; + --sidebar-primary: #2563EB; + --sidebar-primary-foreground: #FFFFFF; + --sidebar-accent: #E8E8F0; + --sidebar-accent-foreground: #1A1A1F; + --sidebar-border: #E0E0E8; + --sidebar-ring: #2563EB; +} + @layer base { * { @apply border-border outline-ring/50; @@ -279,14 +328,14 @@ } .streaming-markdown code { - background-color: var(--background); + background-color: var(--muted); padding: 0.125em 0.25em; border-radius: 3px; font-size: 0.875em; } .streaming-markdown pre { - background-color: var(--background); + background-color: var(--muted); padding: 0.75em 1em; border-radius: 3px; overflow-x: auto; @@ -329,7 +378,7 @@ } .streaming-markdown th { - background-color: var(--background); + background-color: var(--muted); font-weight: 600; } diff --git a/src/renderer/src/lib/store.ts b/src/renderer/src/lib/store.ts index 83b6480..5fe85ea 100644 --- a/src/renderer/src/lib/store.ts +++ b/src/renderer/src/lib/store.ts @@ -1,6 +1,52 @@ import { create } from 'zustand' import type { Thread, ModelConfig, Provider } from '@/types' +export type Theme = 'light' | 'dark' | 'system' + +const THEME_STORAGE_KEY = 'openwork-theme' + +// Helper to get the resolved theme (light or dark) from the theme setting +export function getResolvedTheme(theme: Theme): 'light' | 'dark' { + if (theme === 'system') { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' + } + return theme +} + +// Helper to apply theme to the document +export function applyTheme(theme: Theme): void { + const resolved = getResolvedTheme(theme) + const root = document.documentElement + + // Remove both classes first + root.classList.remove('light', 'dark') + + // Add the resolved theme class + root.classList.add(resolved) +} + +// Helper to load theme from localStorage +function loadStoredTheme(): Theme { + try { + const stored = localStorage.getItem(THEME_STORAGE_KEY) + if (stored === 'light' || stored === 'dark' || stored === 'system') { + return stored + } + } catch { + // localStorage might not be available + } + return 'system' // Default to system preference +} + +// Helper to save theme to localStorage +function saveTheme(theme: Theme): void { + try { + localStorage.setItem(THEME_STORAGE_KEY, theme) + } catch { + // localStorage might not be available + } +} + interface AppState { // Threads threads: Thread[] @@ -10,6 +56,9 @@ interface AppState { models: ModelConfig[] providers: Provider[] + // Theme + theme: Theme + // Right panel state (UI state, not thread data) rightPanelTab: 'todos' | 'files' | 'subagents' @@ -42,6 +91,10 @@ interface AppState { // Sidebar actions toggleSidebar: () => void setSidebarCollapsed: (collapsed: boolean) => void + + // Theme actions + setTheme: (theme: Theme) => void + initializeTheme: () => void } export const useAppStore = create((set, get) => ({ @@ -50,6 +103,7 @@ export const useAppStore = create((set, get) => ({ currentThreadId: null, models: [], providers: [], + theme: loadStoredTheme(), rightPanelTab: 'todos', settingsOpen: false, sidebarCollapsed: false, @@ -168,5 +222,17 @@ export const useAppStore = create((set, get) => ({ setSidebarCollapsed: (collapsed: boolean) => { set({ sidebarCollapsed: collapsed }) + }, + + // Theme actions + setTheme: (theme: Theme) => { + saveTheme(theme) + applyTheme(theme) + set({ theme }) + }, + + initializeTheme: () => { + const theme = get().theme + applyTheme(theme) } })) diff --git a/src/renderer/src/lib/thread-context.tsx b/src/renderer/src/lib/thread-context.tsx index 3033ebd..4c0a18e 100644 --- a/src/renderer/src/lib/thread-context.tsx +++ b/src/renderer/src/lib/thread-context.tsx @@ -85,6 +85,20 @@ interface ThreadContextValue { getStreamData: (threadId: string) => StreamData } +// Default model (will be loaded from storage) +let cachedDefaultModel: string | null = null + +// Load default model from storage (called once on app start) +async function loadDefaultModel(): Promise { + if (cachedDefaultModel) return cachedDefaultModel + try { + cachedDefaultModel = await window.api.models.getDefault() + return cachedDefaultModel + } catch { + return 'claude-sonnet-4-5-20250929' + } +} + // Default thread state const createDefaultThreadState = (): ThreadState => ({ messages: [], @@ -94,7 +108,7 @@ const createDefaultThreadState = (): ThreadState => ({ subagents: [], pendingApproval: null, error: null, - currentModel: 'claude-sonnet-4-5-20250929', + currentModel: cachedDefaultModel || 'claude-sonnet-4-5-20250929', openFiles: [], activeTab: 'agent', fileContents: {}, @@ -437,6 +451,8 @@ export function ThreadProvider({ children }: { children: ReactNode }) { }, setCurrentModel: (modelId: string) => { updateThreadState(threadId, () => ({ currentModel: modelId })) + // Update cached default model so new threads in this session use it + cachedDefaultModel = modelId }, openFile: (path: string, name: string) => { updateThreadState(threadId, (state) => { @@ -616,16 +632,21 @@ export function ThreadProvider({ children }: { children: ReactNode }) { ) const initializeThread = useCallback( - (threadId: string) => { + async (threadId: string) => { if (initializedThreadsRef.current.has(threadId)) return initializedThreadsRef.current.add(threadId) + // Load default model from storage if not cached + const defaultModel = await loadDefaultModel() + // Add to active threads (this will render a ThreadStreamHolder) setActiveThreadIds((prev) => new Set([...prev, threadId])) setThreadStates((prev) => { if (prev[threadId]) return prev - return { ...prev, [threadId]: createDefaultThreadState() } + const state = createDefaultThreadState() + state.currentModel = defaultModel + return { ...prev, [threadId]: state } }) loadThreadHistory(threadId)