feat: Adjust UI for mobile res

Feature:
- Adjust homecreen and chatscreen for mobile device
- Fix tests for both FE and BE
Self-test:
- Confirm runnable on both Android and iOS
- Confirm runnable on desktop app
- All test suites passed
- Working with ChatGPT API
This commit is contained in:
Vanalite 2025-09-16 19:15:54 +07:00
parent fd046a2d08
commit 224bee5c66
7 changed files with 224 additions and 25 deletions

View File

@ -19,3 +19,115 @@ fn test_get_vulkan_gpus() {
println!(" {:?}", gpu.get_usage()); println!(" {:?}", gpu.get_usage());
} }
} }
#[cfg(not(any(target_os = "android", target_os = "ios")))]
#[test]
fn test_get_vulkan_gpus_on_desktop() {
let gpus = vulkan::get_vulkan_gpus("");
// Test that function returns without panicking on desktop platforms
assert!(gpus.len() >= 0);
// If GPUs are found, verify they have valid properties
for (i, gpu) in gpus.iter().enumerate() {
println!("Desktop GPU {}:", i);
println!(" Name: {}", gpu.name);
println!(" Vendor: {:?}", gpu.vendor);
println!(" Total Memory: {} MB", gpu.total_memory);
println!(" UUID: {}", gpu.uuid);
println!(" Driver Version: {}", gpu.driver_version);
// Verify that GPU properties are not empty/default values
assert!(!gpu.name.is_empty(), "GPU name should not be empty");
assert!(!gpu.uuid.is_empty(), "GPU UUID should not be empty");
// Test vulkan-specific info is present
if let Some(vulkan_info) = &gpu.vulkan_info {
println!(" Vulkan API Version: {}", vulkan_info.api_version);
println!(" Device Type: {}", vulkan_info.device_type);
assert!(!vulkan_info.api_version.is_empty(), "Vulkan API version should not be empty");
assert!(!vulkan_info.device_type.is_empty(), "Device type should not be empty");
}
}
}
#[cfg(target_os = "android")]
#[test]
fn test_get_vulkan_gpus_on_android() {
let gpus = vulkan::get_vulkan_gpus("");
// Test that function returns without panicking on Android
assert!(gpus.len() >= 0);
// Android-specific validation
for (i, gpu) in gpus.iter().enumerate() {
println!("Android GPU {}:", i);
println!(" Name: {}", gpu.name);
println!(" Vendor: {:?}", gpu.vendor);
println!(" Total Memory: {} MB", gpu.total_memory);
println!(" UUID: {}", gpu.uuid);
println!(" Driver Version: {}", gpu.driver_version);
// Verify C string parsing works correctly with i8 on Android
assert!(!gpu.name.is_empty(), "GPU name should not be empty on Android");
assert!(!gpu.uuid.is_empty(), "GPU UUID should not be empty on Android");
// Android devices should typically have Adreno, Mali, or PowerVR GPUs
// The name parsing should handle i8 char arrays correctly
assert!(
gpu.name.chars().all(|c| c.is_ascii() || c.is_ascii_control()),
"GPU name should contain valid characters when parsed from i8 array"
);
if let Some(vulkan_info) = &gpu.vulkan_info {
println!(" Vulkan API Version: {}", vulkan_info.api_version);
println!(" Device Type: {}", vulkan_info.device_type);
// Verify API version parsing works with Android's i8 char arrays
assert!(
vulkan_info.api_version.matches('.').count() >= 2,
"API version should be in format X.Y.Z"
);
}
}
}
#[cfg(target_os = "ios")]
#[test]
fn test_get_vulkan_gpus_on_ios() {
let gpus = vulkan::get_vulkan_gpus("");
// Note: iOS doesn't support Vulkan natively, so this might return empty
// But the function should still work without crashing
assert!(gpus.len() >= 0);
// iOS-specific validation (if any Vulkan implementation is available via MoltenVK)
for (i, gpu) in gpus.iter().enumerate() {
println!("iOS GPU {}:", i);
println!(" Name: {}", gpu.name);
println!(" Vendor: {:?}", gpu.vendor);
println!(" Total Memory: {} MB", gpu.total_memory);
println!(" UUID: {}", gpu.uuid);
println!(" Driver Version: {}", gpu.driver_version);
// Verify C string parsing works correctly with i8 on iOS
assert!(!gpu.name.is_empty(), "GPU name should not be empty on iOS");
assert!(!gpu.uuid.is_empty(), "GPU UUID should not be empty on iOS");
// iOS devices should typically have Apple GPU (if Vulkan is available via MoltenVK)
// The name parsing should handle i8 char arrays correctly
assert!(
gpu.name.chars().all(|c| c.is_ascii() || c.is_ascii_control()),
"GPU name should contain valid characters when parsed from i8 array"
);
if let Some(vulkan_info) = &gpu.vulkan_info {
println!(" Vulkan API Version: {}", vulkan_info.api_version);
println!(" Device Type: {}", vulkan_info.device_type);
// Verify API version parsing works with iOS's i8 char arrays
assert!(
vulkan_info.api_version.matches('.').count() >= 2,
"API version should be in format X.Y.Z"
);
}
}
}

