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:
parent
fd046a2d08
commit
224bee5c66
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -127,7 +127,6 @@ pub async fn create_message<R: Runtime>(
|
||||
.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<R: Runtime>(
|
||||
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)
|
||||
|
||||
@ -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<MockRuntime>, 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)
|
||||
}
|
||||
|
||||
|
||||
@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
'h-10 pl-18 text-main-view-fg flex items-center shrink-0 border-b border-main-view-fg/5',
|
||||
IS_MACOS && !open ? 'pl-18' : 'pl-4',
|
||||
'h-10 text-main-view-fg flex items-center shrink-0 border-b border-main-view-fg/5',
|
||||
// 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'
|
||||
)}
|
||||
>
|
||||
<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 && (
|
||||
<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)}
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
<IconLayoutSidebar
|
||||
size={18}
|
||||
@ -29,7 +44,12 @@ const HeaderPage = ({ children }: HeaderPageProps) => {
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{children}
|
||||
<div className={cn(
|
||||
'flex-1 min-w-0', // Allow content to shrink on small screens
|
||||
isMobile && 'overflow-hidden'
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -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)')
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
<HeaderPage>
|
||||
<DropdownAssistant />
|
||||
</HeaderPage>
|
||||
<div className="h-full px-4 md:px-8 overflow-y-auto flex flex-col gap-2 justify-center">
|
||||
<div className="w-full md:w-4/6 mx-auto">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="font-editorialnew text-main-view-fg text-4xl">
|
||||
<div className={cn(
|
||||
"h-full overflow-y-auto flex flex-col gap-2 justify-center",
|
||||
// Mobile-first responsive padding
|
||||
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')}
|
||||
</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')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -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'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'w-4/6 mx-auto flex max-w-full flex-col grow',
|
||||
chatWidth === 'compact' ? 'w-full md:w-4/6' : 'w-full',
|
||||
isSmallScreen && 'w-full'
|
||||
'mx-auto flex max-w-full flex-col grow',
|
||||
// Mobile-first width constraints
|
||||
// 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 &&
|
||||
@ -355,9 +361,13 @@ function ThreadDetail() {
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'mx-auto pt-2 pb-3 shrink-0 relative px-2',
|
||||
chatWidth === 'compact' ? 'w-full md:w-4/6' : 'w-full',
|
||||
isSmallScreen && 'w-full'
|
||||
'mx-auto pt-2 pb-3 shrink-0 relative',
|
||||
// Responsive padding and width
|
||||
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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user