Skip to content
Open
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
22 changes: 20 additions & 2 deletions src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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 => {
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/src/components/chat/ModelSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
43 changes: 42 additions & 1 deletion src/renderer/src/components/settings/SettingsDialog.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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<Record<string, string>>({})
const [savedKeys, setSavedKeys] = useState<Record<string, boolean>>({})
const [showKeys, setShowKeys] = useState<Record<string, boolean>>({})
Expand Down Expand Up @@ -204,6 +212,39 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {

<Separator />

<div className="space-y-4 py-2">
<div className="text-section-header">APPEARANCE</div>

<div className="space-y-2">
<label className="text-sm font-medium">Theme</label>
<div className="flex gap-2">
{THEME_OPTIONS.map((option) => {
const Icon = option.icon
const isSelected = theme === option.id
return (
<button
key={option.id}
onClick={() => setTheme(option.id)}
className={`flex flex-1 items-center justify-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors ${
isSelected
? 'border-primary bg-primary/10 text-primary'
: 'border-border hover:bg-secondary'
}`}
>
<Icon className="size-4" />
{option.label}
</button>
)
})}
</div>
<p className="text-xs text-muted-foreground">
Choose how the application appears. System will follow your OS preference.
</p>
</div>
</div>

<Separator />

<div className="flex justify-end">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Done
Expand Down
61 changes: 58 additions & 3 deletions src/renderer/src/components/sidebar/ThreadSidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useState } from 'react'
import { Plus, MessageSquare, Trash2, Pencil, Loader2 } from 'lucide-react'
import { Plus, MessageSquare, Trash2, Pencil, Loader2, Sun, Moon, Monitor, Settings } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useAppStore } from '@/lib/store'
import { useAppStore, type Theme, getResolvedTheme } from '@/lib/store'
import { useThreadStream } from '@/lib/thread-context'
import { cn, formatRelativeTime, truncate } from '@/lib/utils'
import {
Expand All @@ -12,8 +12,15 @@ import {
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import type { Thread } from '@/types'

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 }
]

// Thread loading indicator that subscribes to the stream context
function ThreadLoadingIcon({ threadId }: { threadId: string }): React.JSX.Element {
const { isLoading } = useThreadStream(threadId)
Expand Down Expand Up @@ -129,9 +136,16 @@ export function ThreadSidebar(): React.JSX.Element {
createThread,
selectThread,
deleteThread,
updateThread
updateThread,
theme,
setTheme,
setSettingsOpen
} = useAppStore()

// Get the icon for the current theme
const resolvedTheme = getResolvedTheme(theme)
const ThemeIcon = resolvedTheme === 'dark' ? Moon : Sun

const [editingThreadId, setEditingThreadId] = useState<string | null>(null)
const [editingTitle, setEditingTitle] = useState('')

Expand Down Expand Up @@ -193,6 +207,47 @@ export function ThreadSidebar(): React.JSX.Element {
)}
</div>
</ScrollArea>

{/* Footer with Theme Toggle and Settings */}
<div className="border-t border-border p-2 flex items-center gap-1">
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon-sm" title={`Theme: ${theme}`}>
<ThemeIcon className="size-4" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-36 p-1">
{THEME_OPTIONS.map((option) => {
const Icon = option.icon
const isSelected = theme === option.id
return (
<button
key={option.id}
onClick={() => setTheme(option.id)}
className={cn(
'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm transition-colors',
isSelected
? 'bg-accent text-accent-foreground'
: 'hover:bg-secondary'
)}
>
<Icon className="size-4" />
{option.label}
</button>
)
})}
</PopoverContent>
</Popover>

<Button
variant="ghost"
size="icon-sm"
onClick={() => setSettingsOpen(true)}
title="Settings"
>
<Settings className="size-4" />
</Button>
</div>
</aside>
)
}
15 changes: 11 additions & 4 deletions src/renderer/src/components/tabs/CodeViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -24,7 +26,7 @@ let highlighterPromise: Promise<HighlighterCore> | null = null
async function getHighlighter(): Promise<HighlighterCore> {
if (!highlighterPromise) {
highlighterPromise = createHighlighterCore({
themes: [githubDarkDefault],
themes: [githubDarkDefault, githubLightDefault],
langs: [
langTypescript, langTsx, langJavascript, langJsx,
langPython, langJson, langCss, langHtml,
Expand Down Expand Up @@ -76,12 +78,17 @@ function getLanguage(ext: string | undefined): string | null {

export function CodeViewer({ filePath, content }: CodeViewerProps) {
const [highlightedHtml, setHighlightedHtml] = useState<string | null>(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
Expand All @@ -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
Expand All @@ -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

Expand Down
55 changes: 52 additions & 3 deletions src/renderer/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -329,7 +378,7 @@
}

.streaming-markdown th {
background-color: var(--background);
background-color: var(--muted);
font-weight: 600;
}

Expand Down
Loading