Merge branch 'dev' into mobile/jan-provider
This commit is contained in:
commit
b6a71828da
@ -12,6 +12,8 @@
|
|||||||
"core:webview:allow-set-webview-zoom",
|
"core:webview:allow-set-webview-zoom",
|
||||||
"core:window:allow-start-dragging",
|
"core:window:allow-start-dragging",
|
||||||
"core:window:allow-set-theme",
|
"core:window:allow-set-theme",
|
||||||
|
"core:window:allow-get-all-windows",
|
||||||
|
"core:event:allow-listen",
|
||||||
"shell:allow-spawn",
|
"shell:allow-spawn",
|
||||||
"shell:allow-open",
|
"shell:allow-open",
|
||||||
"core:app:allow-set-app-theme",
|
"core:app:allow-set-app-theme",
|
||||||
|
|||||||
@ -1,14 +1,18 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "logs-app-window",
|
"identifier": "log-app-window",
|
||||||
"description": "enables permissions for the logs app window",
|
"description": "enables permissions for the logs app window",
|
||||||
"windows": ["logs-app-window"],
|
"windows": ["logs-app-window"],
|
||||||
|
"platforms": ["linux", "macOS", "windows"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"core:window:allow-start-dragging",
|
"core:window:allow-start-dragging",
|
||||||
"core:window:allow-set-theme",
|
"core:window:allow-set-theme",
|
||||||
|
"core:window:allow-get-all-windows",
|
||||||
|
"core:event:allow-listen",
|
||||||
"log:default",
|
"log:default",
|
||||||
"core:webview:allow-create-webview-window",
|
"core:webview:allow-create-webview-window",
|
||||||
|
"core:webview:allow-get-all-webviews",
|
||||||
"core:window:allow-set-focus"
|
"core:window:allow-set-focus"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,12 +3,16 @@
|
|||||||
"identifier": "logs-window",
|
"identifier": "logs-window",
|
||||||
"description": "enables permissions for the logs window",
|
"description": "enables permissions for the logs window",
|
||||||
"windows": ["logs-window-local-api-server"],
|
"windows": ["logs-window-local-api-server"],
|
||||||
|
"platforms": ["linux", "macOS", "windows"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"core:window:allow-start-dragging",
|
"core:window:allow-start-dragging",
|
||||||
"core:window:allow-set-theme",
|
"core:window:allow-set-theme",
|
||||||
|
"core:window:allow-get-all-windows",
|
||||||
|
"core:event:allow-listen",
|
||||||
"log:default",
|
"log:default",
|
||||||
"core:webview:allow-create-webview-window",
|
"core:webview:allow-create-webview-window",
|
||||||
|
"core:webview:allow-get-all-webviews",
|
||||||
"core:window:allow-set-focus"
|
"core:window:allow-set-focus"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,13 +8,28 @@
|
|||||||
"core:default",
|
"core:default",
|
||||||
"core:window:allow-start-dragging",
|
"core:window:allow-start-dragging",
|
||||||
"core:window:allow-set-theme",
|
"core:window:allow-set-theme",
|
||||||
|
"core:window:allow-get-all-windows",
|
||||||
|
"core:event:allow-listen",
|
||||||
"log:default",
|
"log:default",
|
||||||
"core:webview:allow-create-webview-window",
|
"core:webview:allow-create-webview-window",
|
||||||
|
"core:webview:allow-get-all-webviews",
|
||||||
"core:window:allow-set-focus",
|
"core:window:allow-set-focus",
|
||||||
"hardware:allow-get-system-info",
|
"hardware:allow-get-system-info",
|
||||||
"hardware:allow-get-system-usage",
|
"hardware:allow-get-system-usage",
|
||||||
"llamacpp:allow-get-devices",
|
"llamacpp:allow-get-devices",
|
||||||
"llamacpp:allow-read-gguf-metadata",
|
"llamacpp:allow-read-gguf-metadata",
|
||||||
"deep-link:allow-get-current"
|
"deep-link:allow-get-current",
|
||||||
|
{
|
||||||
|
"identifier": "http:default",
|
||||||
|
"allow": [
|
||||||
|
{
|
||||||
|
"url": "https://*:*"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://*:*"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"deny": []
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,7 @@ use std::{
|
|||||||
};
|
};
|
||||||
use tar::Archive;
|
use tar::Archive;
|
||||||
use tauri::{
|
use tauri::{
|
||||||
App, Emitter, Manager, Runtime, Wry
|
App, Emitter, Manager, Runtime, Wry, WindowEvent
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(desktop)]
|
#[cfg(desktop)]
|
||||||
@ -270,3 +270,32 @@ pub fn setup_tray(app: &App) -> tauri::Result<TrayIcon> {
|
|||||||
})
|
})
|
||||||
.build(app)
|
.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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -117,3 +117,4 @@ pub fn is_library_available(library: &str) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -193,6 +193,7 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setup_mcp(app);
|
setup_mcp(app);
|
||||||
|
setup::setup_theme_listener(app)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.build(tauri::generate_context!())
|
.build(tauri::generate_context!())
|
||||||
|
|||||||
@ -40,7 +40,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"capabilities": ["default"],
|
"capabilities": ["default", "logs-app-window", "logs-window", "system-monitor-window"],
|
||||||
"csp": {
|
"csp": {
|
||||||
"default-src": "'self' customprotocol: asset: http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:*",
|
"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:",
|
"connect-src": "ipc: http://ipc.localhost http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* https: http:",
|
||||||
|
|||||||
@ -1,7 +1,12 @@
|
|||||||
{
|
{
|
||||||
"app": {
|
"app": {
|
||||||
"security": {
|
"security": {
|
||||||
"capabilities": ["desktop", "system-monitor-window"]
|
"capabilities": [
|
||||||
|
"desktop",
|
||||||
|
"system-monitor-window",
|
||||||
|
"log-app-window",
|
||||||
|
"logs-window"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|||||||
@ -1,7 +1,12 @@
|
|||||||
{
|
{
|
||||||
"app": {
|
"app": {
|
||||||
"security": {
|
"security": {
|
||||||
"capabilities": ["desktop", "system-monitor-window"]
|
"capabilities": [
|
||||||
|
"desktop",
|
||||||
|
"system-monitor-window",
|
||||||
|
"log-app-window",
|
||||||
|
"logs-window"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|||||||
@ -1,7 +1,12 @@
|
|||||||
{
|
{
|
||||||
"app": {
|
"app": {
|
||||||
"security": {
|
"security": {
|
||||||
"capabilities": ["desktop"]
|
"capabilities": [
|
||||||
|
"desktop",
|
||||||
|
"system-monitor-window",
|
||||||
|
"log-app-window",
|
||||||
|
"logs-window"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,7 @@
|
|||||||
<link rel="apple-touch-icon" href="/images/jan-logo.png" />
|
<link rel="apple-touch-icon" href="/images/jan-logo.png" />
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
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>
|
<title>Jan</title>
|
||||||
<!-- INJECT_GOOGLE_ANALYTICS -->
|
<!-- INJECT_GOOGLE_ANALYTICS -->
|
||||||
|
|||||||
@ -229,7 +229,7 @@ function DropdownMenuSubContent({
|
|||||||
<DropdownMenuPrimitive.SubContent
|
<DropdownMenuPrimitive.SubContent
|
||||||
data-slot="dropdown-menu-sub-content"
|
data-slot="dropdown-menu-sub-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-main-view text-main-view-fg border-main-view-fg/5 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[51] min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
|
'bg-main-view text-main-view-fg border-main-view-fg/5 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[51] min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto max-h-[var(--radix-dropdown-menu-content-available-height)] rounded-md border p-1 shadow-lg',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useAppearance, isDefaultColor } from '@/hooks/useAppearance'
|
import { useAppearance, useBlurSupport } from '@/hooks/useAppearance'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { RgbaColor, RgbaColorPicker } from 'react-colorful'
|
import { RgbaColor, RgbaColorPicker } from 'react-colorful'
|
||||||
import { IconColorPicker } from '@tabler/icons-react'
|
import { IconColorPicker } from '@tabler/icons-react'
|
||||||
@ -14,6 +14,12 @@ export function ColorPickerAppBgColor() {
|
|||||||
const { appBgColor, setAppBgColor } = useAppearance()
|
const { appBgColor, setAppBgColor } = useAppearance()
|
||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
const { t } = useTranslation()
|
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[] = [
|
const predefineAppBgColor: RgbaColor[] = [
|
||||||
isDark
|
isDark
|
||||||
@ -21,55 +27,64 @@ export function ColorPickerAppBgColor() {
|
|||||||
r: 25,
|
r: 25,
|
||||||
g: 25,
|
g: 25,
|
||||||
b: 25,
|
b: 25,
|
||||||
a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.4,
|
a: getAlpha(0.4),
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
r: 255,
|
r: 255,
|
||||||
g: 255,
|
g: 255,
|
||||||
b: 255,
|
b: 255,
|
||||||
a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.4,
|
a: getAlpha(0.4),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
r: 70,
|
r: 70,
|
||||||
g: 79,
|
g: 79,
|
||||||
b: 229,
|
b: 229,
|
||||||
a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.5,
|
a: getAlpha(0.5),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
r: 238,
|
r: 238,
|
||||||
g: 130,
|
g: 130,
|
||||||
b: 238,
|
b: 238,
|
||||||
a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.5,
|
a: getAlpha(0.5),
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
r: 255,
|
r: 255,
|
||||||
g: 99,
|
g: 99,
|
||||||
b: 71,
|
b: 71,
|
||||||
a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.5,
|
a: getAlpha(0.5),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
r: 255,
|
r: 255,
|
||||||
g: 165,
|
g: 165,
|
||||||
b: 0,
|
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 (
|
return (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{predefineAppBgColor.map((item, i) => {
|
{predefineAppBgColor.map((item, i) => {
|
||||||
const isSelected =
|
const isSelected =
|
||||||
(item.r === appBgColor.r &&
|
(item.r === appBgColor.r &&
|
||||||
item.g === appBgColor.g &&
|
item.g === appBgColor.g &&
|
||||||
item.b === appBgColor.b &&
|
item.b === appBgColor.b &&
|
||||||
item.a === appBgColor.a) ||
|
Math.abs(item.a - appBgColor.a) < 0.01) ||
|
||||||
(isDefaultColor(appBgColor) && isDefaultColor(item))
|
(isColorDefault(appBgColor) && isColorDefault(item))
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={cn(
|
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'
|
isSelected && 'ring-2 ring-accent border-none'
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@ -33,7 +33,7 @@ const DropdownAssistant = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
<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>
|
<DropdownMenuTrigger asChild>
|
||||||
<button className="font-medium cursor-pointer flex items-center gap-1.5 relative z-20 max-w-40">
|
<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">
|
<div className="text-main-view-fg/80 flex items-center gap-1">
|
||||||
|
|||||||
@ -228,12 +228,11 @@ const DropdownModelProvider = ({
|
|||||||
selectModelProvider,
|
selectModelProvider,
|
||||||
updateCurrentThreadModel,
|
updateCurrentThreadModel,
|
||||||
providers,
|
providers,
|
||||||
useLastUsedModel,
|
|
||||||
checkModelExists,
|
checkModelExists,
|
||||||
updateProvider,
|
updateProvider,
|
||||||
getProviderByName,
|
getProviderByName,
|
||||||
checkAndUpdateModelVisionCapability,
|
checkAndUpdateModelVisionCapability,
|
||||||
serviceHub,
|
|
||||||
// selectedModel and selectedProvider intentionally excluded to prevent race conditions
|
// selectedModel and selectedProvider intentionally excluded to prevent race conditions
|
||||||
])
|
])
|
||||||
|
|
||||||
@ -326,7 +325,8 @@ const DropdownModelProvider = ({
|
|||||||
// Create Fzf instance for fuzzy search
|
// Create Fzf instance for fuzzy search
|
||||||
const fzfInstance = useMemo(() => {
|
const fzfInstance = useMemo(() => {
|
||||||
return new Fzf(searchableItems, {
|
return new Fzf(searchableItems, {
|
||||||
selector: (item) => `${getModelDisplayName(item.model)} ${item.model.id}`.toLowerCase(),
|
selector: (item) =>
|
||||||
|
`${getModelDisplayName(item.model)} ${item.model.id}`.toLowerCase(),
|
||||||
})
|
})
|
||||||
}, [searchableItems])
|
}, [searchableItems])
|
||||||
|
|
||||||
@ -404,12 +404,10 @@ const DropdownModelProvider = ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Store the selected model as last used
|
// Store the selected model as last used
|
||||||
if (useLastUsedModel) {
|
setLastUsedModel(
|
||||||
setLastUsedModel(
|
searchableModel.provider.provider,
|
||||||
searchableModel.provider.provider,
|
searchableModel.model.id
|
||||||
searchableModel.model.id
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check mmproj existence for llamacpp models (async, don't block UI)
|
// Check mmproj existence for llamacpp models (async, don't block UI)
|
||||||
if (searchableModel.provider.provider === 'llamacpp') {
|
if (searchableModel.provider.provider === 'llamacpp') {
|
||||||
@ -443,7 +441,6 @@ const DropdownModelProvider = ({
|
|||||||
[
|
[
|
||||||
selectModelProvider,
|
selectModelProvider,
|
||||||
updateCurrentThreadModel,
|
updateCurrentThreadModel,
|
||||||
useLastUsedModel,
|
|
||||||
updateProvider,
|
updateProvider,
|
||||||
getProviderByName,
|
getProviderByName,
|
||||||
checkAndUpdateModelVisionCapability,
|
checkAndUpdateModelVisionCapability,
|
||||||
|
|||||||
@ -154,7 +154,6 @@ const LeftPanel = () => {
|
|||||||
}
|
}
|
||||||
}, [setLeftPanel, open])
|
}, [setLeftPanel, open])
|
||||||
|
|
||||||
|
|
||||||
const currentPath = useRouterState({
|
const currentPath = useRouterState({
|
||||||
select: (state) => state.location.pathname,
|
select: (state) => state.location.pathname,
|
||||||
})
|
})
|
||||||
@ -581,6 +580,10 @@ const LeftPanel = () => {
|
|||||||
|
|
||||||
{filteredThreads.length === 0 && searchTerm.length > 0 && (
|
{filteredThreads.length === 0 && searchTerm.length > 0 && (
|
||||||
<div className="px-1 mt-2">
|
<div className="px-1 mt-2">
|
||||||
|
<span className="block text-xs text-left-panel-fg/50 px-1 font-semibold mb-2">
|
||||||
|
{t('common:recents')}
|
||||||
|
</span>
|
||||||
|
|
||||||
<div className="flex items-center gap-1 text-left-panel-fg/80">
|
<div className="flex items-center gap-1 text-left-panel-fg/80">
|
||||||
<IconSearch size={18} />
|
<IconSearch size={18} />
|
||||||
<h6 className="font-medium text-base">
|
<h6 className="font-medium text-base">
|
||||||
@ -640,7 +643,7 @@ const LeftPanel = () => {
|
|||||||
data-test-id={`menu-${menu.title}`}
|
data-test-id={`menu-${menu.title}`}
|
||||||
activeOptions={{ exact: true }}
|
activeOptions={{ exact: true }}
|
||||||
className={cn(
|
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'
|
isActive && 'bg-left-panel-fg/10'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -237,13 +237,13 @@ const SortableItem = memo(
|
|||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
<DropdownMenuSubTrigger className="gap-2">
|
<DropdownMenuSubTrigger className="gap-2">
|
||||||
<IconFolder size={16} />
|
<IconFolder size={16} />
|
||||||
<span>Add to project</span>
|
<span>{t('common:projects.addToProject')}</span>
|
||||||
</DropdownMenuSubTrigger>
|
</DropdownMenuSubTrigger>
|
||||||
<DropdownMenuSubContent>
|
<DropdownMenuSubContent>
|
||||||
{availableProjects.length === 0 ? (
|
{availableProjects.length === 0 ? (
|
||||||
<DropdownMenuItem disabled>
|
<DropdownMenuItem disabled>
|
||||||
<span className="text-left-panel-fg/50">
|
<span className="text-left-panel-fg/50">
|
||||||
No projects available
|
{t('common:projects.noProjectsAvailable')}
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : (
|
) : (
|
||||||
@ -262,32 +262,29 @@ const SortableItem = memo(
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
{thread.metadata?.project && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
// Remove project from metadata
|
|
||||||
const projectName = thread.metadata?.project?.name
|
|
||||||
updateThread(thread.id, {
|
|
||||||
metadata: {
|
|
||||||
...thread.metadata,
|
|
||||||
project: undefined,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
toast.success(
|
|
||||||
`Thread removed from "${projectName}" successfully`
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconX size={16} />
|
|
||||||
<span>Remove from project</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DropdownMenuSubContent>
|
</DropdownMenuSubContent>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
|
{thread.metadata?.project && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
// Remove project from metadata
|
||||||
|
const projectName = thread.metadata?.project?.name
|
||||||
|
updateThread(thread.id, {
|
||||||
|
metadata: {
|
||||||
|
...thread.metadata,
|
||||||
|
project: undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
toast.success(
|
||||||
|
`Thread removed from "${projectName}" successfully`
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconX size={16} />
|
||||||
|
<span>{t('common:projects.removeFromProject')}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DeleteThreadDialog
|
<DeleteThreadDialog
|
||||||
thread={thread}
|
thread={thread}
|
||||||
|
|||||||
@ -31,6 +31,8 @@ vi.mock('zustand/middleware', () => ({
|
|||||||
// Mock global constants
|
// Mock global constants
|
||||||
Object.defineProperty(global, 'IS_WINDOWS', { value: false, writable: true })
|
Object.defineProperty(global, 'IS_WINDOWS', { value: false, writable: true })
|
||||||
Object.defineProperty(global, 'IS_LINUX', { 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 })
|
Object.defineProperty(global, 'IS_WEB_APP', { value: false, writable: true })
|
||||||
|
|
||||||
describe('useAppearance', () => {
|
describe('useAppearance', () => {
|
||||||
@ -212,24 +214,55 @@ describe('useAppearance', () => {
|
|||||||
|
|
||||||
const { result } = renderHook(() => useAppearance())
|
const { result } = renderHook(() => useAppearance())
|
||||||
const testColor = { r: 128, g: 64, b: 192, a: 0.8 }
|
const testColor = { r: 128, g: 64, b: 192, a: 0.8 }
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.setAppBgColor(testColor)
|
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', () => {
|
it('should handle transparent colors', () => {
|
||||||
const { result } = renderHook(() => useAppearance())
|
const { result } = renderHook(() => useAppearance())
|
||||||
const transparentColor = { r: 100, g: 100, b: 100, a: 0 }
|
const transparentColor = { r: 100, g: 100, b: 100, a: 0 }
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.setAppAccentBgColor(transparentColor)
|
result.current.setAppAccentBgColor(transparentColor)
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.current.appAccentBgColor).toEqual(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', () => {
|
describe('Edge cases', () => {
|
||||||
|
|||||||
@ -4,6 +4,9 @@ import { localStorageKey } from '@/constants/localStorage'
|
|||||||
import { RgbaColor } from 'react-colorful'
|
import { RgbaColor } from 'react-colorful'
|
||||||
import { rgb, oklch, formatCss } from 'culori'
|
import { rgb, oklch, formatCss } from 'culori'
|
||||||
import { useTheme } from './useTheme'
|
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 FontSize = '14px' | '15px' | '16px' | '18px'
|
||||||
export type ChatWidth = 'full' | 'compact'
|
export type ChatWidth = 'full' | 'compact'
|
||||||
@ -41,19 +44,37 @@ export const fontSizeOptions = [
|
|||||||
{ label: 'Extra Large', value: '18px' as FontSize },
|
{ 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
|
// Default appearance settings
|
||||||
const defaultFontSize: FontSize = '15px'
|
const defaultFontSize: FontSize = '15px'
|
||||||
const defaultAppBgColor: RgbaColor = {
|
const defaultAppBgColor: RgbaColor = {
|
||||||
r: 25,
|
r: 25,
|
||||||
g: 25,
|
g: 25,
|
||||||
b: 25,
|
b: 25,
|
||||||
a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.4,
|
a: getAlphaValue(),
|
||||||
}
|
}
|
||||||
const defaultLightAppBgColor: RgbaColor = {
|
const defaultLightAppBgColor: RgbaColor = {
|
||||||
r: 255,
|
r: 255,
|
||||||
g: 255,
|
g: 255,
|
||||||
b: 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 defaultAppMainViewBgColor: RgbaColor = { r: 25, g: 25, b: 25, a: 1 }
|
||||||
const defaultLightAppMainViewBgColor: RgbaColor = {
|
const defaultLightAppMainViewBgColor: RgbaColor = {
|
||||||
@ -89,10 +110,15 @@ const isColorEqual = (color1: RgbaColor, color2: RgbaColor): boolean => {
|
|||||||
|
|
||||||
// Helper function to check if color is default (not customized)
|
// Helper function to check if color is default (not customized)
|
||||||
export const isDefaultColor = (color: RgbaColor): boolean => {
|
export const isDefaultColor = (color: RgbaColor): boolean => {
|
||||||
return (
|
// Check if RGB matches default (ignore alpha since it changes based on blur support)
|
||||||
isColorEqual(color, defaultAppBgColor) ||
|
const isDarkDefault = color.r === 25 && color.g === 25 && color.b === 25
|
||||||
isColorEqual(color, defaultLightAppBgColor)
|
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 => {
|
export const isDefaultColorMainView = (color: RgbaColor): boolean => {
|
||||||
@ -128,6 +154,59 @@ export const getDefaultTextColor = (isDark: boolean): string => {
|
|||||||
return isDark ? defaultDarkLeftPanelTextColor : defaultLightLeftPanelTextColor
|
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>()(
|
export const useAppearance = create<AppearanceState>()(
|
||||||
persist(
|
persist(
|
||||||
(set) => {
|
(set) => {
|
||||||
@ -154,8 +233,11 @@ export const useAppearance = create<AppearanceState>()(
|
|||||||
defaultFontSize
|
defaultFontSize
|
||||||
)
|
)
|
||||||
|
|
||||||
// Reset app background color
|
// Reset app background color with correct alpha based on blur support
|
||||||
const defaultBg = isDark ? defaultAppBgColor : defaultLightAppBgColor
|
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({
|
const culoriRgbBg = rgb({
|
||||||
mode: 'rgb',
|
mode: 'rgb',
|
||||||
r: defaultBg.r / 255,
|
r: defaultBg.r / 255,
|
||||||
@ -295,6 +377,11 @@ export const useAppearance = create<AppearanceState>()(
|
|||||||
finalColor = isDark ? defaultAppBgColor : defaultLightAppBgColor
|
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
|
// Convert RGBA to a format culori can work with
|
||||||
const culoriRgb = rgb({
|
const culoriRgb = rgb({
|
||||||
mode: 'rgb',
|
mode: 'rgb',
|
||||||
@ -565,11 +652,9 @@ export const useAppearance = create<AppearanceState>()(
|
|||||||
// Get the current theme state
|
// Get the current theme state
|
||||||
const { isDark } = useTheme.getState()
|
const { isDark } = useTheme.getState()
|
||||||
|
|
||||||
// If stored color is default, use theme-appropriate default
|
// Just use the stored color as-is during rehydration
|
||||||
let finalColor = state.appBgColor
|
// The AppearanceProvider will handle alpha normalization after blur detection
|
||||||
if (isDefaultColor(state.appBgColor)) {
|
const finalColor = state.appBgColor
|
||||||
finalColor = isDark ? defaultAppBgColor : defaultLightAppBgColor
|
|
||||||
}
|
|
||||||
|
|
||||||
let finalColorMainView = state.appMainViewBgColor
|
let finalColorMainView = state.appMainViewBgColor
|
||||||
if (isDefaultColorMainView(state.appMainViewBgColor)) {
|
if (isDefaultColorMainView(state.appMainViewBgColor)) {
|
||||||
|
|||||||
@ -32,7 +32,9 @@ export const useTheme = create<ThemeState>()(
|
|||||||
await getServiceHub().theme().setTheme(null)
|
await getServiceHub().theme().setTheme(null)
|
||||||
set(() => ({ activeTheme, isDark: isDarkMode }))
|
set(() => ({ activeTheme, isDark: isDarkMode }))
|
||||||
} else {
|
} else {
|
||||||
await getServiceHub().theme().setTheme(activeTheme as ThemeMode)
|
await getServiceHub()
|
||||||
|
.theme()
|
||||||
|
.setTheme(activeTheme as ThemeMode)
|
||||||
set(() => ({ activeTheme, isDark: activeTheme === 'dark' }))
|
set(() => ({ activeTheme, isDark: activeTheme === 'dark' }))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -56,7 +56,6 @@
|
|||||||
@layer base {
|
@layer base {
|
||||||
body {
|
body {
|
||||||
@apply overflow-hidden;
|
@apply overflow-hidden;
|
||||||
background-color: white;
|
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
min-height: -webkit-fill-available;
|
min-height: -webkit-fill-available;
|
||||||
padding-top: env(safe-area-inset-top, 0px);
|
padding-top: env(safe-area-inset-top, 0px);
|
||||||
|
|||||||
@ -272,9 +272,12 @@
|
|||||||
"thread": "Thread",
|
"thread": "Thread",
|
||||||
"threads": "Threads",
|
"threads": "Threads",
|
||||||
"updated": "Aktualisiert:",
|
"updated": "Aktualisiert:",
|
||||||
"collapseThreads": "Threads einklappen",
|
"collapseProject": "Projekt einklappen",
|
||||||
"expandThreads": "Threads ausklappen",
|
"expandProject": "Projekt ausklappen",
|
||||||
"update": "Aktualisieren"
|
"update": "Aktualisieren",
|
||||||
|
"searchProjects": "Projekte durchsuchen...",
|
||||||
|
"noProjectsFound": "Keine Projekte gefunden",
|
||||||
|
"tryDifferentSearch": "Versuchen Sie einen anderen Suchbegriff"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"allThreadsUnfavorited": {
|
"allThreadsUnfavorited": {
|
||||||
@ -356,80 +359,6 @@
|
|||||||
"downloadAndVerificationComplete": {
|
"downloadAndVerificationComplete": {
|
||||||
"title": "Download abgeschlossen",
|
"title": "Download abgeschlossen",
|
||||||
"description": "Modell \"{{item}}\" erfolgreich heruntergeladen und verifiziert"
|
"description": "Modell \"{{item}}\" erfolgreich heruntergeladen und verifiziert"
|
||||||
},
|
|
||||||
"projectCreated": {
|
|
||||||
"title": "Projekt erstellt",
|
|
||||||
"description": "Projekt \"{{projectName}}\" erfolgreich erstellt"
|
|
||||||
},
|
|
||||||
"projectRenamed": {
|
|
||||||
"title": "Projekt umbenannt",
|
|
||||||
"description": "Projekt von \"{{oldName}}\" zu \"{{newName}}\" umbenannt"
|
|
||||||
},
|
|
||||||
"projectDeleted": {
|
|
||||||
"title": "Projekt gelöscht",
|
|
||||||
"description": "Projekt \"{{projectName}}\" erfolgreich gelöscht"
|
|
||||||
},
|
|
||||||
"projectAlreadyExists": {
|
|
||||||
"title": "Projekt existiert bereits",
|
|
||||||
"description": "Projekt \"{{projectName}}\" existiert bereits"
|
|
||||||
},
|
|
||||||
"projectDeleteFailed": {
|
|
||||||
"title": "Löschen fehlgeschlagen",
|
|
||||||
"description": "Projekt konnte nicht gelöscht werden. Bitte versuchen Sie es erneut."
|
|
||||||
},
|
|
||||||
"threadAssignedToProject": {
|
|
||||||
"title": "Thread zugewiesen",
|
|
||||||
"description": "Thread erfolgreich zu \"{{projectName}}\" hinzugefügt"
|
|
||||||
},
|
|
||||||
"threadRemovedFromProject": {
|
|
||||||
"title": "Thread entfernt",
|
|
||||||
"description": "Thread erfolgreich von \"{{projectName}}\" entfernt"
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"projects": {
|
|
||||||
"title": "Projekte",
|
|
||||||
"addProject": "Projekt hinzufügen",
|
|
||||||
"addToProject": "Zu Projekt hinzufügen",
|
|
||||||
"removeFromProject": "Von Projekt entfernen",
|
|
||||||
"createNewProject": "Neues Projekt erstellen",
|
|
||||||
"editProject": "Projekt bearbeiten",
|
|
||||||
"deleteProject": "Projekt löschen",
|
|
||||||
"projectName": "Projektname",
|
|
||||||
"enterProjectName": "Projektname eingeben...",
|
|
||||||
"noProjectsAvailable": "Keine Projekte verfügbar",
|
|
||||||
"noProjectsYet": "Noch keine Projekte",
|
|
||||||
"noProjectsYetDesc": "Starten Sie ein neues Projekt, indem Sie auf die Schaltfläche Projekt hinzufügen klicken.",
|
|
||||||
"projectNotFound": "Projekt nicht gefunden",
|
|
||||||
"projectNotFoundDesc": "Das gesuchte Projekt existiert nicht oder wurde gelöscht.",
|
|
||||||
"deleteProjectDialog": {
|
|
||||||
"title": "Projekt löschen",
|
|
||||||
"description": "Sind Sie sicher, dass Sie dieses Projekt löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
|
||||||
"deleteButton": "Löschen",
|
|
||||||
"successWithName": "Projekt \"{{projectName}}\" erfolgreich gelöscht",
|
|
||||||
"successWithoutName": "Projekt erfolgreich gelöscht",
|
|
||||||
"error": "Projekt konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.",
|
|
||||||
"ariaLabel": "{{projectName}} löschen"
|
|
||||||
},
|
|
||||||
"addProjectDialog": {
|
|
||||||
"createTitle": "Neues Projekt erstellen",
|
|
||||||
"editTitle": "Projekt bearbeiten",
|
|
||||||
"nameLabel": "Projektname",
|
|
||||||
"namePlaceholder": "Projektname eingeben...",
|
|
||||||
"createButton": "Erstellen",
|
|
||||||
"updateButton": "Aktualisieren",
|
|
||||||
"alreadyExists": "Projekt \"{{projectName}}\" existiert bereits",
|
|
||||||
"createSuccess": "Projekt \"{{projectName}}\" erfolgreich erstellt",
|
|
||||||
"renameSuccess": "Projekt von \"{{oldName}}\" zu \"{{newName}}\" umbenannt"
|
|
||||||
},
|
|
||||||
"noConversationsIn": "Keine Gespräche in {{projectName}}",
|
|
||||||
"startNewConversation": "Starten Sie ein neues Gespräch mit {{projectName}} unten",
|
|
||||||
"conversationsIn": "Gespräche in {{projectName}}",
|
|
||||||
"conversationsDescription": "Klicken Sie auf ein Gespräch, um weiterzuchatten, oder starten Sie unten ein neues.",
|
|
||||||
"thread": "Thread",
|
|
||||||
"threads": "Threads",
|
|
||||||
"updated": "Aktualisiert:",
|
|
||||||
"collapseThreads": "Threads einklappen",
|
|
||||||
"expandThreads": "Threads ausklappen",
|
|
||||||
"update": "Aktualisieren"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -282,9 +282,12 @@
|
|||||||
"thread": "thread",
|
"thread": "thread",
|
||||||
"threads": "threads",
|
"threads": "threads",
|
||||||
"updated": "Updated:",
|
"updated": "Updated:",
|
||||||
"collapseThreads": "Collapse threads",
|
"collapseProject": "Collapse project",
|
||||||
"expandThreads": "Expand threads",
|
"expandProject": "Expand project",
|
||||||
"update": "Update"
|
"update": "Update",
|
||||||
|
"searchProjects": "Search projects...",
|
||||||
|
"noProjectsFound": "No projects found",
|
||||||
|
"tryDifferentSearch": "Try a different search term"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"allThreadsUnfavorited": {
|
"allThreadsUnfavorited": {
|
||||||
|
|||||||
@ -354,8 +354,11 @@
|
|||||||
"thread": "utas",
|
"thread": "utas",
|
||||||
"threads": "utas",
|
"threads": "utas",
|
||||||
"updated": "Diperbarui:",
|
"updated": "Diperbarui:",
|
||||||
"collapseThreads": "Tutup utas",
|
"collapseProject": "Tutup proyek",
|
||||||
"expandThreads": "Buka utas",
|
"expandProject": "Buka proyek",
|
||||||
"update": "Perbarui"
|
"update": "Perbarui",
|
||||||
|
"searchProjects": "Cari proyek...",
|
||||||
|
"noProjectsFound": "Tidak ada proyek ditemukan",
|
||||||
|
"tryDifferentSearch": "Coba kata kunci pencarian lain"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -272,9 +272,12 @@
|
|||||||
"thread": "wątek",
|
"thread": "wątek",
|
||||||
"threads": "wątki",
|
"threads": "wątki",
|
||||||
"updated": "Zaktualizowano:",
|
"updated": "Zaktualizowano:",
|
||||||
"collapseThreads": "Zwiń wątki",
|
"collapseProject": "Zwiń projekt",
|
||||||
"expandThreads": "Rozwiń wątki",
|
"expandProject": "Rozwiń projekt",
|
||||||
"update": "Aktualizuj"
|
"update": "Aktualizuj",
|
||||||
|
"searchProjects": "Szukaj projektów...",
|
||||||
|
"noProjectsFound": "Nie znaleziono projektów",
|
||||||
|
"tryDifferentSearch": "Spróbuj innego wyszukiwania"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"allThreadsUnfavorited": {
|
"allThreadsUnfavorited": {
|
||||||
|
|||||||
@ -199,6 +199,35 @@
|
|||||||
"title": "Cài đặt mô hình - {{modelId}}",
|
"title": "Cài đặt mô hình - {{modelId}}",
|
||||||
"description": "Định cấu hình cài đặt mô hình để tối ưu hóa hiệu suất và hành vi."
|
"description": "Định cấu hình cài đặt mô hình để tối ưu hóa hiệu suất và hành vi."
|
||||||
},
|
},
|
||||||
|
"projects": {
|
||||||
|
"title": "Dự án",
|
||||||
|
"addProject": "Thêm dự án",
|
||||||
|
"editProject": "Chỉnh sửa dự án",
|
||||||
|
"deleteProject": "Xóa dự án",
|
||||||
|
"projectName": "Tên dự án",
|
||||||
|
"enterProjectName": "Nhập tên dự án",
|
||||||
|
"noProjectsYet": "Chưa có dự án nào",
|
||||||
|
"noProjectsYetDesc": "Tạo dự án đầu tiên của bạn để tổ chức các cuộc trò chuyện.",
|
||||||
|
"projectNotFound": "Không tìm thấy dự án",
|
||||||
|
"projectNotFoundDesc": "Dự án mà bạn đang tìm kiếm không tồn tại.",
|
||||||
|
"deleteProjectConfirm": "Bạn có chắc chắn muốn xóa dự án này không? Hành động này không thể hoàn tác.",
|
||||||
|
"addToProject": "Thêm vào dự án",
|
||||||
|
"removeFromProject": "Xóa khỏi dự án",
|
||||||
|
"noConversationsIn": "Chưa có cuộc trò chuyện nào trong {{projectName}}",
|
||||||
|
"startNewConversation": "Bắt đầu một cuộc trò chuyện mới với {{projectName}} bên dưới",
|
||||||
|
"conversationsIn": "Cuộc trò chuyện trong {{projectName}}",
|
||||||
|
"conversationsDescription": "Nhấp vào bất kỳ cuộc trò chuyện nào để tiếp tục trò chuyện hoặc bắt đầu một cuộc trò chuyện mới bên dưới.",
|
||||||
|
"thread": "chủ đề",
|
||||||
|
"threads": "chủ đề",
|
||||||
|
"updated": "Đã cập nhật:",
|
||||||
|
"collapseProject": "Thu gọn dự án",
|
||||||
|
"expandProject": "Mở rộng dự án",
|
||||||
|
"update": "Cập nhật",
|
||||||
|
"noProjectsAvailable": "Không có dự án nào",
|
||||||
|
"searchProjects": "Tìm kiếm dự án...",
|
||||||
|
"noProjectsFound": "Không tìm thấy dự án nào",
|
||||||
|
"tryDifferentSearch": "Thử từ khóa tìm kiếm khác"
|
||||||
|
},
|
||||||
"dialogs": {
|
"dialogs": {
|
||||||
"changeDataFolder": {
|
"changeDataFolder": {
|
||||||
"title": "Thay đổi vị trí thư mục dữ liệu",
|
"title": "Thay đổi vị trí thư mục dữ liệu",
|
||||||
|
|||||||
@ -199,6 +199,35 @@
|
|||||||
"title": "模型设置 - {{modelId}}",
|
"title": "模型设置 - {{modelId}}",
|
||||||
"description": "配置模型设置以优化性能和行为。"
|
"description": "配置模型设置以优化性能和行为。"
|
||||||
},
|
},
|
||||||
|
"projects": {
|
||||||
|
"title": "项目",
|
||||||
|
"addProject": "添加项目",
|
||||||
|
"editProject": "编辑项目",
|
||||||
|
"deleteProject": "删除项目",
|
||||||
|
"projectName": "项目名称",
|
||||||
|
"enterProjectName": "输入项目名称",
|
||||||
|
"noProjectsYet": "还没有项目",
|
||||||
|
"noProjectsYetDesc": "创建您的第一个项目来组织对话。",
|
||||||
|
"projectNotFound": "未找到项目",
|
||||||
|
"projectNotFoundDesc": "您正在查找的项目不存在。",
|
||||||
|
"deleteProjectConfirm": "您确定要删除此项目吗?此操作无法撤销。",
|
||||||
|
"addToProject": "添加到项目",
|
||||||
|
"removeFromProject": "从项目中删除",
|
||||||
|
"noConversationsIn": "{{projectName}} 中还没有对话",
|
||||||
|
"startNewConversation": "在下方开始与 {{projectName}} 的新对话",
|
||||||
|
"conversationsIn": "{{projectName}} 中的对话",
|
||||||
|
"conversationsDescription": "点击任何对话以继续聊天,或在下方开始新的对话。",
|
||||||
|
"thread": "线程",
|
||||||
|
"threads": "线程",
|
||||||
|
"updated": "已更新:",
|
||||||
|
"collapseProject": "收起项目",
|
||||||
|
"expandProject": "展开项目",
|
||||||
|
"update": "更新",
|
||||||
|
"noProjectsAvailable": "没有可用的项目",
|
||||||
|
"searchProjects": "搜索项目...",
|
||||||
|
"noProjectsFound": "未找到项目",
|
||||||
|
"tryDifferentSearch": "尝试不同的搜索词"
|
||||||
|
},
|
||||||
"dialogs": {
|
"dialogs": {
|
||||||
"changeDataFolder": {
|
"changeDataFolder": {
|
||||||
"title": "更改数据文件夹位置",
|
"title": "更改数据文件夹位置",
|
||||||
|
|||||||
@ -199,6 +199,35 @@
|
|||||||
"title": "模型設定 - {{modelId}}",
|
"title": "模型設定 - {{modelId}}",
|
||||||
"description": "設定模型設定以最佳化效能和行為。"
|
"description": "設定模型設定以最佳化效能和行為。"
|
||||||
},
|
},
|
||||||
|
"projects": {
|
||||||
|
"title": "專案",
|
||||||
|
"addProject": "新增專案",
|
||||||
|
"editProject": "編輯專案",
|
||||||
|
"deleteProject": "刪除專案",
|
||||||
|
"projectName": "專案名稱",
|
||||||
|
"enterProjectName": "輸入專案名稱",
|
||||||
|
"noProjectsYet": "尚無專案",
|
||||||
|
"noProjectsYetDesc": "建立您的第一個專案來組織對話。",
|
||||||
|
"projectNotFound": "找不到專案",
|
||||||
|
"projectNotFoundDesc": "您正在尋找的專案不存在。",
|
||||||
|
"deleteProjectConfirm": "您確定要刪除此專案嗎?此操作無法復原。",
|
||||||
|
"addToProject": "加入專案",
|
||||||
|
"removeFromProject": "從專案中移除",
|
||||||
|
"noConversationsIn": "{{projectName}} 中尚無對話",
|
||||||
|
"startNewConversation": "在下方開始與 {{projectName}} 的新對話",
|
||||||
|
"conversationsIn": "{{projectName}} 中的對話",
|
||||||
|
"conversationsDescription": "點擊任何對話以繼續聊天,或在下方開始新的對話。",
|
||||||
|
"thread": "執行緒",
|
||||||
|
"threads": "執行緒",
|
||||||
|
"updated": "已更新:",
|
||||||
|
"collapseProject": "收合專案",
|
||||||
|
"expandProject": "展開專案",
|
||||||
|
"update": "更新",
|
||||||
|
"noProjectsAvailable": "沒有可用的專案",
|
||||||
|
"searchProjects": "搜尋專案...",
|
||||||
|
"noProjectsFound": "找不到專案",
|
||||||
|
"tryDifferentSearch": "嘗試不同的搜尋詞"
|
||||||
|
},
|
||||||
"dialogs": {
|
"dialogs": {
|
||||||
"changeDataFolder": {
|
"changeDataFolder": {
|
||||||
"title": "變更資料夾位置",
|
"title": "變更資料夾位置",
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useAppearance } from '@/hooks/useAppearance'
|
import { useAppearance, useBlurSupport } from '@/hooks/useAppearance'
|
||||||
import { useTheme } from '@/hooks/useTheme'
|
import { useTheme } from '@/hooks/useTheme'
|
||||||
import {
|
import {
|
||||||
isDefaultColor,
|
isDefaultColor,
|
||||||
@ -29,14 +29,37 @@ export function AppearanceProvider() {
|
|||||||
appDestructiveTextColor,
|
appDestructiveTextColor,
|
||||||
} = useAppearance()
|
} = useAppearance()
|
||||||
const { isDark } = useTheme()
|
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
|
// Apply appearance settings on mount and when they change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Apply font size
|
// Apply font size
|
||||||
document.documentElement.style.setProperty('--font-size-base', fontSize)
|
document.documentElement.style.setProperty('--font-size-base', fontSize)
|
||||||
|
|
||||||
// Hide alpha slider when IS_LINUX || !IS_TAURI
|
// Hide alpha slider when blur is not supported
|
||||||
const shouldHideAlpha = IS_LINUX || !IS_TAURI
|
const shouldHideAlpha = !showAlphaSlider
|
||||||
let alphaStyleElement = document.getElementById('alpha-slider-style')
|
let alphaStyleElement = document.getElementById('alpha-slider-style')
|
||||||
|
|
||||||
if (shouldHideAlpha) {
|
if (shouldHideAlpha) {
|
||||||
@ -55,12 +78,13 @@ export function AppearanceProvider() {
|
|||||||
// Import culori functions dynamically to avoid SSR issues
|
// Import culori functions dynamically to avoid SSR issues
|
||||||
import('culori').then(({ rgb, oklch, formatCss }) => {
|
import('culori').then(({ rgb, oklch, formatCss }) => {
|
||||||
// Convert RGBA to a format culori can work with
|
// Convert RGBA to a format culori can work with
|
||||||
|
// Use alpha = 1 when blur is not supported
|
||||||
const culoriRgb = rgb({
|
const culoriRgb = rgb({
|
||||||
mode: 'rgb',
|
mode: 'rgb',
|
||||||
r: appBgColor.r / 255,
|
r: appBgColor.r / 255,
|
||||||
g: appBgColor.g / 255,
|
g: appBgColor.g / 255,
|
||||||
b: appBgColor.b / 255,
|
b: appBgColor.b / 255,
|
||||||
alpha: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : appBgColor.a,
|
alpha: showAlphaSlider ? appBgColor.a : 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
const culoriRgbMainView = rgb({
|
const culoriRgbMainView = rgb({
|
||||||
@ -176,6 +200,7 @@ export function AppearanceProvider() {
|
|||||||
appAccentTextColor,
|
appAccentTextColor,
|
||||||
appDestructiveBgColor,
|
appDestructiveBgColor,
|
||||||
appDestructiveTextColor,
|
appDestructiveTextColor,
|
||||||
|
showAlphaSlider,
|
||||||
])
|
])
|
||||||
|
|
||||||
// Update appearance when theme changes
|
// Update appearance when theme changes
|
||||||
@ -194,6 +219,10 @@ export function AppearanceProvider() {
|
|||||||
setAppDestructiveBgColor,
|
setAppDestructiveBgColor,
|
||||||
} = useAppearance.getState()
|
} = 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 using default background color, update it when theme changes
|
||||||
if (isDefaultColor(appBgColor)) {
|
if (isDefaultColor(appBgColor)) {
|
||||||
// This will trigger the appropriate updates for both background and text color
|
// This will trigger the appropriate updates for both background and text color
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useTheme, checkOSDarkMode } from '@/hooks/useTheme'
|
import { useTheme, checkOSDarkMode } from '@/hooks/useTheme'
|
||||||
|
import { isPlatformTauri } from '@/lib/platform/utils'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ThemeProvider ensures theme settings are applied on every page load
|
* 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
|
// Detect OS theme on mount and apply it
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// If theme is set to auto, detect OS preference
|
// Force refresh theme on mount to handle Linux startup timing issues
|
||||||
if (activeTheme === 'auto') {
|
const refreshTheme = () => {
|
||||||
const isDarkMode = checkOSDarkMode()
|
if (activeTheme === 'auto') {
|
||||||
setIsDark(isDarkMode)
|
const isDarkMode = checkOSDarkMode()
|
||||||
setTheme('auto')
|
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
|
// Listen for changes in OS theme preference
|
||||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
|
||||||
@ -26,16 +36,41 @@ export function ThemeProvider() {
|
|||||||
if (activeTheme === 'auto') {
|
if (activeTheme === 'auto') {
|
||||||
setIsDark(e.matches)
|
setIsDark(e.matches)
|
||||||
} else {
|
} else {
|
||||||
setTheme(e.matches ? 'dark' : 'light')
|
setTheme(activeTheme)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add event listener
|
// Add event listener for browser/web
|
||||||
mediaQuery.addEventListener('change', handleThemeChange)
|
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
|
// Clean up
|
||||||
return () => {
|
return () => {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
mediaQuery.removeEventListener('change', handleThemeChange)
|
mediaQuery.removeEventListener('change', handleThemeChange)
|
||||||
|
if (unlistenTauri) {
|
||||||
|
unlistenTauri()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [activeTheme, setIsDark, setTheme])
|
}, [activeTheme, setIsDark, setTheme])
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import SetupScreen from '@/containers/SetupScreen'
|
|||||||
import { route } from '@/constants/routes'
|
import { route } from '@/constants/routes'
|
||||||
|
|
||||||
type SearchParams = {
|
type SearchParams = {
|
||||||
model?: {
|
'model'?: {
|
||||||
id: string
|
id: string
|
||||||
provider: string
|
provider: string
|
||||||
}
|
}
|
||||||
@ -33,7 +33,10 @@ export const Route = createFileRoute(route.home as any)({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only include temporary-chat if it's explicitly true
|
// 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
|
result['temporary-chat'] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,7 +80,7 @@ function Index() {
|
|||||||
</HeaderPage>
|
</HeaderPage>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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
|
<div
|
||||||
@ -110,7 +113,9 @@ function Index() {
|
|||||||
isMobile ? 'text-base' : 'text-lg'
|
isMobile ? 'text-base' : 'text-lg'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isTemporaryChat ? t('chat:temporaryChatDescription') : t('chat:description')}
|
{isTemporaryChat
|
||||||
|
? t('chat:temporaryChatDescription')
|
||||||
|
: t('chat:description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 shrink-0">
|
<div className="flex-1 shrink-0">
|
||||||
|
|||||||
@ -31,17 +31,20 @@ function LogsViewer() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let lastLogsLength = 0
|
let lastLogsLength = 0
|
||||||
function updateLogs() {
|
function updateLogs() {
|
||||||
serviceHub.app().readLogs().then((logData) => {
|
serviceHub
|
||||||
let needScroll = false
|
.app()
|
||||||
const filteredLogs = logData.filter(Boolean) as LogEntry[]
|
.readLogs()
|
||||||
if (filteredLogs.length > lastLogsLength) needScroll = true
|
.then((logData) => {
|
||||||
|
let needScroll = false
|
||||||
|
const filteredLogs = logData.filter(Boolean) as LogEntry[]
|
||||||
|
if (filteredLogs.length > lastLogsLength) needScroll = true
|
||||||
|
|
||||||
lastLogsLength = filteredLogs.length
|
lastLogsLength = filteredLogs.length
|
||||||
setLogs(filteredLogs)
|
setLogs(filteredLogs)
|
||||||
|
|
||||||
// Scroll to bottom after initial logs are loaded
|
// Scroll to bottom after initial logs are loaded
|
||||||
if (needScroll) setTimeout(() => scrollToBottom(), 100)
|
if (needScroll) setTimeout(() => scrollToBottom(), 100)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
updateLogs()
|
updateLogs()
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,8 @@ import {
|
|||||||
IconFolder,
|
IconFolder,
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconChevronRight,
|
IconChevronRight,
|
||||||
|
IconSearch,
|
||||||
|
IconX,
|
||||||
} from '@tabler/icons-react'
|
} from '@tabler/icons-react'
|
||||||
import AddProjectDialog from '@/containers/dialogs/AddProjectDialog'
|
import AddProjectDialog from '@/containers/dialogs/AddProjectDialog'
|
||||||
import { DeleteProjectDialog } from '@/containers/dialogs/DeleteProjectDialog'
|
import { DeleteProjectDialog } from '@/containers/dialogs/DeleteProjectDialog'
|
||||||
@ -42,6 +44,7 @@ function ProjectContent() {
|
|||||||
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(
|
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(
|
||||||
new Set()
|
new Set()
|
||||||
)
|
)
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
|
||||||
const handleDelete = (id: string) => {
|
const handleDelete = (id: string) => {
|
||||||
setDeletingId(id)
|
setDeletingId(id)
|
||||||
@ -93,6 +96,16 @@ function ProjectContent() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter projects based on search query
|
||||||
|
const filteredProjects = useMemo(() => {
|
||||||
|
if (!searchQuery.trim()) {
|
||||||
|
return folders
|
||||||
|
}
|
||||||
|
return folders.filter((folder) =>
|
||||||
|
folder.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
)
|
||||||
|
}, [folders, searchQuery])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col justify-center">
|
<div className="flex h-full flex-col justify-center">
|
||||||
<HeaderPage>
|
<HeaderPage>
|
||||||
@ -113,6 +126,33 @@ function ProjectContent() {
|
|||||||
</HeaderPage>
|
</HeaderPage>
|
||||||
<div className="h-full overflow-y-auto flex flex-col">
|
<div className="h-full overflow-y-auto flex flex-col">
|
||||||
<div className="p-4 w-full md:w-3/4 mx-auto mt-2">
|
<div className="p-4 w-full md:w-3/4 mx-auto mt-2">
|
||||||
|
{/* Search Bar */}
|
||||||
|
{folders.length > 0 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="relative">
|
||||||
|
<IconSearch
|
||||||
|
size={18}
|
||||||
|
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-main-view-fg/50"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t('projects.searchProjects')}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 bg-main-view-fg/5 border border-main-view-fg/10 rounded-lg text-main-view-fg placeholder:text-main-view-fg/50 focus:outline-none focus:ring-2 focus:ring-main-view-fg/20 focus:border-main-view-fg/20 transition-all"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchQuery('')}
|
||||||
|
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-main-view-fg/50 hover:text-main-view-fg transition-colors"
|
||||||
|
>
|
||||||
|
<IconX size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{folders.length === 0 ? (
|
{folders.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<IconFolder size={48} className="text-main-view-fg/30 mb-4" />
|
<IconFolder size={48} className="text-main-view-fg/30 mb-4" />
|
||||||
@ -123,9 +163,19 @@ function ProjectContent() {
|
|||||||
{t('projects.noProjectsYetDesc')}
|
{t('projects.noProjectsYetDesc')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : filteredProjects.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<IconSearch size={48} className="text-main-view-fg/30 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-main-view-fg/60 mb-2">
|
||||||
|
{t('projects.noProjectsFound')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-main-view-fg/50 text-sm">
|
||||||
|
{t('projects.tryDifferentSearch')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{folders
|
{filteredProjects
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => b.updated_at - a.updated_at)
|
.sort((a, b) => b.updated_at - a.updated_at)
|
||||||
.map((folder) => {
|
.map((folder) => {
|
||||||
@ -172,8 +222,8 @@ function ProjectContent() {
|
|||||||
className="size-8 cursor-pointer flex items-center justify-center rounded-md hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out mr-1"
|
className="size-8 cursor-pointer flex items-center justify-center rounded-md hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out mr-1"
|
||||||
title={
|
title={
|
||||||
isExpanded
|
isExpanded
|
||||||
? t('projects.collapseThreads')
|
? t('projects.collapseProject')
|
||||||
: t('projects.expandThreads')
|
: t('projects.expandProject')
|
||||||
}
|
}
|
||||||
onClick={() => toggleProjectExpansion(folder.id)}
|
onClick={() => toggleProjectExpansion(folder.id)}
|
||||||
>
|
>
|
||||||
@ -218,7 +268,9 @@ function ProjectContent() {
|
|||||||
|
|
||||||
{/* Thread List */}
|
{/* Thread List */}
|
||||||
{isExpanded && projectThreads.length > 0 && (
|
{isExpanded && projectThreads.length > 0 && (
|
||||||
<div className="mt-3 pl-2">
|
<div
|
||||||
|
className="mt-3 pl-2 pr-2 max-h-[190px] overflow-y-auto overflow-x-hidden [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-main-view-fg/20 [&::-webkit-scrollbar-thumb]:rounded-full hover:[&::-webkit-scrollbar-thumb]:bg-main-view-fg/30"
|
||||||
|
>
|
||||||
<ThreadList
|
<ThreadList
|
||||||
threads={projectThreads}
|
threads={projectThreads}
|
||||||
variant="project"
|
variant="project"
|
||||||
|
|||||||
@ -2,7 +2,8 @@
|
|||||||
* Tauri Theme Service - Desktop implementation
|
* 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 type { ThemeMode } from './types'
|
||||||
import { DefaultThemeService } from './default'
|
import { DefaultThemeService } from './default'
|
||||||
|
|
||||||
@ -10,7 +11,27 @@ export class TauriThemeService extends DefaultThemeService {
|
|||||||
async setTheme(theme: ThemeMode): Promise<void> {
|
async setTheme(theme: ThemeMode): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const tauriTheme = theme as Theme | null
|
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) {
|
} catch (error) {
|
||||||
console.error('Error setting theme in Tauri:', error)
|
console.error('Error setting theme in Tauri:', error)
|
||||||
throw error
|
throw error
|
||||||
@ -21,7 +42,7 @@ export class TauriThemeService extends DefaultThemeService {
|
|||||||
return {
|
return {
|
||||||
setTheme: (theme: ThemeMode): Promise<void> => {
|
setTheme: (theme: ThemeMode): Promise<void> => {
|
||||||
return this.setTheme(theme)
|
return this.setTheme(theme)
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,8 +7,39 @@ import type { WindowConfig, WebviewWindowInstance } from './types'
|
|||||||
import { DefaultWindowService } from './default'
|
import { DefaultWindowService } from './default'
|
||||||
|
|
||||||
export class TauriWindowService extends DefaultWindowService {
|
export class TauriWindowService extends DefaultWindowService {
|
||||||
async createWebviewWindow(config: WindowConfig): Promise<WebviewWindowInstance> {
|
async createWebviewWindow(
|
||||||
|
config: WindowConfig
|
||||||
|
): Promise<WebviewWindowInstance> {
|
||||||
try {
|
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, {
|
const webviewWindow = new WebviewWindow(config.label, {
|
||||||
url: config.url,
|
url: config.url,
|
||||||
title: config.title,
|
title: config.title,
|
||||||
@ -20,8 +51,12 @@ export class TauriWindowService extends DefaultWindowService {
|
|||||||
maximizable: config.maximizable,
|
maximizable: config.maximizable,
|
||||||
closable: config.closable,
|
closable: config.closable,
|
||||||
fullscreen: config.fullscreen,
|
fullscreen: config.fullscreen,
|
||||||
|
theme: theme,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Setup theme listener for this window
|
||||||
|
this.setupThemeListenerForWindow(webviewWindow)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: config.label,
|
label: config.label,
|
||||||
async close() {
|
async close() {
|
||||||
@ -38,7 +73,7 @@ export class TauriWindowService extends DefaultWindowService {
|
|||||||
},
|
},
|
||||||
async setTitle(title: string) {
|
async setTitle(title: string) {
|
||||||
await webviewWindow.setTitle(title)
|
await webviewWindow.setTitle(title)
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating Tauri window:', 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 {
|
try {
|
||||||
const existingWindow = await WebviewWindow.getByLabel(label)
|
const existingWindow = await WebviewWindow.getByLabel(label)
|
||||||
|
|
||||||
if (existingWindow) {
|
if (existingWindow) {
|
||||||
return {
|
return {
|
||||||
label: label,
|
label: label,
|
||||||
@ -67,10 +104,10 @@ export class TauriWindowService extends DefaultWindowService {
|
|||||||
},
|
},
|
||||||
async setTitle(title: string) {
|
async setTitle(title: string) {
|
||||||
await existingWindow.setTitle(title)
|
await existingWindow.setTitle(title)
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting Tauri window by label:', error)
|
console.error('Error getting Tauri window by label:', error)
|
||||||
@ -135,8 +172,35 @@ export class TauriWindowService extends DefaultWindowService {
|
|||||||
center: true,
|
center: true,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} 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
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
65
web-app/src/utils/blurSupport.ts
Normal file
65
web-app/src/utils/blurSupport.ts
Normal 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
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user