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());
|
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")?;
|
.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)
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,7 +44,12 @@ const HeaderPage = ({ children }: HeaderPageProps) => {
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{children}
|
<div className={cn(
|
||||||
|
'flex-1 min-w-0', // Allow content to shrink on small screens
|
||||||
|
isMobile && 'overflow-hidden'
|
||||||
|
)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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)')
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user