Merge pull request #6713 from menloresearch/fix/theme-system

fix: theme system cross platform
This commit is contained in:
Faisal Amir 2025-10-06 16:58:53 +07:00 committed by GitHub
commit 0588cb34c6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 503 additions and 76 deletions

View File

@ -12,6 +12,8 @@
"core:webview:allow-set-webview-zoom",
"core:window:allow-start-dragging",
"core:window:allow-set-theme",
"core:window:allow-get-all-windows",
"core:event:allow-listen",
"shell:allow-spawn",
"shell:allow-open",
"core:app:allow-set-app-theme",

View File

@ -1,14 +1,18 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "logs-app-window",
"identifier": "log-app-window",
"description": "enables permissions for the logs app window",
"windows": ["logs-app-window"],
"platforms": ["linux", "macOS", "windows"],
"permissions": [
"core:default",
"core:window:allow-start-dragging",
"core:window:allow-set-theme",
"core:window:allow-get-all-windows",
"core:event:allow-listen",
"log:default",
"core:webview:allow-create-webview-window",
"core:webview:allow-get-all-webviews",
"core:window:allow-set-focus"
]
}

View File

@ -3,12 +3,16 @@
"identifier": "logs-window",
"description": "enables permissions for the logs window",
"windows": ["logs-window-local-api-server"],
"platforms": ["linux", "macOS", "windows"],
"permissions": [
"core:default",
"core:window:allow-start-dragging",
"core:window:allow-set-theme",
"core:window:allow-get-all-windows",
"core:event:allow-listen",
"log:default",
"core:webview:allow-create-webview-window",
"core:webview:allow-get-all-webviews",
"core:window:allow-set-focus"
]
}

View File

@ -8,13 +8,28 @@
"core:default",
"core:window:allow-start-dragging",
"core:window:allow-set-theme",
"core:window:allow-get-all-windows",
"core:event:allow-listen",
"log:default",
"core:webview:allow-create-webview-window",
"core:webview:allow-get-all-webviews",
"core:window:allow-set-focus",
"hardware:allow-get-system-info",
"hardware:allow-get-system-usage",
"llamacpp:allow-get-devices",
"llamacpp:allow-read-gguf-metadata",
"deep-link:allow-get-current"
"deep-link:allow-get-current",
{
"identifier": "http:default",
"allow": [
{
"url": "https://*:*"
},
{
"url": "http://*:*"
}
],
"deny": []
}
]
}

View File

@ -7,7 +7,7 @@ use std::{
};
use tar::Archive;
use tauri::{
App, Emitter, Manager, Runtime, Wry
App, Emitter, Manager, Runtime, Wry, WindowEvent
};
#[cfg(desktop)]
@ -270,3 +270,32 @@ pub fn setup_tray(app: &App) -> tauri::Result<TrayIcon> {
})
.build(app)
}
pub fn setup_theme_listener<R: Runtime>(app: &App<R>) -> tauri::Result<()> {
// Setup theme listener for main window
if let Some(window) = app.get_webview_window("main") {
setup_window_theme_listener(app.handle().clone(), window);
}
Ok(())
}
fn setup_window_theme_listener<R: Runtime>(
app_handle: tauri::AppHandle<R>,
window: tauri::WebviewWindow<R>,
) {
let window_label = window.label().to_string();
let app_handle_clone = app_handle.clone();
window.on_window_event(move |event| {
if let WindowEvent::ThemeChanged(theme) = event {
let theme_str = match theme {
tauri::Theme::Light => "light",
tauri::Theme::Dark => "dark",
_ => "auto",
};
log::info!("System theme changed to: {} for window: {}", theme_str, window_label);
let _ = app_handle_clone.emit("theme-changed", theme_str);
}
});
}

View File

@ -117,3 +117,4 @@ pub fn is_library_available(library: &str) -> bool {
}
}
}

View File

@ -193,6 +193,7 @@ pub fn run() {
}
setup_mcp(app);
setup::setup_theme_listener(app)?;
Ok(())
})
.build(tauri::generate_context!())

View File

