From 224bee5c66211efbcc48b3564a7dd540da6d5b1b Mon Sep 17 00:00:00 2001 From: Vanalite Date: Tue, 16 Sep 2025 19:15:54 +0700 Subject: [PATCH] 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 --- .../tauri-plugin-hardware/src/vendor/tests.rs | 112 ++++++++++++++++++ src-tauri/src/core/threads/commands.rs | 4 +- src-tauri/src/core/threads/tests.rs | 16 ++- web-app/src/containers/HeaderPage.tsx | 30 ++++- web-app/src/hooks/useMediaQuery.ts | 28 ++++- web-app/src/routes/index.tsx | 33 +++++- web-app/src/routes/threads/$threadId.tsx | 26 ++-- 7 files changed, 224 insertions(+), 25 deletions(-) diff --git a/src-tauri/plugins/tauri-plugin-hardware/src/vendor/tests.rs b/src-tauri/plugins/tauri-plugin-hardware/src/vendor/tests.rs index 078efe91b..0a641c089 100644 --- a/src-tauri/plugins/tauri-plugin-hardware/src/vendor/tests.rs +++ b/src-tauri/plugins/tauri-plugin-hardware/src/vendor/tests.rs @@ -19,3 +19,115 @@ fn test_get_vulkan_gpus() { 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" + ); + } + } +} diff --git a/src-tauri/src/core/threads/commands.rs b/src-tauri/src/core/threads/commands.rs index a9012193a..905c8c6b8 100644 --- a/src-tauri/src/core/threads/commands.rs +++ b/src-tauri/src/core/threads/commands.rs @@ -127,7 +127,6 @@ pub async fn create_message( .ok_or("Missing thread_id")?; id.to_string() }; - ensure_thread_dir_exists(app_handle.clone(), &thread_id)?; let path = get_messages_path(app_handle.clone(), &thread_id); if message.get("id").is_none() { @@ -140,6 +139,9 @@ pub async fn create_message( let lock = get_lock_for_thread(&thread_id).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() .create(true) .append(true) diff --git a/src-tauri/src/core/threads/tests.rs b/src-tauri/src/core/threads/tests.rs index 5b4aaec57..e9f4cbdad 100644 --- a/src-tauri/src/core/threads/tests.rs +++ b/src-tauri/src/core/threads/tests.rs @@ -1,4 +1,3 @@ -use crate::core::app::commands::get_jan_data_folder_path; use super::commands::*; 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 fn mock_app_with_temp_data_dir() -> (tauri::App, PathBuf) { 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()); - // Patch get_data_dir to use temp dir (requires get_data_dir to be overridable or injectable) - // For now, we assume get_data_dir uses tauri::api::path::app_data_dir(&app_handle) - // and that we can set the environment variable to redirect it. + // Ensure the unique test directory exists + let _ = fs::create_dir_all(&data_dir); (app, data_dir) } diff --git a/web-app/src/containers/HeaderPage.tsx b/web-app/src/containers/HeaderPage.tsx index a528d0a8c..2cfa8b50d 100644 --- a/web-app/src/containers/HeaderPage.tsx +++ b/web-app/src/containers/HeaderPage.tsx @@ -2,26 +2,41 @@ import { useLeftPanel } from '@/hooks/useLeftPanel' import { cn } from '@/lib/utils' import { IconLayoutSidebar } from '@tabler/icons-react' import { ReactNode } from 'react' +import { useMobileScreen, useSmallScreen } from '@/hooks/useMediaQuery' type HeaderPageProps = { children?: ReactNode } const HeaderPage = ({ children }: HeaderPageProps) => { const { open, setLeftPanel } = useLeftPanel() + const isMobile = useMobileScreen() + const isSmallScreen = useSmallScreen() return (
-
+
{!open && ( )} - {children} +
+ {children} +
) diff --git a/web-app/src/hooks/useMediaQuery.ts b/web-app/src/hooks/useMediaQuery.ts index d6d5881a9..dd41e1417 100644 --- a/web-app/src/hooks/useMediaQuery.ts +++ b/web-app/src/hooks/useMediaQuery.ts @@ -77,7 +77,33 @@ export function useMediaQuery( return matches || false } -// Specific hook for small screen detection +// Specific hooks for different screen sizes export const useSmallScreen = (): boolean => { 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)') +} diff --git a/web-app/src/routes/index.tsx b/web-app/src/routes/index.tsx index 4ff643356..130be4e00 100644 --- a/web-app/src/routes/index.tsx +++ b/web-app/src/routes/index.tsx @@ -4,6 +4,7 @@ import ChatInput from '@/containers/ChatInput' import HeaderPage from '@/containers/HeaderPage' import { useTranslation } from '@/i18n/react-i18next-compat' import { useTools } from '@/hooks/useTools' +import { cn } from '@/lib/utils' import { useModelProvider } from '@/hooks/useModelProvider' import SetupScreen from '@/containers/SetupScreen' @@ -18,6 +19,7 @@ type SearchParams = { import DropdownAssistant from '@/containers/DropdownAssistant' import { useEffect } from 'react' import { useThreads } from '@/hooks/useThreads' +import { useMobileScreen } from '@/hooks/useMediaQuery' export const Route = createFileRoute(route.home as any)({ component: Index, @@ -32,6 +34,7 @@ function Index() { const search = useSearch({ from: route.home as any }) const selectedModel = search.model const { setCurrentThreadId } = useThreads() + const isMobile = useMobileScreen() useTools() // Conditional to check if there are any valid providers @@ -56,13 +59,33 @@ function Index() { -
-
-
-

+
+
+
+

{t('chat:welcome')}

-

+

{t('chat:description')}

diff --git a/web-app/src/routes/threads/$threadId.tsx b/web-app/src/routes/threads/$threadId.tsx index 6f2a83de8..8c61ef550 100644 --- a/web-app/src/routes/threads/$threadId.tsx +++ b/web-app/src/routes/threads/$threadId.tsx @@ -22,7 +22,7 @@ import { useAppearance } from '@/hooks/useAppearance' import { ContentType, ThreadMessage } from '@janhq/core' import { useTranslation } from '@/i18n/react-i18next-compat' import { useChat } from '@/hooks/useChat' -import { useSmallScreen } from '@/hooks/useMediaQuery' +import { useSmallScreen, useMobileScreen } from '@/hooks/useMediaQuery' import { useTools } from '@/hooks/useTools' // as route.threadsDetail @@ -47,6 +47,7 @@ function ThreadDetail() { const { appMainViewBgColor, chatWidth } = useAppearance() const { sendMessage } = useChat() const isSmallScreen = useSmallScreen() + const isMobile = useMobileScreen() useTools() const { messages } = useMessages( @@ -308,14 +309,19 @@ function ThreadDetail() { ref={scrollContainerRef} onScroll={handleScroll} 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' )} >
{messages && @@ -355,9 +361,13 @@ function ThreadDetail() {