diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 6ddab7bc..d73ae442 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -64,6 +64,8 @@ function createWindow() { const win = new BrowserWindow({ width: 1280, height: 800, + show: false, // Don't show until ready + backgroundColor: "#252525", // Prevent white flash (matches dark mode) webPreferences: { // IMPORTANT: keep Node out of renderer nodeIntegration: false, @@ -73,6 +75,11 @@ function createWindow() { }, }); + // Show window when content is ready to prevent blank screen + win.once("ready-to-show", () => { + win.show(); + }); + // Open external links in system browser (not sandboxed Electron window) // This handles window.open() and target="_blank" links win.webContents.setWindowOpenHandler(({ url }) => { diff --git a/apps/x/apps/renderer/index.html b/apps/x/apps/renderer/index.html index 1803a850..856065c2 100644 --- a/apps/x/apps/renderer/index.html +++ b/apps/x/apps/renderer/index.html @@ -5,6 +5,22 @@ Rowboat + +
diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index 4fd02863..64d2b9a3 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -123,6 +123,9 @@ --sidebar-accent-foreground: var(--text-color, oklch(0.205 0 0)); --sidebar-border: var(--sub-alt-color, oklch(0.922 0 0)); --sidebar-ring: var(--main-color, oklch(0.708 0 0)); + --scrollbar-track: oklch(0.95 0 0); + --scrollbar-thumb: oklch(0.75 0 0); + --scrollbar-thumb-hover: oklch(0.65 0 0); } .dark { @@ -157,15 +160,39 @@ --sidebar-accent-foreground: var(--text-color, oklch(0.985 0 0)); --sidebar-border: var(--sub-alt-color, oklch(1 0 0 / 10%)); --sidebar-ring: var(--main-color, oklch(0.556 0 0)); + --scrollbar-track: oklch(0.2 0 0); + --scrollbar-thumb: oklch(0.4 0 0); + --scrollbar-thumb-hover: oklch(0.5 0 0); } @layer base { * { @apply border-border outline-ring/50; + scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); + scrollbar-width: thin; } + body { @apply bg-background text-foreground; } + + ::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + ::-webkit-scrollbar-track { + background: var(--scrollbar-track); + } + + ::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + border-radius: 4px; + } + + ::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-hover); + } } /* Markdown content base styles for Streamdown/MessageResponse */ diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index b60ec736..7d986401 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -1,8 +1,8 @@ "use client" import * as React from "react" -import { useState, useEffect } from "react" -import { Server, Key, Shield } from "lucide-react" +import { useState, useEffect, useCallback } from "react" +import { Server, Key, Shield, Palette, Monitor, Sun, Moon } from "lucide-react" import { Dialog, @@ -11,14 +11,15 @@ import { } from "@/components/ui/dialog" import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" +import { useTheme, type Theme } from "@/contexts/theme-context" -type ConfigTab = "models" | "mcp" | "security" +type ConfigTab = "models" | "mcp" | "security" | "appearance" interface TabConfig { id: ConfigTab label: string icon: React.ElementType - path: string + path?: string description: string } @@ -44,12 +45,82 @@ const tabs: TabConfig[] = [ path: "config/security.json", description: "Configure allowed shell commands", }, + { + id: "appearance", + label: "Appearance", + icon: Palette, + description: "Customize the look and feel", + }, ] interface SettingsDialogProps { children: React.ReactNode } +function ThemeOption({ + label, + icon: Icon, + isSelected, + onClick, +}: { + label: string + icon: React.ElementType + isSelected: boolean + onClick: () => void +}) { + return ( + + ) +} + +function AppearanceSettings() { + const { theme, setTheme } = useTheme() + + return ( +
+
+

Theme

+

+ Select your preferred color scheme +

+
+ setTheme("light")} + /> + setTheme("dark")} + /> + setTheme("system")} + /> +
+
+
+ ) +} + export function SettingsDialog({ children }: SettingsDialogProps) { const [open, setOpen] = useState(false) const [activeTab, setActiveTab] = useState("models") @@ -60,9 +131,20 @@ export function SettingsDialog({ children }: SettingsDialogProps) { const [error, setError] = useState(null) const activeTabConfig = tabs.find((t) => t.id === activeTab)! + const isConfigTab = activeTab !== "appearance" - const loadConfig = async (tab: ConfigTab) => { + const formatJson = (jsonString: string): string => { + try { + return JSON.stringify(JSON.parse(jsonString), null, 2) + } catch { + return jsonString + } + } + + const loadConfig = useCallback(async (tab: ConfigTab) => { + if (tab === "appearance") return const tabConfig = tabs.find((t) => t.id === tab)! + if (!tabConfig.path) return setLoading(true) setError(null) try { @@ -72,16 +154,17 @@ export function SettingsDialog({ children }: SettingsDialogProps) { const formattedContent = formatJson(result.data) setContent(formattedContent) setOriginalContent(formattedContent) - } catch (err) { + } catch { setError(`Failed to load ${tabConfig.label} config`) setContent("") setOriginalContent("") } finally { setLoading(false) } - } + }, []) const saveConfig = async () => { + if (!isConfigTab || !activeTabConfig.path) return setSaving(true) setError(null) try { @@ -103,14 +186,6 @@ export function SettingsDialog({ children }: SettingsDialogProps) { } } - const formatJson = (jsonString: string): string => { - try { - return JSON.stringify(JSON.parse(jsonString), null, 2) - } catch { - return jsonString - } - } - const handleFormat = () => { setContent(formatJson(content)) } @@ -118,10 +193,10 @@ export function SettingsDialog({ children }: SettingsDialogProps) { const hasChanges = content !== originalContent useEffect(() => { - if (open) { + if (open && activeTab !== "appearance") { loadConfig(activeTab) } - }, [open, activeTab]) + }, [open, activeTab, loadConfig]) const handleTabChange = (tab: ConfigTab) => { if (hasChanges) { @@ -173,9 +248,11 @@ export function SettingsDialog({ children }: SettingsDialogProps) {

- {/* Editor */} + {/* Content */}
- {loading ? ( + {activeTab === "appearance" ? ( + + ) : loading ? (
Loading...
@@ -190,36 +267,38 @@ export function SettingsDialog({ children }: SettingsDialogProps) { )}
- {/* Footer */} -
-
- {error && ( - {error} - )} - {hasChanges && !error && ( - - Unsaved changes - - )} + {/* Footer - only show for config tabs */} + {isConfigTab && ( +
+
+ {error && ( + {error} + )} + {hasChanges && !error && ( + + Unsaved changes + + )} +
+
+ + +
-
- - -
-
+ )}
diff --git a/apps/x/apps/renderer/src/contexts/theme-context.tsx b/apps/x/apps/renderer/src/contexts/theme-context.tsx new file mode 100644 index 00000000..1149cb42 --- /dev/null +++ b/apps/x/apps/renderer/src/contexts/theme-context.tsx @@ -0,0 +1,93 @@ +"use client" + +import * as React from "react" + +export type Theme = "light" | "dark" | "system" + +type ThemeContextProps = { + theme: Theme + resolvedTheme: "light" | "dark" + setTheme: (theme: Theme) => void +} + +const ThemeContext = React.createContext(null) + +const STORAGE_KEY = "rowboat-theme" + +function getSystemTheme(): "light" | "dark" { + if (typeof window === "undefined") return "light" + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light" +} + +export function useTheme() { + const context = React.useContext(ThemeContext) + if (!context) { + throw new Error("useTheme must be used within a ThemeProvider.") + } + return context +} + +export function ThemeProvider({ + defaultTheme = "system", + children, +}: { + defaultTheme?: Theme + children: React.ReactNode +}) { + const [theme, setThemeState] = React.useState(() => { + if (typeof window === "undefined") return defaultTheme + const stored = localStorage.getItem(STORAGE_KEY) as Theme | null + return stored || defaultTheme + }) + + const [resolvedTheme, setResolvedTheme] = React.useState<"light" | "dark">(() => { + if (theme === "system") return getSystemTheme() + return theme + }) + + // Apply theme to document + React.useEffect(() => { + const root = document.documentElement + const resolved = theme === "system" ? getSystemTheme() : theme + + root.classList.remove("light", "dark") + root.classList.add(resolved) + setResolvedTheme(resolved) + }, [theme]) + + // Listen for system theme changes + React.useEffect(() => { + if (theme !== "system") return + + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") + const handleChange = () => { + const resolved = getSystemTheme() + document.documentElement.classList.remove("light", "dark") + document.documentElement.classList.add(resolved) + setResolvedTheme(resolved) + } + + mediaQuery.addEventListener("change", handleChange) + return () => mediaQuery.removeEventListener("change", handleChange) + }, [theme]) + + const setTheme = React.useCallback((newTheme: Theme) => { + localStorage.setItem(STORAGE_KEY, newTheme) + setThemeState(newTheme) + }, []) + + const contextValue = React.useMemo( + () => ({ + theme, + resolvedTheme, + setTheme, + }), + [theme, resolvedTheme, setTheme] + ) + + return ( + + {children} + + ) +} diff --git a/apps/x/apps/renderer/src/main.tsx b/apps/x/apps/renderer/src/main.tsx index 0feeaa41..7ad7ac86 100644 --- a/apps/x/apps/renderer/src/main.tsx +++ b/apps/x/apps/renderer/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' import { PostHogProvider } from 'posthog-js/react' +import { ThemeProvider } from '@/contexts/theme-context' const options = { api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST, @@ -12,7 +13,9 @@ const options = { createRoot(document.getElementById('root')!).render( - + + + , )