@ -40,7 +40,7 @@
}
],
"security": {
"capabilities": ["default"],
"capabilities": ["default", "logs-app-window", "logs-window", "system-monitor-window"],
"csp": {
"default-src": "'self' customprotocol: asset: http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:*",
"connect-src": "ipc: http://ipc.localhost http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* https: http:",

View File

@ -1,7 +1,12 @@
{
"app": {
"security": {
"capabilities": ["desktop", "system-monitor-window"]
"capabilities": [
"desktop",
"system-monitor-window",
"log-app-window",
"logs-window"
]
}
},
"bundle": {

View File

@ -1,7 +1,12 @@
{
"app": {
"security": {
"capabilities": ["desktop", "system-monitor-window"]
"capabilities": [
"desktop",
"system-monitor-window",
"log-app-window",
"logs-window"
]
}
},
"bundle": {

View File

@ -1,7 +1,12 @@
{
"app": {
"security": {
"capabilities": ["desktop"]
"capabilities": [
"desktop",
"system-monitor-window",
"log-app-window",
"logs-window"
]
}
},

View File

@ -17,7 +17,7 @@
<link rel="apple-touch-icon" href="/images/jan-logo.png" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=no, maximum-scale=1.0, minimum-scale=1.0, viewport-fit=cover, interactive-widget=resizes-visual"
content="width=device-width, initial-scale=1.0, user-scalable=no, maximum-scale=1.0, minimum-scale=1.0, viewport-fit=cover"
/>
<title>Jan</title>
<!-- INJECT_GOOGLE_ANALYTICS -->

View File

@ -1,4 +1,4 @@
import { useAppearance, isDefaultColor } from '@/hooks/useAppearance'
import { useAppearance, useBlurSupport } from '@/hooks/useAppearance'
import { cn } from '@/lib/utils'
import { RgbaColor, RgbaColorPicker } from 'react-colorful'
import { IconColorPicker } from '@tabler/icons-react'
@ -14,6 +14,12 @@ export function ColorPickerAppBgColor() {
const { appBgColor, setAppBgColor } = useAppearance()
const { isDark } = useTheme()
const { t } = useTranslation()
const showAlphaSlider = useBlurSupport()
// Helper to get alpha value based on blur support
const getAlpha = (defaultAlpha: number) => {
return showAlphaSlider ? defaultAlpha : 1
}
const predefineAppBgColor: RgbaColor[] = [
isDark
@ -21,55 +27,64 @@ export function ColorPickerAppBgColor() {
r: 25,
g: 25,
b: 25,
a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.4,
a: getAlpha(0.4),
}
: {
r: 255,
g: 255,
b: 255,
a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.4,
a: getAlpha(0.4),
},
{
r: 70,
g: 79,
b: 229,
a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.5,
a: getAlpha(0.5),
},
{
r: 238,
g: 130,
b: 238,
a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.5,
a: getAlpha(0.5),
},
{
r: 255,
g: 99,
b: 71,
a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.5,
a: getAlpha(0.5),
},
{
r: 255,
g: 165,
b: 0,
a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.5,
a: getAlpha(0.5),
},
]
// Check if a color is the default color (considering both dark and light themes)
const isColorDefault = (color: RgbaColor): boolean => {
const isDarkDefault = color.r === 25 && color.g === 25 && color.b === 25
const isLightDefault = color.r === 255 && color.g === 255 && color.b === 255
// Accept both 0.4 and 1 as valid default alpha values (handles blur detection timing)
const hasDefaultAlpha = Math.abs(color.a - 0.4) < 0.01 || Math.abs(color.a - 1) < 0.01
return (isDarkDefault || isLightDefault) && hasDefaultAlpha
}
return (
<div className="flex items-center gap-1.5">
{predefineAppBgColor.map((item, i) => {
const isSelected =
(item.r === appBgColor.r &&
item.g === appBgColor.g &&
item.b === appBgColor.b &&
item.a === appBgColor.a) ||
(isDefaultColor(appBgColor) && isDefaultColor(item))
item.g === appBgColor.g &&
item.b === appBgColor.b &&
Math.abs(item.a - appBgColor.a) < 0.01) ||
(isColorDefault(appBgColor) && isColorDefault(item))
return (
<div
key={i}
className={cn(
'size-4 rounded-full border border-main-view-fg/20',
'size-4 rounded-full border border-main-view-fg/20 cursor-pointer',
isSelected && 'ring-2 ring-accent border-none'
)}
onClick={() => {

View File

@ -33,7 +33,7 @@ const DropdownAssistant = () => {
return (
<>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<div className="flex items-center justify-between gap-2 bg-main-view-fg/5 py-1 hover:bg-main-view-fg/8 px-2 rounded-sm">
<div className="inline-flex items-center justify-between gap-2 bg-main-view-fg/5 py-1 hover:bg-main-view-fg/8 px-2 rounded-sm">
<DropdownMenuTrigger asChild>
<button className="font-medium cursor-pointer flex items-center gap-1.5 relative z-20 max-w-40">
<div className="text-main-view-fg/80 flex items-center gap-1">

View File

@ -643,7 +643,7 @@ const LeftPanel = () => {
data-test-id={`menu-${menu.title}`}
activeOptions={{ exact: true }}
className={cn(
'flex items-center gap-1.5 cursor-pointer hover:bg-left-panel-fg/10 py-1 px-1 rounded',
'flex items-center gap-1.5 cursor-pointer hover:bg-left-panel-fg/10 py-1 my-0.5 px-1 rounded',
isActive && 'bg-left-panel-fg/10'
)}
>

View File

@ -31,6 +31,8 @@ vi.mock('zustand/middleware', () => ({
// Mock global constants
Object.defineProperty(global, 'IS_WINDOWS', { value: false, writable: true })
Object.defineProperty(global, 'IS_LINUX', { value: false, writable: true })
Object.defineProperty(global, 'IS_MACOS', { value: false, writable: true })
Object.defineProperty(global, 'IS_TAURI', { value: false, writable: true })
Object.defineProperty(global, 'IS_WEB_APP', { value: false, writable: true })
describe('useAppearance', () => {
@ -212,24 +214,55 @@ describe('useAppearance', () => {
const { result } = renderHook(() => useAppearance())
const testColor = { r: 128, g: 64, b: 192, a: 0.8 }
act(() => {
result.current.setAppBgColor(testColor)
})
expect(result.current.appBgColor).toEqual(testColor)
// In web environment (IS_TAURI=false), alpha is forced to 1
expect(result.current.appBgColor).toEqual({ ...testColor, a: 1 })
})
it('should handle transparent colors', () => {
const { result } = renderHook(() => useAppearance())
const transparentColor = { r: 100, g: 100, b: 100, a: 0 }
act(() => {
result.current.setAppAccentBgColor(transparentColor)
})
expect(result.current.appAccentBgColor).toEqual(transparentColor)
})
it('should preserve alpha when blur is supported (macOS)', () => {
// Mock macOS environment
Object.defineProperty(global, 'IS_MACOS', { value: true, writable: true })
Object.defineProperty(global, 'IS_TAURI', { value: true, writable: true })
Object.defineProperty(global, 'IS_WINDOWS', { value: false, writable: true })
Object.defineProperty(global, 'IS_LINUX', { value: false, writable: true })
const setPropertySpy = vi.fn()
Object.defineProperty(document.documentElement, 'style', {
value: {
setProperty: setPropertySpy,
},
writable: true,
})
const { result } = renderHook(() => useAppearance())
const testColor = { r: 128, g: 64, b: 192, a: 0.5 }
act(() => {
result.current.setAppBgColor(testColor)
})
// On macOS with Tauri, alpha should be preserved
expect(result.current.appBgColor).toEqual(testColor)
// Reset for other tests
Object.defineProperty(global, 'IS_MACOS', { value: false, writable: true })
Object.defineProperty(global, 'IS_TAURI', { value: false, writable: true })
})
})
describe('Edge cases', () => {

View File

@ -4,6 +4,9 @@ import { localStorageKey } from '@/constants/localStorage'
import { RgbaColor } from 'react-colorful'
import { rgb, oklch, formatCss } from 'culori'
import { useTheme } from './useTheme'
import { useEffect, useState } from 'react'
import { getServiceHub } from '@/hooks/useServiceHub'
import { supportsBlurEffects } from '@/utils/blurSupport'
export type FontSize = '14px' | '15px' | '16px' | '18px'
export type ChatWidth = 'full' | 'compact'
@ -41,19 +44,37 @@ export const fontSizeOptions = [
{ label: 'Extra Large', value: '18px' as FontSize },
]
// Helper to determine if blur effects are supported
// This will be dynamically checked on Windows and Linux
let blurEffectsSupported = true
if ((IS_WINDOWS || IS_LINUX) && IS_TAURI) {
// Default to false for Windows/Linux, will be checked async
blurEffectsSupported = false
}
// Helper to get the appropriate alpha value
const getAlphaValue = () => {
// Web always uses alpha = 1
if (!IS_TAURI) return 1
// Windows/Linux use 1 if blur not supported, 0.4 if supported
if ((IS_WINDOWS || IS_LINUX) && !blurEffectsSupported) return 1
// macOS and Windows/Linux with blur support use 0.4
return 0.4
}
// Default appearance settings
const defaultFontSize: FontSize = '15px'
const defaultAppBgColor: RgbaColor = {
r: 25,
g: 25,
b: 25,
a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.4,
a: getAlphaValue(),
}
const defaultLightAppBgColor: RgbaColor = {
r: 255,
g: 255,
b: 255,
a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.4,
a: getAlphaValue(),
}
const defaultAppMainViewBgColor: RgbaColor = { r: 25, g: 25, b: 25, a: 1 }
const defaultLightAppMainViewBgColor: RgbaColor = {
@ -89,10 +110,15 @@ const isColorEqual = (color1: RgbaColor, color2: RgbaColor): boolean => {
// Helper function to check if color is default (not customized)
export const isDefaultColor = (color: RgbaColor): boolean => {
return (
isColorEqual(color, defaultAppBgColor) ||
isColorEqual(color, defaultLightAppBgColor)
)
// Check if RGB matches default (ignore alpha since it changes based on blur support)
const isDarkDefault = color.r === 25 && color.g === 25 && color.b === 25
const isLightDefault = color.r === 255 && color.g === 255 && color.b === 255
// Consider it default if RGB matches and alpha is either 0.4 or 1 (common values)
const hasDefaultAlpha =
Math.abs(color.a - 0.4) < 0.01 || Math.abs(color.a - 1) < 0.01
return (isDarkDefault || isLightDefault) && hasDefaultAlpha
}
export const isDefaultColorMainView = (color: RgbaColor): boolean => {
@ -128,6 +154,59 @@ export const getDefaultTextColor = (isDark: boolean): string => {
return isDark ? defaultDarkLeftPanelTextColor : defaultLightLeftPanelTextColor
}
// Hook to check if alpha slider should be shown
export const useBlurSupport = () => {
const [supportsBlur, setSupportsBlur] = useState(
IS_MACOS && IS_TAURI // Default to true only for macOS
)
useEffect(() => {
const checkBlurSupport = async () => {
if ((IS_WINDOWS || IS_LINUX) && IS_TAURI) {
try {
// Get hardware info to check OS version
const hardwareInfo = await getServiceHub()
.hardware()
.getHardwareInfo()
const supported = supportsBlurEffects(hardwareInfo)
blurEffectsSupported = supported
setSupportsBlur(supported)
const platform = IS_WINDOWS ? 'Windows' : 'Linux'
if (supported) {
console.log(
`${platform} blur effects: SUPPORTED - Alpha slider will be shown`
)
} else {
console.log(
`${platform} blur effects: NOT SUPPORTED - Alpha slider will be hidden, alpha set to 1`
)
}
} catch (error) {
console.error(
`❌ Failed to check ${IS_WINDOWS ? 'Windows' : 'Linux'} blur support:`,
error
)
setSupportsBlur(false)
}
} else if (IS_MACOS && IS_TAURI) {
console.log(
'🍎 macOS platform: Blur effects supported, alpha slider shown'
)
} else if (!IS_TAURI) {
console.log('🌐 Web platform: Alpha slider hidden, alpha set to 1')
}
}
checkBlurSupport()
}, [])
// Return true if alpha slider should be shown
// Show on macOS (always), and conditionally on Windows/Linux based on detection
return IS_TAURI && (IS_MACOS || supportsBlur)
}
export const useAppearance = create<AppearanceState>()(
persist(
(set) => {
@ -154,8 +233,11 @@ export const useAppearance = create<AppearanceState>()(
defaultFontSize
)
// Reset app background color
const defaultBg = isDark ? defaultAppBgColor : defaultLightAppBgColor
// Reset app background color with correct alpha based on blur support
const currentAlpha = blurEffectsSupported && IS_TAURI ? 0.4 : 1
const defaultBg = isDark
? { r: 25, g: 25, b: 25, a: currentAlpha }
: { r: 255, g: 255, b: 255, a: currentAlpha }
const culoriRgbBg = rgb({
mode: 'rgb',
r: defaultBg.r / 255,
@ -295,6 +377,11 @@ export const useAppearance = create<AppearanceState>()(
finalColor = isDark ? defaultAppBgColor : defaultLightAppBgColor
}
// Force alpha to 1 if blur effects are not supported
if (!blurEffectsSupported && (IS_WINDOWS || IS_LINUX || !IS_TAURI)) {
finalColor = { ...finalColor, a: 1 }
}
// Convert RGBA to a format culori can work with
const culoriRgb = rgb({
mode: 'rgb',
@ -565,11 +652,9 @@ export const useAppearance = create<AppearanceState>()(
// Get the current theme state
const { isDark } = useTheme.getState()
// If stored color is default, use theme-appropriate default
let finalColor = state.appBgColor
if (isDefaultColor(state.appBgColor)) {
finalColor = isDark ? defaultAppBgColor : defaultLightAppBgColor
}
// Just use the stored color as-is during rehydration
// The AppearanceProvider will handle alpha normalization after blur detection
const finalColor = state.appBgColor
let finalColorMainView = state.appMainViewBgColor
if (isDefaultColorMainView(state.appMainViewBgColor)) {

View File

@ -32,7 +32,9 @@ export const useTheme = create<ThemeState>()(
await getServiceHub().theme().setTheme(null)
set(() => ({ activeTheme, isDark: isDarkMode }))
} else {
await getServiceHub().theme().setTheme(activeTheme as ThemeMode)
await getServiceHub()
.theme()
.setTheme(activeTheme as ThemeMode)
set(() => ({ activeTheme, isDark: activeTheme === 'dark' }))
}
},

View File

@ -56,7 +56,6 @@
@layer base {
body {
@apply overflow-hidden;
background-color: white;
min-height: 100vh;
min-height: -webkit-fill-available;
padding-top: env(safe-area-inset-top, 0px);

View File

@ -1,5 +1,5 @@
import { useEffect } from 'react'
import { useAppearance } from '@/hooks/useAppearance'
import { useAppearance, useBlurSupport } from '@/hooks/useAppearance'
import { useTheme } from '@/hooks/useTheme'
import {
isDefaultColor,
@ -29,14 +29,37 @@ export function AppearanceProvider() {
appDestructiveTextColor,
} = useAppearance()
const { isDark } = useTheme()
const showAlphaSlider = useBlurSupport()
// Force re-apply appearance on mount to fix theme desync issues on Windows
// This ensures that when navigating to routes (like logs), the theme is properly applied
useEffect(() => {
const {
setAppBgColor,
setAppMainViewBgColor,
appBgColor,
appMainViewBgColor,
} = useAppearance.getState()
// Re-trigger setters to ensure CSS variables are applied with correct theme
setAppBgColor(appBgColor)
setAppMainViewBgColor(appMainViewBgColor)
}, []) // Run once on mount
// Update colors when blur support changes (important for Windows/Linux)
useEffect(() => {
const { setAppBgColor, appBgColor } = useAppearance.getState()
// Re-apply color to update alpha based on blur support
setAppBgColor(appBgColor)
}, [showAlphaSlider])
// Apply appearance settings on mount and when they change
useEffect(() => {
// Apply font size
document.documentElement.style.setProperty('--font-size-base', fontSize)
// Hide alpha slider when IS_LINUX || !IS_TAURI
const shouldHideAlpha = IS_LINUX || !IS_TAURI
// Hide alpha slider when blur is not supported
const shouldHideAlpha = !showAlphaSlider
let alphaStyleElement = document.getElementById('alpha-slider-style')
if (shouldHideAlpha) {
@ -55,12 +78,13 @@ export function AppearanceProvider() {
// Import culori functions dynamically to avoid SSR issues
import('culori').then(({ rgb, oklch, formatCss }) => {
// Convert RGBA to a format culori can work with
// Use alpha = 1 when blur is not supported
const culoriRgb = rgb({
mode: 'rgb',
r: appBgColor.r / 255,
g: appBgColor.g / 255,
b: appBgColor.b / 255,
alpha: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : appBgColor.a,
alpha: showAlphaSlider ? appBgColor.a : 1,
})
const culoriRgbMainView = rgb({
@ -176,6 +200,7 @@ export function AppearanceProvider() {
appAccentTextColor,
appDestructiveBgColor,
appDestructiveTextColor,
showAlphaSlider,
])
// Update appearance when theme changes
@ -194,6 +219,10 @@ export function AppearanceProvider() {
setAppDestructiveBgColor,
} = useAppearance.getState()
// Force re-apply all colors when theme changes to ensure correct dark/light defaults
// This is especially important on Windows where the theme might not be properly
// synchronized when navigating to different routes (e.g., logs page)
// If using default background color, update it when theme changes
if (isDefaultColor(appBgColor)) {
// This will trigger the appropriate updates for both background and text color

View File

@ -1,5 +1,6 @@
import { useEffect } from 'react'
import { useTheme, checkOSDarkMode } from '@/hooks/useTheme'
import { isPlatformTauri } from '@/lib/platform/utils'
/**
* ThemeProvider ensures theme settings are applied on every page load
@ -11,13 +12,22 @@ export function ThemeProvider() {
// Detect OS theme on mount and apply it
useEffect(() => {
// If theme is set to auto, detect OS preference
if (activeTheme === 'auto') {
const isDarkMode = checkOSDarkMode()
setIsDark(isDarkMode)
setTheme('auto')
// Force refresh theme on mount to handle Linux startup timing issues
const refreshTheme = () => {
if (activeTheme === 'auto') {
const isDarkMode = checkOSDarkMode()
setIsDark(isDarkMode)
setTheme('auto')
}
}
// Initial refresh
refreshTheme()
// On Linux, desktop environment may not be ready immediately
// Add a delayed refresh to catch the correct OS theme
const timeoutId = setTimeout(refreshTheme, 100)
// Listen for changes in OS theme preference
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
@ -26,16 +36,41 @@ export function ThemeProvider() {
if (activeTheme === 'auto') {
setIsDark(e.matches)
} else {
setTheme(e.matches ? 'dark' : 'light')
setTheme(activeTheme)
}
}
// Add event listener
// Add event listener for browser/web
mediaQuery.addEventListener('change', handleThemeChange)
// Listen to Tauri native theme events (uses XDG Desktop Portal on Linux)
let unlistenTauri: (() => void) | undefined
if (isPlatformTauri()) {
import('@tauri-apps/api/event')
.then(({ listen }) => {
return listen<string>('theme-changed', (event) => {
if (activeTheme === 'auto') {
const isDark = event.payload === 'dark'
setIsDark(isDark)
}
})
})
.then((unlisten) => {
unlistenTauri = unlisten
})
.catch((err) => {
console.error('Failed to setup Tauri theme listener:', err)
})
}
// Clean up
return () => {
clearTimeout(timeoutId)
mediaQuery.removeEventListener('change', handleThemeChange)
if (unlistenTauri) {
unlistenTauri()
}
}
}, [activeTheme, setIsDark, setTheme])

View File

@ -11,7 +11,7 @@ import SetupScreen from '@/containers/SetupScreen'
import { route } from '@/constants/routes'
type SearchParams = {
model?: {
'model'?: {
id: string
provider: string
}
@ -33,7 +33,10 @@ export const Route = createFileRoute(route.home as any)({
}
// Only include temporary-chat if it's explicitly true
if (search[TEMPORARY_CHAT_QUERY_ID] === 'true' || search[TEMPORARY_CHAT_QUERY_ID] === true) {
if (
search[TEMPORARY_CHAT_QUERY_ID] === 'true' ||
search[TEMPORARY_CHAT_QUERY_ID] === true
) {
result['temporary-chat'] = true
}
@ -77,7 +80,7 @@ function Index() {
</HeaderPage>
<div
className={cn(
'h-full overflow-y-auto flex flex-col gap-2 justify-center px-3 sm:px-4 md:px-8 py-4 md:py-0'
'h-full overflow-y-auto inline-flex flex-col gap-2 justify-center px-3 sm:px-4 md:px-8 py-4 md:py-0'
)}
>
<div
@ -110,7 +113,9 @@ function Index() {
isMobile ? 'text-base' : 'text-lg'
)}
>
{isTemporaryChat ? t('chat:temporaryChatDescription') : t('chat:description')}
{isTemporaryChat
? t('chat:temporaryChatDescription')
: t('chat:description')}
</p>
</div>
<div className="flex-1 shrink-0">

View File

@ -31,17 +31,20 @@ function LogsViewer() {
useEffect(() => {
let lastLogsLength = 0
function updateLogs() {
serviceHub.app().readLogs().then((logData) => {
let needScroll = false
const filteredLogs = logData.filter(Boolean) as LogEntry[]
if (filteredLogs.length > lastLogsLength) needScroll = true
serviceHub
.app()
.readLogs()
.then((logData) => {
let needScroll = false
const filteredLogs = logData.filter(Boolean) as LogEntry[]
if (filteredLogs.length > lastLogsLength) needScroll = true
lastLogsLength = filteredLogs.length
setLogs(filteredLogs)
lastLogsLength = filteredLogs.length
setLogs(filteredLogs)
// Scroll to bottom after initial logs are loaded
if (needScroll) setTimeout(() => scrollToBottom(), 100)
})
// Scroll to bottom after initial logs are loaded
if (needScroll) setTimeout(() => scrollToBottom(), 100)
})
}
updateLogs()

View File

@ -2,7 +2,8 @@
* Tauri Theme Service - Desktop implementation
*/
import { getCurrentWindow, Theme } from '@tauri-apps/api/window'
import { Theme } from '@tauri-apps/api/window'
import { getAllWebviewWindows, type WebviewWindow } from '@tauri-apps/api/webviewWindow'
import type { ThemeMode } from './types'
import { DefaultThemeService } from './default'
@ -10,7 +11,27 @@ export class TauriThemeService extends DefaultThemeService {
async setTheme(theme: ThemeMode): Promise<void> {
try {
const tauriTheme = theme as Theme | null
await getCurrentWindow().setTheme(tauriTheme)
// Update all open windows, not just the current one
const allWindows = await getAllWebviewWindows()
// Convert to array if it's not already
const windowsArray: WebviewWindow[] = Array.isArray(allWindows)
? allWindows
: Object.values(allWindows)
await Promise.all(
windowsArray.map(async (window) => {
try {
await window.setTheme(tauriTheme)
} catch (error) {
console.error(
`Failed to set theme for window ${window.label}:`,
error
)
}
})
)
} catch (error) {
console.error('Error setting theme in Tauri:', error)
throw error
@ -21,7 +42,7 @@ export class TauriThemeService extends DefaultThemeService {
return {
setTheme: (theme: ThemeMode): Promise<void> => {
return this.setTheme(theme)
}
},
}
}
}

View File

@ -7,8 +7,39 @@ import type { WindowConfig, WebviewWindowInstance } from './types'
import { DefaultWindowService } from './default'
export class TauriWindowService extends DefaultWindowService {
async createWebviewWindow(config: WindowConfig): Promise<WebviewWindowInstance> {
async createWebviewWindow(
config: WindowConfig
): Promise<WebviewWindowInstance> {
try {
// Get current theme from localStorage
const storedTheme = localStorage.getItem('jan-theme')
let theme: 'light' | 'dark' | undefined = undefined
if (storedTheme) {
try {
const themeData = JSON.parse(storedTheme)
const activeTheme = themeData?.state?.activeTheme
const isDark = themeData?.state?.isDark
// Set theme based on stored preference
if (activeTheme === 'auto') {
theme = undefined // Let OS decide
} else if (
activeTheme === 'dark' ||
(activeTheme === 'auto' && isDark)
) {
theme = 'dark'
} else if (
activeTheme === 'light' ||
(activeTheme === 'auto' && !isDark)
) {
theme = 'light'
}
} catch (e) {
console.warn('Failed to parse theme from localStorage:', e)
}
}
const webviewWindow = new WebviewWindow(config.label, {
url: config.url,
title: config.title,
@ -20,8 +51,12 @@ export class TauriWindowService extends DefaultWindowService {
maximizable: config.maximizable,
closable: config.closable,
fullscreen: config.fullscreen,
theme: theme,
})
// Setup theme listener for this window
this.setupThemeListenerForWindow(webviewWindow)
return {
label: config.label,
async close() {
@ -38,7 +73,7 @@ export class TauriWindowService extends DefaultWindowService {
},
async setTitle(title: string) {
await webviewWindow.setTitle(title)
}
},
}
} catch (error) {
console.error('Error creating Tauri window:', error)
@ -46,10 +81,12 @@ export class TauriWindowService extends DefaultWindowService {
}
}
async getWebviewWindowByLabel(label: string): Promise<WebviewWindowInstance | null> {
async getWebviewWindowByLabel(
label: string
): Promise<WebviewWindowInstance | null> {
try {
const existingWindow = await WebviewWindow.getByLabel(label)
if (existingWindow) {
return {
label: label,
@ -67,10 +104,10 @@ export class TauriWindowService extends DefaultWindowService {
},
async setTitle(title: string) {
await existingWindow.setTitle(title)
}
},
}
}
return null
} catch (error) {
console.error('Error getting Tauri window by label:', error)
@ -135,8 +172,35 @@ export class TauriWindowService extends DefaultWindowService {
center: true,
})
} catch (error) {
console.error('Error opening local API server logs window in Tauri:', error)
console.error(
'Error opening local API server logs window in Tauri:',
error
)
throw error
}
}
private setupThemeListenerForWindow(window: WebviewWindow): void {
// Listen to theme change events from Tauri backend
import('@tauri-apps/api/event')
.then(({ listen }) => {
return listen<string>('theme-changed', async (event) => {
const theme = event.payload
try {
if (theme === 'dark') {
await window.setTheme('dark')
} else if (theme === 'light') {
await window.setTheme('light')
} else {
await window.setTheme(null)
}
} catch (err) {
console.error('Failed to update window theme:', err)
}
})
})
.catch((err) => {
console.error('Failed to setup theme listener for window:', err)
})
}
}

View File

@ -0,0 +1,65 @@
/**
* Utility to check if the system supports blur/acrylic effects
* based on OS information from hardware data
*/
import type { HardwareData } from '@/hooks/useHardware'
/**
* Check if Windows supports blur effects based on build number
* Windows 10 build 17134 (version 1803) and later support acrylic effects
*/
function checkWindowsBlurSupport(osName: string): boolean {
// os_name format: "Windows 10 Pro (build 22631)" or similar
const buildMatch = osName.match(/build\s+(\d+)/i)
if (buildMatch && buildMatch[1]) {
const build = parseInt(buildMatch[1], 10)
return build >= 17134
}
// If we can't detect build number, assume modern Windows supports blur
return true
}
/**
* Check if Linux supports blur effects based on desktop environment
*/
function checkLinuxBlurSupport(): boolean {
// Check environment variables (only available in Tauri)
if (typeof window === 'undefined') return false
// These checks would need to be done on the backend
// For now, we'll assume Linux with common DEs supports blur
return true
}
/**
* Check if the system supports blur/acrylic effects
*
* @param hardwareData - Hardware data from the hardware plugin
* @returns true if blur effects are supported
*/
export function supportsBlurEffects(hardwareData: HardwareData | null): boolean {
if (!hardwareData) return false
const { os_type, os_name } = hardwareData
// macOS always supports blur/vibrancy effects
if (os_type === 'macos') {
return true
}
// Windows: Check build number
if (os_type === 'windows') {
return checkWindowsBlurSupport(os_name)
}
// Linux: Check desktop environment (simplified for now)
if (os_type === 'linux') {
return checkLinuxBlurSupport()
}
// Unknown platforms: assume no blur support
return false
}