View File

@ -127,7 +127,6 @@ pub async fn create_message<R: Runtime>(
.ok_or("Missing thread_id")?; .ok_or("Missing thread_id")?;
id.to_string() id.to_string()
}; };
ensure_thread_dir_exists(app_handle.clone(), &thread_id)?;
let path = get_messages_path(app_handle.clone(), &thread_id); let path = get_messages_path(app_handle.clone(), &thread_id);
if message.get("id").is_none() { if message.get("id").is_none() {
@ -140,6 +139,9 @@ pub async fn create_message<R: Runtime>(
let lock = get_lock_for_thread(&thread_id).await; let lock = get_lock_for_thread(&thread_id).await;
let _guard = lock.lock().await; let _guard = lock.lock().await;
// Ensure directory exists right before file operations to handle race conditions
ensure_thread_dir_exists(app_handle.clone(), &thread_id)?;
let mut file: File = fs::OpenOptions::new() let mut file: File = fs::OpenOptions::new()
.create(true) .create(true)
.append(true) .append(true)

View File

@ -1,4 +1,3 @@
use crate::core::app::commands::get_jan_data_folder_path;
use super::commands::*; use super::commands::*;
use serde_json::json; use serde_json::json;
@ -9,11 +8,18 @@ use tauri::test::{mock_app, MockRuntime};
// Helper to create a mock app handle with a temp data dir // Helper to create a mock app handle with a temp data dir
fn mock_app_with_temp_data_dir() -> (tauri::App<MockRuntime>, PathBuf) { fn mock_app_with_temp_data_dir() -> (tauri::App<MockRuntime>, PathBuf) {
let app = mock_app(); let app = mock_app();
let data_dir = get_jan_data_folder_path(app.handle().clone()); // Create a unique test directory to avoid race conditions between parallel tests
let unique_id = std::thread::current().id();
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let data_dir = std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(format!("test-data-{:?}-{}", unique_id, timestamp));
println!("Mock app data dir: {}", data_dir.display()); println!("Mock app data dir: {}", data_dir.display());
// Patch get_data_dir to use temp dir (requires get_data_dir to be overridable or injectable) // Ensure the unique test directory exists
// For now, we assume get_data_dir uses tauri::api::path::app_data_dir(&app_handle) let _ = fs::create_dir_all(&data_dir);
// and that we can set the environment variable to redirect it.
(app, data_dir) (app, data_dir)
} }

View File

@ -2,26 +2,41 @@ import { useLeftPanel } from '@/hooks/useLeftPanel'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { IconLayoutSidebar } from '@tabler/icons-react' import { IconLayoutSidebar } from '@tabler/icons-react'
import { ReactNode } from 'react' import { ReactNode } from 'react'
import { useMobileScreen, useSmallScreen } from '@/hooks/useMediaQuery'
type HeaderPageProps = { type HeaderPageProps = {
children?: ReactNode children?: ReactNode
} }
const HeaderPage = ({ children }: HeaderPageProps) => { const HeaderPage = ({ children }: HeaderPageProps) => {
const { open, setLeftPanel } = useLeftPanel() const { open, setLeftPanel } = useLeftPanel()
const isMobile = useMobileScreen()
const isSmallScreen = useSmallScreen()
return ( return (
<div <div
className={cn( className={cn(
'h-10 pl-18 text-main-view-fg flex items-center shrink-0 border-b border-main-view-fg/5', 'h-10 text-main-view-fg flex items-center shrink-0 border-b border-main-view-fg/5',
IS_MACOS && !open ? 'pl-18' : 'pl-4', // Mobile-first responsive padding
isMobile ? 'px-3' : 'px-4',
// macOS-specific padding when panel is closed
IS_MACOS && !open && !isSmallScreen ? 'pl-18' : '',
children === undefined && 'border-none' children === undefined && 'border-none'
)} )}
> >
<div className="flex items-center w-full gap-2"> <div className={cn(
'flex items-center w-full',
// Adjust gap based on screen size
isMobile ? 'gap-2' : 'gap-3'
)}>
{!open && ( {!open && (
<button <button
className="size-5 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out data-[state=open]:bg-main-view-fg/10" className={cn(
'cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out data-[state=open]:bg-main-view-fg/10',
// Larger touch target on mobile
isMobile ? 'size-8 min-w-8' : 'size-5'
)}
onClick={() => setLeftPanel(!open)} onClick={() => setLeftPanel(!open)}
aria-label="Toggle sidebar"
> >
<IconLayoutSidebar <IconLayoutSidebar
size={18} size={18}
@ -29,9 +44,14 @@ const HeaderPage = ({ children }: HeaderPageProps) => {
/> />
</button> </button>
)} )}
<div className={cn(
'flex-1 min-w-0', // Allow content to shrink on small screens
isMobile && 'overflow-hidden'
)}>
{children} {children}
</div> </div>
</div> </div>
</div>
) )
} }

View File

@ -77,7 +77,33 @@ export function useMediaQuery(
return matches || false return matches || false
} }
// Specific hook for small screen detection // Specific hooks for different screen sizes
export const useSmallScreen = (): boolean => { export const useSmallScreen = (): boolean => {
return useMediaQuery('(max-width: 768px)') return useMediaQuery('(max-width: 768px)')
} }
export const useMobileScreen = (): boolean => {
return useMediaQuery('(max-width: 640px)')
}
export const useTabletScreen = (): boolean => {
return useMediaQuery('(min-width: 641px) and (max-width: 1024px)')
}
export const useDesktopScreen = (): boolean => {
return useMediaQuery('(min-width: 1025px)')
}
// Orientation detection
export const usePortrait = (): boolean => {
return useMediaQuery('(orientation: portrait)')
}
export const useLandscape = (): boolean => {
return useMediaQuery('(orientation: landscape)')
}
// Touch device detection
export const useTouchDevice = (): boolean => {
return useMediaQuery('(pointer: coarse)')
}

View File

@ -4,6 +4,7 @@ import ChatInput from '@/containers/ChatInput'
import HeaderPage from '@/containers/HeaderPage' import HeaderPage from '@/containers/HeaderPage'
import { useTranslation } from '@/i18n/react-i18next-compat' import { useTranslation } from '@/i18n/react-i18next-compat'
import { useTools } from '@/hooks/useTools' import { useTools } from '@/hooks/useTools'
import { cn } from '@/lib/utils'
import { useModelProvider } from '@/hooks/useModelProvider' import { useModelProvider } from '@/hooks/useModelProvider'
import SetupScreen from '@/containers/SetupScreen' import SetupScreen from '@/containers/SetupScreen'
@ -18,6 +19,7 @@ type SearchParams = {
import DropdownAssistant from '@/containers/DropdownAssistant' import DropdownAssistant from '@/containers/DropdownAssistant'
import { useEffect } from 'react' import { useEffect } from 'react'
import { useThreads } from '@/hooks/useThreads' import { useThreads } from '@/hooks/useThreads'
import { useMobileScreen } from '@/hooks/useMediaQuery'
export const Route = createFileRoute(route.home as any)({ export const Route = createFileRoute(route.home as any)({
component: Index, component: Index,
@ -32,6 +34,7 @@ function Index() {
const search = useSearch({ from: route.home as any }) const search = useSearch({ from: route.home as any })
const selectedModel = search.model const selectedModel = search.model
const { setCurrentThreadId } = useThreads() const { setCurrentThreadId } = useThreads()
const isMobile = useMobileScreen()
useTools() useTools()
// Conditional to check if there are any valid providers // Conditional to check if there are any valid providers
@ -56,13 +59,33 @@ function Index() {
<HeaderPage> <HeaderPage>
<DropdownAssistant /> <DropdownAssistant />
</HeaderPage> </HeaderPage>
<div className="h-full px-4 md:px-8 overflow-y-auto flex flex-col gap-2 justify-center"> <div className={cn(
<div className="w-full md:w-4/6 mx-auto"> "h-full overflow-y-auto flex flex-col gap-2 justify-center",
<div className="mb-8 text-center"> // Mobile-first responsive padding
<h1 className="font-editorialnew text-main-view-fg text-4xl"> isMobile ? "px-3 py-4" : "px-4 md:px-8"
)}>
<div className={cn(
"mx-auto",
// Full width on mobile, constrained on desktop
isMobile ? "w-full max-w-full" : "w-full md:w-4/6"
)}>
<div className={cn(
"text-center",
// Adjust spacing for mobile
isMobile ? "mb-6" : "mb-8"
)}>
<h1 className={cn(
"font-editorialnew text-main-view-fg",
// Responsive title size
isMobile ? "text-2xl sm:text-3xl" : "text-4xl"
)}>
{t('chat:welcome')} {t('chat:welcome')}
</h1> </h1>
<p className="text-main-view-fg/70 text-lg mt-2"> <p className={cn(
"text-main-view-fg/70 mt-2",
// Responsive description size
isMobile ? "text-base" : "text-lg"
)}>
{t('chat:description')} {t('chat:description')}
</p> </p>
</div> </div>

View File

@ -22,7 +22,7 @@ import { useAppearance } from '@/hooks/useAppearance'
import { ContentType, ThreadMessage } from '@janhq/core' import { ContentType, ThreadMessage } from '@janhq/core'
import { useTranslation } from '@/i18n/react-i18next-compat' import { useTranslation } from '@/i18n/react-i18next-compat'
import { useChat } from '@/hooks/useChat' import { useChat } from '@/hooks/useChat'
import { useSmallScreen } from '@/hooks/useMediaQuery' import { useSmallScreen, useMobileScreen } from '@/hooks/useMediaQuery'
import { useTools } from '@/hooks/useTools' import { useTools } from '@/hooks/useTools'
// as route.threadsDetail // as route.threadsDetail
@ -47,6 +47,7 @@ function ThreadDetail() {
const { appMainViewBgColor, chatWidth } = useAppearance() const { appMainViewBgColor, chatWidth } = useAppearance()
const { sendMessage } = useChat() const { sendMessage } = useChat()
const isSmallScreen = useSmallScreen() const isSmallScreen = useSmallScreen()
const isMobile = useMobileScreen()
useTools() useTools()
const { messages } = useMessages( const { messages } = useMessages(
@ -308,14 +309,19 @@ function ThreadDetail() {
ref={scrollContainerRef} ref={scrollContainerRef}
onScroll={handleScroll} onScroll={handleScroll}
className={cn( className={cn(
'flex flex-col h-full w-full overflow-auto px-4 pt-4 pb-3' 'flex flex-col h-full w-full overflow-auto pt-4 pb-3',
// Mobile-first responsive padding
isMobile ? 'px-3' : 'px-4'
)} )}
> >
<div <div
className={cn( className={cn(
'w-4/6 mx-auto flex max-w-full flex-col grow', 'mx-auto flex max-w-full flex-col grow',
chatWidth === 'compact' ? 'w-full md:w-4/6' : 'w-full', // Mobile-first width constraints
isSmallScreen && 'w-full' // Mobile and small screens always use full width, otherwise compact chat uses constrained width
isMobile || isSmallScreen || chatWidth !== 'compact'
? 'w-full'
: 'w-full md:w-4/6'
)} )}
> >
{messages && {messages &&
@ -355,9 +361,13 @@ function ThreadDetail() {
</div> </div>
<div <div
className={cn( className={cn(
'mx-auto pt-2 pb-3 shrink-0 relative px-2', 'mx-auto pt-2 pb-3 shrink-0 relative',
chatWidth === 'compact' ? 'w-full md:w-4/6' : 'w-full', // Responsive padding and width
isSmallScreen && 'w-full' isMobile ? 'px-3' : 'px-2',
// Width: mobile/small screens or non-compact always full, compact desktop uses constrained
isMobile || isSmallScreen || chatWidth !== 'compact'
? 'w-full'
: 'w-full md:w-4/6'
)} )}
> >
<div <div