From 1d46101a8d86aaae1bbe3e6b107ab9463e559eb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=9F=8E=E5=9F=8E?= Date: Wed, 4 Feb 2026 18:17:49 +0800 Subject: [PATCH 01/14] feat: add navigation ref utility for programmatic navigation --- src/navigators/navigationRef.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 src/navigators/navigationRef.ts diff --git a/src/navigators/navigationRef.ts b/src/navigators/navigationRef.ts new file mode 100644 index 00000000..7ee04dab --- /dev/null +++ b/src/navigators/navigationRef.ts @@ -0,0 +1,16 @@ +import type { NavigationContainerRef, ParamListBase } from '@react-navigation/native' +import { createRef } from 'react' + + +export const navigationRef = createRef>() + +export function isNavigationReady(): boolean { + return navigationRef.current !== null && navigationRef.current !== undefined +} + + +export function safeNavigate(name: string, params?: object): void { + if (isNavigationReady()) { + navigationRef.current?.navigate(name, params) + } +} From 18e19c81aedaa3a7e40406a0f43bf6422b2a21e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=9F=8E=E5=9F=8E?= Date: Wed, 4 Feb 2026 18:18:25 +0800 Subject: [PATCH 02/14] feat: add useDrawer hook with tablet landscape support --- src/hooks/useDrawer.ts | 46 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/hooks/useDrawer.ts diff --git a/src/hooks/useDrawer.ts b/src/hooks/useDrawer.ts new file mode 100644 index 00000000..ddb48f0a --- /dev/null +++ b/src/hooks/useDrawer.ts @@ -0,0 +1,46 @@ +import { DrawerActions, useNavigation } from '@react-navigation/native' +import { useCallback } from 'react' + +import { useResponsive } from './useResponsive' + +/** + * Hook for safely handling drawer operations + * In tablet landscape mode, the drawer is always visible and doesn't need open/close actions + */ +export function useDrawer() { + const navigation = useNavigation() + const { isTablet, isLandscape } = useResponsive() + const isTabletLandscape = isTablet && isLandscape + + const openDrawer = useCallback(() => { + // In tablet landscape mode, drawer is always visible, no need to open + if (isTabletLandscape) { + return + } + navigation.dispatch(DrawerActions.openDrawer()) + }, [navigation, isTabletLandscape]) + + const closeDrawer = useCallback(() => { + // In tablet landscape mode, drawer is always visible, no need to close + if (isTabletLandscape) { + return + } + navigation.dispatch(DrawerActions.closeDrawer()) + }, [navigation, isTabletLandscape]) + + const toggleDrawer = useCallback(() => { + // In tablet landscape mode, drawer is always visible, no need to toggle + if (isTabletLandscape) { + return + } + navigation.dispatch(DrawerActions.toggleDrawer()) + }, [navigation, isTabletLandscape]) + + return { + openDrawer, + closeDrawer, + toggleDrawer, + // Indicates if currently in tablet landscape mode (drawer is always visible in this mode) + isDrawerAlwaysVisible: isTabletLandscape + } +} From 2e299d8acfcd2982e6afbe020d5ac4f550094647 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=9F=8E=E5=9F=8E?= Date: Wed, 4 Feb 2026 18:19:12 +0800 Subject: [PATCH 03/14] feat: add tablet sidebar translations for multiple languages --- src/i18n/locales/en-us.json | 6 ++++++ src/i18n/locales/ja-jp.json | 6 ++++++ src/i18n/locales/ru-ru.json | 6 ++++++ src/i18n/locales/zh-cn.json | 6 ++++++ src/i18n/locales/zh-tw.json | 6 ++++++ 5 files changed, 30 insertions(+) diff --git a/src/i18n/locales/en-us.json b/src/i18n/locales/en-us.json index f7bb2ab0..b322d64a 100644 --- a/src/i18n/locales/en-us.json +++ b/src/i18n/locales/en-us.json @@ -700,6 +700,12 @@ "anonymous": "Anonymous report errors and statistics", "title": "Privacy Settings" }, + "tablet_sidebar": { + "title": "Tablet Sidebar", + "position": "Sidebar Position", + "left": "Left", + "right": "Right" + }, "theme": { "auto": "Auto", "dark": "Dark", diff --git a/src/i18n/locales/ja-jp.json b/src/i18n/locales/ja-jp.json index 4a8cdf8c..6e7ab5c3 100644 --- a/src/i18n/locales/ja-jp.json +++ b/src/i18n/locales/ja-jp.json @@ -700,6 +700,12 @@ "anonymous": "エラーと統計を匿名で報告", "title": "プライバシー設定" }, + "tablet_sidebar": { + "title": "タブレットサイドバー", + "position": "サイドバー位置", + "left": "左", + "right": "右" + }, "theme": { "auto": "自動", "dark": "ダーク", diff --git a/src/i18n/locales/ru-ru.json b/src/i18n/locales/ru-ru.json index 9d450788..b82954fb 100644 --- a/src/i18n/locales/ru-ru.json +++ b/src/i18n/locales/ru-ru.json @@ -700,6 +700,12 @@ "anonymous": "Анонимно сообщать об ошибках и статистике", "title": "Настройки конфиденциальности" }, + "tablet_sidebar": { + "title": "Боковая панель планшета", + "position": "Положение боковой панели", + "left": "Слева", + "right": "Справа" + }, "theme": { "auto": "Авто", "dark": "Тёмная", diff --git a/src/i18n/locales/zh-cn.json b/src/i18n/locales/zh-cn.json index 7da44afa..0a25e874 100644 --- a/src/i18n/locales/zh-cn.json +++ b/src/i18n/locales/zh-cn.json @@ -700,6 +700,12 @@ "anonymous": "匿名报告错误和统计", "title": "隐私设置" }, + "tablet_sidebar": { + "title": "平板导航栏", + "position": "导航栏位置", + "left": "左侧", + "right": "右侧" + }, "theme": { "auto": "自动", "dark": "深色", diff --git a/src/i18n/locales/zh-tw.json b/src/i18n/locales/zh-tw.json index 93230a80..fb63511b 100644 --- a/src/i18n/locales/zh-tw.json +++ b/src/i18n/locales/zh-tw.json @@ -700,6 +700,12 @@ "anonymous": "匿名回報錯誤和統計", "title": "隱私設定" }, + "tablet_sidebar": { + "title": "平板導航欄", + "position": "導航欄位置", + "left": "左側", + "right": "右側" + }, "theme": { "auto": "自動", "dark": "深色", From f31dc06f4ed02da85c4b46278d5477f2d7491bca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=9F=8E=E5=9F=8E?= Date: Wed, 4 Feb 2026 18:21:28 +0800 Subject: [PATCH 04/14] refactor: replace Container with ScrollView in settings screens Update various settings screens to use ScrollView with padding instead of Container for better scrolling behavior and consistent layout. Also adjust drawer handling in McpScreen to use custom hook. --- src/screens/mcp/McpScreen.tsx | 14 +++++----- src/screens/settings/SettingsScreen.tsx | 9 +++---- src/screens/settings/about/AboutScreen.tsx | 22 +++++----------- .../assistant/AssistantSettingsScreen.tsx | 10 +++---- .../settings/data/BasicDataSettingsScreen.tsx | 9 +++---- .../settings/data/DataSettingsScreen.tsx | 26 +++++++++---------- .../general/GeneralSettingsScreen.tsx | 9 ++++--- .../websearch/WebSearchSettingsScreen.tsx | 19 +++++--------- 8 files changed, 52 insertions(+), 66 deletions(-) diff --git a/src/screens/mcp/McpScreen.tsx b/src/screens/mcp/McpScreen.tsx index 86b4493f..3e8c8cbf 100644 --- a/src/screens/mcp/McpScreen.tsx +++ b/src/screens/mcp/McpScreen.tsx @@ -1,4 +1,4 @@ -import { DrawerActions, useNavigation } from '@react-navigation/native' +import { useNavigation } from '@react-navigation/native' import React, { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { View } from 'react-native' @@ -13,18 +13,20 @@ import { } from '@/componentsV2' import { McpMarketContent } from '@/componentsV2/features/MCP/McpMarketContent' import { Menu, Plus, Store } from '@/componentsV2/icons/LucideIcon' +import { useDrawer } from '@/hooks/useDrawer' import { useMcpServers } from '@/hooks/useMcp' import { useSearch } from '@/hooks/useSearch' import { useSkeletonLoading } from '@/hooks/useSkeletonLoading' import { useToast } from '@/hooks/useToast' import { mcpService } from '@/services/McpService' import type { MCPServer } from '@/types/mcp' -import type { DrawerNavigationProps, McpNavigationProps } from '@/types/naviagate' +import type { McpNavigationProps } from '@/types/naviagate' import { uuid } from '@/utils' export default function McpScreen() { const { t } = useTranslation() - const navigation = useNavigation() + const navigation = useNavigation() + const { openDrawer, isDrawerAlwaysVisible } = useDrawer() const toast = useToast() const { mcpServers, isLoading, updateMcpServers } = useMcpServers() const { @@ -39,7 +41,7 @@ export default function McpScreen() { const showSkeleton = useSkeletonLoading(isLoading) const handleMenuPress = () => { - navigation.dispatch(DrawerActions.openDrawer()) + openDrawer() } const handleNavigateToMarket = () => { @@ -78,10 +80,10 @@ export default function McpScreen() { , onPress: handleMenuPress - }} + } : undefined} rightButtons={[ { icon: , diff --git a/src/screens/settings/SettingsScreen.tsx b/src/screens/settings/SettingsScreen.tsx index 75f7311e..d8def230 100644 --- a/src/screens/settings/SettingsScreen.tsx +++ b/src/screens/settings/SettingsScreen.tsx @@ -1,11 +1,10 @@ import { useNavigation } from '@react-navigation/native' import React from 'react' import { useTranslation } from 'react-i18next' -import { View } from 'react-native' +import { ScrollView, View } from 'react-native' import { GestureDetector } from 'react-native-gesture-handler' import { - Container, Group, GroupTitle, HeaderBar, @@ -109,8 +108,8 @@ export default function SettingsScreen() { - - + + {settingsItems.map((group, index) => ( {group.items.map((item, index) => ( @@ -125,7 +124,7 @@ export default function SettingsScreen() { ))} - + diff --git a/src/screens/settings/about/AboutScreen.tsx b/src/screens/settings/about/AboutScreen.tsx index eab4f6db..f95cd439 100644 --- a/src/screens/settings/about/AboutScreen.tsx +++ b/src/screens/settings/about/AboutScreen.tsx @@ -1,20 +1,10 @@ import * as ExpoLinking from 'expo-linking' import React from 'react' import { useTranslation } from 'react-i18next' +import { ScrollView } from 'react-native' import FastSquircleView from 'react-native-fast-squircle' -import { - Container, - Group, - HeaderBar, - Image, - PressableRow, - Row, - SafeAreaContainer, - Text, - XStack, - YStack -} from '@/componentsV2' +import { Group, HeaderBar, Image, PressableRow, Row, SafeAreaContainer, Text, XStack, YStack } from '@/componentsV2' import { ArrowUpRight, Copyright, Github, Globe, Mail, Rss } from '@/componentsV2/icons/LucideIcon' import { loggerService } from '@/services/LoggerService' @@ -42,8 +32,8 @@ export default function AboutScreen() { onPress: async () => await openLink('https://github.com/CherryHQ/cherry-studio-app') }} /> - - + + {/* Logo and Description */} @@ -57,7 +47,7 @@ export default function AboutScreen() { cornerSmoothing={0.6}> - + {t('common.cherry_studio')} {t('common.cherry_studio_description')} @@ -109,7 +99,7 @@ export default function AboutScreen() { - + ) } diff --git a/src/screens/settings/assistant/AssistantSettingsScreen.tsx b/src/screens/settings/assistant/AssistantSettingsScreen.tsx index 6c1d53d9..a4185e32 100644 --- a/src/screens/settings/assistant/AssistantSettingsScreen.tsx +++ b/src/screens/settings/assistant/AssistantSettingsScreen.tsx @@ -3,9 +3,9 @@ import type { StackNavigationProp } from '@react-navigation/stack' import { Button } from 'heroui-native' import React from 'react' import { useTranslation } from 'react-i18next' -import { ActivityIndicator } from 'react-native' +import { ActivityIndicator, ScrollView } from 'react-native' -import { Container, HeaderBar, IconButton, Image, SafeAreaContainer, Text, XStack, YStack } from '@/componentsV2' +import { HeaderBar, IconButton, Image, SafeAreaContainer, Text, XStack, YStack } from '@/componentsV2' import { presentModelSheet } from '@/componentsV2/features/Sheet/ModelSheet' import { ChevronDown, Languages, MessageSquareMore, Rocket, Settings2 } from '@/componentsV2/icons/LucideIcon' import { useAssistant } from '@/hooks/useAssistant' @@ -37,7 +37,7 @@ function ModelPicker({ assistant, onPress }: { assistant: Assistant; onPress: () {model ? ( <> @@ -160,7 +160,7 @@ export default function AssistantSettingsScreen() { return ( - + {assistantItems.map(item => ( ))} - + ) } diff --git a/src/screens/settings/data/BasicDataSettingsScreen.tsx b/src/screens/settings/data/BasicDataSettingsScreen.tsx index 86667166..9e45b721 100644 --- a/src/screens/settings/data/BasicDataSettingsScreen.tsx +++ b/src/screens/settings/data/BasicDataSettingsScreen.tsx @@ -7,10 +7,9 @@ import * as IntentLauncher from 'expo-intent-launcher' import { delay } from 'lodash' import React, { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { InteractionManager, Platform } from 'react-native' +import { InteractionManager, Platform, ScrollView } from 'react-native' import { - Container, dismissDialog, Group, GroupTitle, @@ -270,8 +269,8 @@ export default function BasicDataSettingsScreen() { - - + + {settingsItems.map(group => ( {group.items.map(item => ( @@ -280,7 +279,7 @@ export default function BasicDataSettingsScreen() { ))} - + - - - - {settingsItems.map(group => ( - - {group.items.map(item => ( - - ))} - - ))} - - - + + + {settingsItems.map(group => ( + + {group.items.map(item => ( + + ))} + + ))} + + ) } diff --git a/src/screens/settings/general/GeneralSettingsScreen.tsx b/src/screens/settings/general/GeneralSettingsScreen.tsx index 7b6348e3..a5ab15ce 100644 --- a/src/screens/settings/general/GeneralSettingsScreen.tsx +++ b/src/screens/settings/general/GeneralSettingsScreen.tsx @@ -4,18 +4,21 @@ import { useTranslation } from 'react-i18next' import { Container, Group, GroupTitle, HeaderBar, SafeAreaContainer, Text, XStack, YStack } from '@/componentsV2' import { LanguageDropdown } from '@/componentsV2/features/SettingsScreen/general/LanguageDropdown' +import { TabletSidebarPositionDropdown } from '@/componentsV2/features/SettingsScreen/general/TabletSidebarPositionDropdown' import { ThemeDropdown } from '@/componentsV2/features/SettingsScreen/general/ThemeDropdown' import { usePreference } from '@/hooks/usePreference' +import { useResponsive } from '@/hooks/useResponsive' export default function GeneralSettingsScreen() { const { t } = useTranslation() + const { isTablet } = useResponsive() const [developerMode, setDeveloperMode] = usePreference('app.developer_mode') return ( - - + + {/* Display settings */} {t('settings.general.display.title')} @@ -49,7 +52,7 @@ export default function GeneralSettingsScreen() { - + ) } diff --git a/src/screens/settings/websearch/WebSearchSettingsScreen.tsx b/src/screens/settings/websearch/WebSearchSettingsScreen.tsx index 3fe98538..b042e0ca 100644 --- a/src/screens/settings/websearch/WebSearchSettingsScreen.tsx +++ b/src/screens/settings/websearch/WebSearchSettingsScreen.tsx @@ -1,9 +1,8 @@ import React from 'react' import { useTranslation } from 'react-i18next' -import { View } from 'react-native' import { KeyboardAwareScrollView } from 'react-native-keyboard-controller' -import { Container, HeaderBar, SafeAreaContainer, YStack } from '@/componentsV2' +import { HeaderBar, SafeAreaContainer, YStack } from '@/componentsV2' import GeneralSettings from './GeneralSettings' import ProviderSettings from './ProviderSettings' @@ -13,17 +12,13 @@ export default function WebSearchSettingsScreen() { return ( - - - - - - + + + + - - - - + + ) From 6f9a748b08c035a8072b683dfbf45c559504f225 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=9F=8E=E5=9F=8E?= Date: Wed, 4 Feb 2026 18:22:37 +0800 Subject: [PATCH 05/14] feat: add tablet sidebar position setting Introduce a new dropdown in general settings to select tablet sidebar position (left/right). Includes new icons, preference schema updates, and UI component. --- .../general/TabletSidebarPositionDropdown.tsx | 47 +++++++++++++++++++ src/componentsV2/icons/LucideIcon/index.tsx | 6 +++ .../general/GeneralSettingsScreen.tsx | 16 ++++++- .../data/preference/preferenceSchemas.ts | 6 +++ src/shared/data/preference/preferenceTypes.ts | 3 ++ 5 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 src/componentsV2/features/SettingsScreen/general/TabletSidebarPositionDropdown.tsx diff --git a/src/componentsV2/features/SettingsScreen/general/TabletSidebarPositionDropdown.tsx b/src/componentsV2/features/SettingsScreen/general/TabletSidebarPositionDropdown.tsx new file mode 100644 index 00000000..6a2a6bb8 --- /dev/null +++ b/src/componentsV2/features/SettingsScreen/general/TabletSidebarPositionDropdown.tsx @@ -0,0 +1,47 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { Pressable } from 'react-native' + +import SelectionDropdown, { type SelectionDropdownItem } from '@/componentsV2/base/SelectionDropdown' +import Text from '@/componentsV2/base/Text' +import { ChevronsUpDown, PanelLeft, PanelRight } from '@/componentsV2/icons' +import { usePreference } from '@/hooks/usePreference' +import type { TabletSidebarPosition } from '@/shared/data/preference/preferenceTypes' + +const positionOptions: { value: TabletSidebarPosition; labelKey: string; icon: React.ReactNode }[] = [ + { value: 'left', labelKey: 'settings.general.tablet_sidebar.left', icon: }, + { value: 'right', labelKey: 'settings.general.tablet_sidebar.right', icon: } +] + +export function TabletSidebarPositionDropdown() { + const { t } = useTranslation() + const [currentPosition, setCurrentPosition] = usePreference('ui.tablet_sidebar_position') + + const handlePositionChange = (position: TabletSidebarPosition) => { + setCurrentPosition(position) + } + + const positionDropdownOptions: SelectionDropdownItem[] = positionOptions.map(opt => ({ + id: opt.value, + label: t(opt.labelKey), + icon: opt.icon, + isSelected: currentPosition === opt.value, + onSelect: () => handlePositionChange(opt.value) + })) + + const getCurrentPositionLabel = () => { + const current = positionOptions.find(item => item.value === currentPosition) + return current ? t(current.labelKey) : t('settings.general.tablet_sidebar.left') + } + + return ( + + + + {getCurrentPositionLabel()} + + + + + ) +} diff --git a/src/componentsV2/icons/LucideIcon/index.tsx b/src/componentsV2/icons/LucideIcon/index.tsx index e9c51a49..f48e055c 100644 --- a/src/componentsV2/icons/LucideIcon/index.tsx +++ b/src/componentsV2/icons/LucideIcon/index.tsx @@ -56,6 +56,8 @@ import { MoreHorizontal, Package, Palette, + PanelLeft, + PanelRight, PenLine, Plus, Radio, @@ -159,6 +161,8 @@ const MicIcon = createIcon(Mic) const MinusIcon = createIcon(Minus) const MoreHorizontalIcon = createIcon(MoreHorizontal) const PackageIcon = createIcon(Package) +const PanelLeftIcon = createIcon(PanelLeft) +const PanelRightIcon = createIcon(PanelRight) const PenLineIcon = createIcon(PenLine) const PlusIcon = createIcon(Plus) const RadioIcon = createIcon(Radio) @@ -244,6 +248,8 @@ export { MoreHorizontalIcon as MoreHorizontal, PackageIcon as Package, PaletteIcon as Palette, + PanelLeftIcon as PanelLeft, + PanelRightIcon as PanelRight, PenLineIcon as PenLine, PlusIcon as Plus, RadioIcon as Radio, diff --git a/src/screens/settings/general/GeneralSettingsScreen.tsx b/src/screens/settings/general/GeneralSettingsScreen.tsx index a5ab15ce..895becca 100644 --- a/src/screens/settings/general/GeneralSettingsScreen.tsx +++ b/src/screens/settings/general/GeneralSettingsScreen.tsx @@ -1,8 +1,9 @@ import { Switch } from 'heroui-native' import React from 'react' import { useTranslation } from 'react-i18next' +import { ScrollView } from 'react-native' -import { Container, Group, GroupTitle, HeaderBar, SafeAreaContainer, Text, XStack, YStack } from '@/componentsV2' +import { Group, GroupTitle, HeaderBar, SafeAreaContainer, Text, XStack, YStack } from '@/componentsV2' import { LanguageDropdown } from '@/componentsV2/features/SettingsScreen/general/LanguageDropdown' import { TabletSidebarPositionDropdown } from '@/componentsV2/features/SettingsScreen/general/TabletSidebarPositionDropdown' import { ThemeDropdown } from '@/componentsV2/features/SettingsScreen/general/ThemeDropdown' @@ -41,6 +42,19 @@ export default function GeneralSettingsScreen() { + {/* Tablet sidebar position - only visible on tablet devices */} + {isTablet && ( + + {t('settings.general.tablet_sidebar.title')} + + + {t('settings.general.tablet_sidebar.position')} + + + + + )} + {/* Developer settings */} {t('settings.general.developer_mode.title')} diff --git a/src/shared/data/preference/preferenceSchemas.ts b/src/shared/data/preference/preferenceSchemas.ts index 34ea3a2c..16160744 100644 --- a/src/shared/data/preference/preferenceSchemas.ts +++ b/src/shared/data/preference/preferenceSchemas.ts @@ -40,6 +40,11 @@ export const DefaultPreferences: PreferenceSchemas = { // - system: Follow system theme preference 'ui.theme_mode': ThemeMode.system, + // Tablet sidebar position (only visible on tablet devices) + // - left: Sidebar on the left side + // - right: Sidebar on the right side + 'ui.tablet_sidebar_position': 'left', + // === Topic State === // Currently active conversation topic ID // Empty string means no active topic @@ -86,6 +91,7 @@ export const PreferenceDescriptions: Record Date: Wed, 4 Feb 2026 18:22:50 +0800 Subject: [PATCH 06/14] feat: add tablet landscape layout with persistent sidebar Introduce a responsive layout for tablets in landscape mode with a persistent sidebar. The sidebar contains navigation elements and assistant list. Mobile and portrait layouts remain unchanged with drawer navigation. Added useDrawer hook to centralize drawer logic. --- src/App.tsx | 5 +- .../features/ChatScreen/Header/index.tsx | 10 +- .../layout/DrawerGestureWrapper/index.tsx | 13 +- .../layout/TabletSidebar/index.tsx | 162 ++++++++++++++++++ src/hooks/useResponsive.ts | 29 ++-- src/navigators/AppDrawerNavigator.tsx | 126 ++++++++++++-- .../assistant/AssistantMarketScreen.tsx | 10 +- src/screens/assistant/AssistantScreen.tsx | 10 +- src/screens/home/ChatScreen.tsx | 9 +- 9 files changed, 318 insertions(+), 56 deletions(-) create mode 100644 src/componentsV2/layout/TabletSidebar/index.tsx diff --git a/src/App.tsx b/src/App.tsx index b8f960c8..9809028e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,7 +8,7 @@ import { useDrizzleStudio } from 'expo-drizzle-studio-plugin' import { useFonts } from 'expo-font' import * as SplashScreen from 'expo-splash-screen' import { HeroUINativeProvider } from 'heroui-native' -import React, { Suspense, useEffect } from 'react' +import React, { useEffect } from 'react' import { ActivityIndicator } from 'react-native' import { SystemBars } from 'react-native-edge-to-edge' import { GestureHandlerRootView } from 'react-native-gesture-handler' @@ -30,6 +30,7 @@ import { ShortcutCallbackManager } from './aiCore/tools/SystemTools/ShortcutCall import { DialogProvider } from './hooks/useDialog' import { ToastProvider } from './hooks/useToast' import MainStackNavigator from './navigators/MainStackNavigator' +import { navigationRef } from './navigators/navigationRef' import { runAppDataMigrations } from './services/AppInitializationService' // Prevent the splash screen from auto-hiding before asset loading is complete. @@ -103,7 +104,7 @@ function ThemedApp() { return ( - + diff --git a/src/componentsV2/features/ChatScreen/Header/index.tsx b/src/componentsV2/features/ChatScreen/Header/index.tsx index 6503df58..ac1f0737 100644 --- a/src/componentsV2/features/ChatScreen/Header/index.tsx +++ b/src/componentsV2/features/ChatScreen/Header/index.tsx @@ -1,12 +1,10 @@ -import type { DrawerNavigationProp } from '@react-navigation/drawer' -import type { ParamListBase } from '@react-navigation/native' -import { DrawerActions, useNavigation } from '@react-navigation/native' import React from 'react' import { IconButton } from '@/componentsV2/base/IconButton' import { Menu } from '@/componentsV2/icons/LucideIcon' import XStack from '@/componentsV2/layout/XStack' import { useAssistant } from '@/hooks/useAssistant' +import { useDrawer } from '@/hooks/useDrawer' import type { Topic } from '@/types/assistant' import { AssistantSelection } from './AssistantSelection' @@ -18,11 +16,11 @@ interface HeaderBarProps { } export const ChatScreenHeader = ({ topic }: HeaderBarProps) => { - const navigation = useNavigation>() + const { openDrawer, isDrawerAlwaysVisible } = useDrawer() const { assistant, isLoading } = useAssistant(topic.assistantId) const handleMenuPress = () => { - navigation.dispatch(DrawerActions.openDrawer()) + openDrawer() } if (isLoading || !assistant) { @@ -32,7 +30,7 @@ export const ChatScreenHeader = ({ topic }: HeaderBarProps) => { return ( - } /> + {!isDrawerAlwaysVisible && } />} diff --git a/src/componentsV2/layout/DrawerGestureWrapper/index.tsx b/src/componentsV2/layout/DrawerGestureWrapper/index.tsx index 69ef4509..4f685a21 100644 --- a/src/componentsV2/layout/DrawerGestureWrapper/index.tsx +++ b/src/componentsV2/layout/DrawerGestureWrapper/index.tsx @@ -1,9 +1,9 @@ -import type { DrawerNavigationProp } from '@react-navigation/drawer' -import { DrawerActions, useNavigation } from '@react-navigation/native' import type { PropsWithChildren } from 'react' import React from 'react' import { PanGestureHandler, State } from 'react-native-gesture-handler' +import { useDrawer } from '@/hooks/useDrawer' + interface DrawerGestureWrapperProps extends PropsWithChildren { enabled?: boolean } @@ -11,12 +11,13 @@ interface DrawerGestureWrapperProps extends PropsWithChildren { /** * Common wrapper component for handling drawer opening gesture * Swipe right from anywhere on the screen to open the drawer + * In tablet landscape mode, the drawer is always visible so gestures are ignored */ export const DrawerGestureWrapper = ({ children, enabled = true }: DrawerGestureWrapperProps) => { - const navigation = useNavigation>() + const { openDrawer, isDrawerAlwaysVisible } = useDrawer() const handleSwipeGesture = (event: any) => { - if (!enabled) return + if (!enabled || isDrawerAlwaysVisible) return const { translationX, velocityX, state } = event.nativeEvent @@ -28,12 +29,12 @@ export const DrawerGestureWrapper = ({ children, enabled = true }: DrawerGesture const hasExcellentDistance = translationX > 80 if ((hasGoodDistance && hasGoodVelocity) || hasExcellentDistance) { - navigation.dispatch(DrawerActions.openDrawer()) + openDrawer() } } } - if (!enabled) { + if (!enabled || isDrawerAlwaysVisible) { return <>{children} } diff --git a/src/componentsV2/layout/TabletSidebar/index.tsx b/src/componentsV2/layout/TabletSidebar/index.tsx new file mode 100644 index 00000000..841ab8fd --- /dev/null +++ b/src/componentsV2/layout/TabletSidebar/index.tsx @@ -0,0 +1,162 @@ +import { Divider } from 'heroui-native' +import React from 'react' +import { useTranslation } from 'react-i18next' +import { StyleSheet, View } from 'react-native' + +import { IconButton } from '@/componentsV2/base/IconButton' +import Image from '@/componentsV2/base/Image' +import Text from '@/componentsV2/base/Text' +import { AssistantList } from '@/componentsV2/features/Menu/AssistantList' +import { MenuTabContent } from '@/componentsV2/features/Menu/MenuTabContent' +import { MarketIcon, MCPIcon, Settings } from '@/componentsV2/icons' +import PressableRow from '@/componentsV2/layout/PressableRow' +import RowRightArrow from '@/componentsV2/layout/Row/RowRightArrow' +import XStack from '@/componentsV2/layout/XStack' +import YStack from '@/componentsV2/layout/YStack' +import { useAssistants } from '@/hooks/useAssistant' +import { usePreference } from '@/hooks/usePreference' +import { useSafeArea } from '@/hooks/useSafeArea' +import { useSettings } from '@/hooks/useSettings' +import { useTheme } from '@/hooks/useTheme' +import { useCurrentTopic } from '@/hooks/useTopic' +import { navigationRef } from '@/navigators/navigationRef' +import { loggerService } from '@/services/LoggerService' +import { topicService } from '@/services/TopicService' +import type { Assistant } from '@/types/assistant' + +const logger = loggerService.withContext('TabletSidebar') + +const SIDEBAR_WIDTH = 320 + +export function TabletSidebar() { + const { t } = useTranslation() + const { isDark } = useTheme() + const { avatar, userName } = useSettings() + const insets = useSafeArea() + const { switchTopic } = useCurrentTopic() + const [sidebarPosition] = usePreference('ui.tablet_sidebar_position') + const isRightSide = sidebarPosition === 'right' + + const { assistants, isLoading: isAssistantsLoading } = useAssistants() + + const handleNavigateAssistantScreen = () => { + navigationRef.current?.navigate('Assistant', { screen: 'AssistantScreen' }) + } + + const handleNavigateAssistantMarketScreen = () => { + navigationRef.current?.navigate('AssistantMarket', { screen: 'AssistantMarketScreen' }) + } + + const handleNavigateMcpScreen = () => { + navigationRef.current?.navigate('Mcp', { screen: 'McpScreen' }) + } + + const handleNavigateSettingsScreen = () => { + navigationRef.current?.navigate('Home', { screen: 'SettingsScreen' }) + } + + const handleNavigatePersonalScreen = () => { + navigationRef.current?.navigate('Home', { screen: 'AboutSettings', params: { screen: 'PersonalScreen' } }) + } + + const handleNavigateChatScreen = (topicId: string) => { + navigationRef.current?.navigate('Home', { screen: 'ChatScreen', params: { topicId: topicId } }) + } + + const handleAssistantItemPress = async (assistant: Assistant) => { + try { + const assistantTopics = await topicService.getTopicsByAssistantId(assistant.id) + const latestTopic = assistantTopics[0] + + if (latestTopic) { + await switchTopic(latestTopic.id) + handleNavigateChatScreen(latestTopic.id) + return + } + + const newTopic = await topicService.createTopic(assistant) + await switchTopic(newTopic.id) + handleNavigateChatScreen(newTopic.id) + } catch (error) { + logger.error('Failed to open assistant topic from sidebar', error as Error) + } + } + + return ( + + + + + + + {t('assistants.market.title')} + + + + + + + + {t('mcp.server.title')} + + + + + + + + + + + + + + + + + + + + + {userName || t('common.cherry_studio')} + + } onPress={handleNavigateSettingsScreen} style={{ paddingRight: 16 }} /> + + + ) +} + +const styles = StyleSheet.create({ + sidebar: { + width: SIDEBAR_WIDTH, + height: '100%' + }, + sidebarLeft: { + borderRightWidth: StyleSheet.hairlineWidth, + borderRightColor: 'rgba(0,0,0,0.1)' + }, + sidebarRight: { + borderLeftWidth: StyleSheet.hairlineWidth, + borderLeftColor: 'rgba(0,0,0,0.1)' + } +}) diff --git a/src/hooks/useResponsive.ts b/src/hooks/useResponsive.ts index 8a5d2bae..b9b726ea 100644 --- a/src/hooks/useResponsive.ts +++ b/src/hooks/useResponsive.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react' +import { useEffect, useMemo } from 'react' import type { ScaledSize } from 'react-native' import { Dimensions, useWindowDimensions } from 'react-native' @@ -53,19 +53,22 @@ const getOrientation = (width: number, height: number): Orientation => { */ export function useResponsive(): ResponsiveInfo { const { width, height } = useWindowDimensions() - const deviceType = getDeviceType(width, height) - const orientation = getOrientation(width, height) - return { - deviceType, - orientation, - isTablet: deviceType === 'tablet', - isPhone: deviceType === 'phone', - isPortrait: orientation === 'portrait', - isLandscape: orientation === 'landscape', - width, - height - } + return useMemo(() => { + const deviceType = getDeviceType(width, height) + const orientation = getOrientation(width, height) + + return { + deviceType, + orientation, + isTablet: deviceType === 'tablet', + isPhone: deviceType === 'phone', + isPortrait: orientation === 'portrait', + isLandscape: orientation === 'landscape', + width, + height + } + }, [width, height]) } /** diff --git a/src/navigators/AppDrawerNavigator.tsx b/src/navigators/AppDrawerNavigator.tsx index a1d07f96..772722cf 100644 --- a/src/navigators/AppDrawerNavigator.tsx +++ b/src/navigators/AppDrawerNavigator.tsx @@ -4,14 +4,18 @@ import '@/i18n' import type { DrawerNavigationOptions } from '@react-navigation/drawer' import { createDrawerNavigator } from '@react-navigation/drawer' import { getFocusedRouteNameFromRoute, type RouteProp } from '@react-navigation/native' -import React from 'react' +import React, { useMemo } from 'react' +import { StyleSheet, View } from 'react-native' import CustomDrawerContent from '@/componentsV2/features/Menu/CustomDrawerContent' -import AssistantMarketStackNavigator from '@/navigators/AssistantMarketStackNavigator' -import AssistantStackNavigator from '@/navigators/AssistantStackNavigator' -import HomeStackNavigator from '@/navigators/HomeStackNavigator' +import { TabletSidebar } from '@/componentsV2/layout/TabletSidebar' +import { usePreference } from '@/hooks/usePreference' +import { useResponsive } from '@/hooks/useResponsive' import { Width } from '@/utils/device' +import AssistantMarketStackNavigator from './AssistantMarketStackNavigator' +import AssistantStackNavigator from './AssistantStackNavigator' +import HomeStackNavigator from './HomeStackNavigator' import McpStackNavigator from './McpStackNavigator' const Drawer = createDrawerNavigator() @@ -72,18 +76,106 @@ const getMcpScreenOptions = ({ } } -export default function AppDrawerNavigator() { - return ( - } screenOptions={screenOptions}> - {/* Main grouped navigators */} - - - - - - {/* Individual screens for backward compatibility */} - {/* - */} - + + +/** + * 平板横屏双栏布局导航器 + * 使用 DrawerNavigator 但隐藏抽屉UI,通过固定侧边栏控制导航 + */ +function TabletLandscapeNavigator() { + const [sidebarPosition] = usePreference('ui.tablet_sidebar_position') + const isRightSide = sidebarPosition === 'right' + + const drawerNavigator = useMemo( + () => ( + + {/* 固定侧边栏 */} + + + + + {/* 内容区域 - 使用 DrawerNavigator 但隐藏抽屉 */} + + } + screenOptions={{ + ...screenOptions, + // 隐藏抽屉UI + drawerType: 'permanent', + drawerStyle: { width: 0, opacity: 0 }, + swipeEnabled: false + }}> + + + + + + + + ), + [isRightSide] + ) + + return drawerNavigator +} + +/** + * 移动端抽屉导航器 + */ +function MobileDrawerNavigator() { + const drawerNavigator = useMemo( + () => ( + } screenOptions={screenOptions}> + + + + + + ), + [] ) + + return drawerNavigator } + +/** + * 统一的导航器组件 + */ +export default function AppDrawerNavigator() { + const { isTablet, isLandscape } = useResponsive() + const isTabletLandscape = isTablet && isLandscape + + // 平板横屏时:使用双栏布局 + if (isTabletLandscape) { + return + } + + // 移动端或平板竖屏:使用抽屉导航 + return +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + flexDirection: 'row' + }, + containerReversed: { + flexDirection: 'row-reverse' + }, + sidebarContainer: { + width: 320, + height: '100%' + }, + sidebarLeft: { + borderRightWidth: StyleSheet.hairlineWidth, + borderRightColor: 'rgba(0,0,0,0.1)' + }, + sidebarRight: { + borderLeftWidth: StyleSheet.hairlineWidth, + borderLeftColor: 'rgba(0,0,0,0.1)' + }, + content: { + flex: 1, + height: '100%' + } +}) diff --git a/src/screens/assistant/AssistantMarketScreen.tsx b/src/screens/assistant/AssistantMarketScreen.tsx index 4b18d981..114d3b5f 100644 --- a/src/screens/assistant/AssistantMarketScreen.tsx +++ b/src/screens/assistant/AssistantMarketScreen.tsx @@ -1,4 +1,4 @@ -import { DrawerActions, useNavigation } from '@react-navigation/native' +import { useNavigation } from '@react-navigation/native' import React, { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { View } from 'react-native' @@ -15,6 +15,7 @@ import { presentAssistantItemSheet } from '@/componentsV2/features/Assistant/Ass import AssistantsTabContent from '@/componentsV2/features/Assistant/AssistantsTabContent' import { Menu } from '@/componentsV2/icons' import { useBuiltInAssistants } from '@/hooks/useAssistant' +import { useDrawer } from '@/hooks/useDrawer' import { useSearch } from '@/hooks/useSearch' import { useSkeletonLoading } from '@/hooks/useSkeletonLoading' import type { Assistant } from '@/types/assistant' @@ -23,6 +24,7 @@ import type { DrawerNavigationProps } from '@/types/naviagate' export default function AssistantMarketScreen() { const { t } = useTranslation() const navigation = useNavigation() + const { openDrawer, isDrawerAlwaysVisible } = useDrawer() const { assistants: builtInAssistants } = useBuiltInAssistants() const { @@ -38,7 +40,7 @@ export default function AssistantMarketScreen() { const showSkeleton = useSkeletonLoading(isLoading) const handleMenuPress = () => { - navigation.dispatch(DrawerActions.openDrawer()) + openDrawer() } const onChatNavigation = async (topicId: string) => { @@ -59,10 +61,10 @@ export default function AssistantMarketScreen() { , onPress: handleMenuPress - }} + } : undefined} /> () + const { openDrawer, isDrawerAlwaysVisible } = useDrawer() const toast = useToast() const { assistants, isLoading } = useAssistants() @@ -81,7 +83,7 @@ export default function AssistantScreen() { } const handleMenuPress = () => { - navigation.dispatch(DrawerActions.openDrawer()) + openDrawer() } const handleEnterMultiSelectMode = useCallback((assistantId: string) => { @@ -184,10 +186,10 @@ export default function AssistantScreen() { ) : ( , onPress: handleMenuPress - }} + } : undefined} rightButtons={[ { icon: , diff --git a/src/screens/home/ChatScreen.tsx b/src/screens/home/ChatScreen.tsx index e2db234b..1febf6c7 100644 --- a/src/screens/home/ChatScreen.tsx +++ b/src/screens/home/ChatScreen.tsx @@ -1,5 +1,4 @@ -import type { DrawerNavigationProp } from '@react-navigation/drawer' -import { DrawerActions, useNavigation } from '@react-navigation/native' +import { useNavigation } from '@react-navigation/native' import type { StackNavigationProp } from '@react-navigation/stack' import React from 'react' import { ActivityIndicator, Platform, View } from 'react-native' @@ -13,6 +12,7 @@ import { MessageInputContainer } from '@/componentsV2/features/ChatScreen/Messag import { CitationSheet } from '@/componentsV2/features/Sheet/CitationSheet' import { useAssistant } from '@/hooks/useAssistant' import { useBottom } from '@/hooks/useBottom' +import { useDrawer } from '@/hooks/useDrawer' import { usePreference } from '@/hooks/usePreference' import { useCurrentTopic } from '@/hooks/useTopic' import type { HomeStackParamList } from '@/navigators/HomeStackNavigator' @@ -21,11 +21,12 @@ import ChatContent from './ChatContent' KeyboardController.preload() -type ChatScreenNavigationProp = DrawerNavigationProp & StackNavigationProp +type ChatScreenNavigationProp = StackNavigationProp const ChatScreen = () => { const insets = useSafeAreaInsets() const navigation = useNavigation() + const { openDrawer } = useDrawer() const [topicId] = usePreference('topic.current_id') const { currentTopic } = useCurrentTopic() @@ -44,7 +45,7 @@ const ChatScreen = () => { const hasExcellentDistance = translationX > 80 if ((hasGoodDistance && hasGoodVelocity) || hasExcellentDistance) { - navigation.dispatch(DrawerActions.openDrawer()) + openDrawer() } } // 左滑 → 跳转到 TopicScreen From b6709a8b339b0e0582854d5d1c2f49b6dccfcfe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=9F=8E=E5=9F=8E?= Date: Wed, 4 Feb 2026 18:25:17 +0800 Subject: [PATCH 07/14] feat: add navigation cleanup and optimize useResponsive hook Add cleanup for navigation ref to prevent memory leaks Replace useMemo with direct computation in useResponsive hook Update navigationRef to use mutable ref object --- src/App.tsx | 9 ++++++++- src/hooks/useResponsive.ts | 29 ++++++++++++++--------------- src/navigators/navigationRef.ts | 25 +++++++++++++++++++++---- 3 files changed, 43 insertions(+), 20 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 9809028e..62874b54 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -30,7 +30,7 @@ import { ShortcutCallbackManager } from './aiCore/tools/SystemTools/ShortcutCall import { DialogProvider } from './hooks/useDialog' import { ToastProvider } from './hooks/useToast' import MainStackNavigator from './navigators/MainStackNavigator' -import { navigationRef } from './navigators/navigationRef' +import { navigationRef, resetNavigationRef } from './navigators/navigationRef' import { runAppDataMigrations } from './services/AppInitializationService' // Prevent the splash screen from auto-hiding before asset loading is complete. @@ -101,6 +101,13 @@ function ThemedApp() { Uniwind.setTheme(isDark ? 'dark' : 'light') }, [isDark]) + // Cleanup navigation ref on unmount to prevent memory leaks + useEffect(() => { + return () => { + resetNavigationRef() + } + }, []) + return ( diff --git a/src/hooks/useResponsive.ts b/src/hooks/useResponsive.ts index b9b726ea..82065696 100644 --- a/src/hooks/useResponsive.ts +++ b/src/hooks/useResponsive.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from 'react' +import { useEffect } from 'react' import type { ScaledSize } from 'react-native' import { Dimensions, useWindowDimensions } from 'react-native' @@ -54,21 +54,20 @@ const getOrientation = (width: number, height: number): Orientation => { export function useResponsive(): ResponsiveInfo { const { width, height } = useWindowDimensions() - return useMemo(() => { - const deviceType = getDeviceType(width, height) - const orientation = getOrientation(width, height) + // Direct computation - useMemo not needed for cheap calculations + const deviceType = getDeviceType(width, height) + const orientation = getOrientation(width, height) - return { - deviceType, - orientation, - isTablet: deviceType === 'tablet', - isPhone: deviceType === 'phone', - isPortrait: orientation === 'portrait', - isLandscape: orientation === 'landscape', - width, - height - } - }, [width, height]) + return { + deviceType, + orientation, + isTablet: deviceType === 'tablet', + isPhone: deviceType === 'phone', + isPortrait: orientation === 'portrait', + isLandscape: orientation === 'landscape', + width, + height + } } /** diff --git a/src/navigators/navigationRef.ts b/src/navigators/navigationRef.ts index 7ee04dab..bc3f7e89 100644 --- a/src/navigators/navigationRef.ts +++ b/src/navigators/navigationRef.ts @@ -1,16 +1,33 @@ import type { NavigationContainerRef, ParamListBase } from '@react-navigation/native' -import { createRef } from 'react' +// Use a mutable ref object instead of createRef to avoid potential memory leaks +// and allow proper cleanup when NavigationContainer unmounts +export const navigationRef: { current: NavigationContainerRef | null } = { + current: null +} -export const navigationRef = createRef>() - +/** + * Check if navigation is ready (NavigationContainer is mounted) + */ export function isNavigationReady(): boolean { return navigationRef.current !== null && navigationRef.current !== undefined } - +/** + * Safely navigate to a screen + * @param name - Screen name + * @param params - Navigation params + */ export function safeNavigate(name: string, params?: object): void { if (isNavigationReady()) { navigationRef.current?.navigate(name, params) } } + +/** + * Reset the navigation ref (call this when NavigationContainer unmounts) + * This prevents memory leaks by releasing the reference + */ +export function resetNavigationRef(): void { + navigationRef.current = null +} From f783887914f851a653182c0c4d97d2e9190da9e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=9F=8E=E5=9F=8E?= Date: Wed, 4 Feb 2026 18:17:49 +0800 Subject: [PATCH 08/14] feat: add navigation ref utility for programmatic navigation --- src/navigators/navigationRef.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 src/navigators/navigationRef.ts diff --git a/src/navigators/navigationRef.ts b/src/navigators/navigationRef.ts new file mode 100644 index 00000000..7ee04dab --- /dev/null +++ b/src/navigators/navigationRef.ts @@ -0,0 +1,16 @@ +import type { NavigationContainerRef, ParamListBase } from '@react-navigation/native' +import { createRef } from 'react' + + +export const navigationRef = createRef>() + +export function isNavigationReady(): boolean { + return navigationRef.current !== null && navigationRef.current !== undefined +} + + +export function safeNavigate(name: string, params?: object): void { + if (isNavigationReady()) { + navigationRef.current?.navigate(name, params) + } +} From 2ba55b01330859da5b2ff1d82df73a24f42f85b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=9F=8E=E5=9F=8E?= Date: Wed, 4 Feb 2026 18:18:25 +0800 Subject: [PATCH 09/14] feat: add useDrawer hook with tablet landscape support --- src/hooks/useDrawer.ts | 46 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/hooks/useDrawer.ts diff --git a/src/hooks/useDrawer.ts b/src/hooks/useDrawer.ts new file mode 100644 index 00000000..ddb48f0a --- /dev/null +++ b/src/hooks/useDrawer.ts @@ -0,0 +1,46 @@ +import { DrawerActions, useNavigation } from '@react-navigation/native' +import { useCallback } from 'react' + +import { useResponsive } from './useResponsive' + +/** + * Hook for safely handling drawer operations + * In tablet landscape mode, the drawer is always visible and doesn't need open/close actions + */ +export function useDrawer() { + const navigation = useNavigation() + const { isTablet, isLandscape } = useResponsive() + const isTabletLandscape = isTablet && isLandscape + + const openDrawer = useCallback(() => { + // In tablet landscape mode, drawer is always visible, no need to open + if (isTabletLandscape) { + return + } + navigation.dispatch(DrawerActions.openDrawer()) + }, [navigation, isTabletLandscape]) + + const closeDrawer = useCallback(() => { + // In tablet landscape mode, drawer is always visible, no need to close + if (isTabletLandscape) { + return + } + navigation.dispatch(DrawerActions.closeDrawer()) + }, [navigation, isTabletLandscape]) + + const toggleDrawer = useCallback(() => { + // In tablet landscape mode, drawer is always visible, no need to toggle + if (isTabletLandscape) { + return + } + navigation.dispatch(DrawerActions.toggleDrawer()) + }, [navigation, isTabletLandscape]) + + return { + openDrawer, + closeDrawer, + toggleDrawer, + // Indicates if currently in tablet landscape mode (drawer is always visible in this mode) + isDrawerAlwaysVisible: isTabletLandscape + } +} From a59a08b409edae8a77e6ae76e6e3d89640e4cfc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=9F=8E=E5=9F=8E?= Date: Wed, 4 Feb 2026 18:19:12 +0800 Subject: [PATCH 10/14] feat: add tablet sidebar translations for multiple languages --- src/i18n/locales/en-us.json | 6 ++++++ src/i18n/locales/ja-jp.json | 6 ++++++ src/i18n/locales/ru-ru.json | 6 ++++++ src/i18n/locales/zh-cn.json | 6 ++++++ src/i18n/locales/zh-tw.json | 6 ++++++ 5 files changed, 30 insertions(+) diff --git a/src/i18n/locales/en-us.json b/src/i18n/locales/en-us.json index f7bb2ab0..b322d64a 100644 --- a/src/i18n/locales/en-us.json +++ b/src/i18n/locales/en-us.json @@ -700,6 +700,12 @@ "anonymous": "Anonymous report errors and statistics", "title": "Privacy Settings" }, + "tablet_sidebar": { + "title": "Tablet Sidebar", + "position": "Sidebar Position", + "left": "Left", + "right": "Right" + }, "theme": { "auto": "Auto", "dark": "Dark", diff --git a/src/i18n/locales/ja-jp.json b/src/i18n/locales/ja-jp.json index 4a8cdf8c..6e7ab5c3 100644 --- a/src/i18n/locales/ja-jp.json +++ b/src/i18n/locales/ja-jp.json @@ -700,6 +700,12 @@ "anonymous": "エラーと統計を匿名で報告", "title": "プライバシー設定" }, + "tablet_sidebar": { + "title": "タブレットサイドバー", + "position": "サイドバー位置", + "left": "左", + "right": "右" + }, "theme": { "auto": "自動", "dark": "ダーク", diff --git a/src/i18n/locales/ru-ru.json b/src/i18n/locales/ru-ru.json index 9d450788..b82954fb 100644 --- a/src/i18n/locales/ru-ru.json +++ b/src/i18n/locales/ru-ru.json @@ -700,6 +700,12 @@ "anonymous": "Анонимно сообщать об ошибках и статистике", "title": "Настройки конфиденциальности" }, + "tablet_sidebar": { + "title": "Боковая панель планшета", + "position": "Положение боковой панели", + "left": "Слева", + "right": "Справа" + }, "theme": { "auto": "Авто", "dark": "Тёмная", diff --git a/src/i18n/locales/zh-cn.json b/src/i18n/locales/zh-cn.json index 7da44afa..0a25e874 100644 --- a/src/i18n/locales/zh-cn.json +++ b/src/i18n/locales/zh-cn.json @@ -700,6 +700,12 @@ "anonymous": "匿名报告错误和统计", "title": "隐私设置" }, + "tablet_sidebar": { + "title": "平板导航栏", + "position": "导航栏位置", + "left": "左侧", + "right": "右侧" + }, "theme": { "auto": "自动", "dark": "深色", diff --git a/src/i18n/locales/zh-tw.json b/src/i18n/locales/zh-tw.json index 93230a80..fb63511b 100644 --- a/src/i18n/locales/zh-tw.json +++ b/src/i18n/locales/zh-tw.json @@ -700,6 +700,12 @@ "anonymous": "匿名回報錯誤和統計", "title": "隱私設定" }, + "tablet_sidebar": { + "title": "平板導航欄", + "position": "導航欄位置", + "left": "左側", + "right": "右側" + }, "theme": { "auto": "自動", "dark": "深色", From 953ce09e94d38a09cfb3d24ca83f5c06f6a4c01a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=9F=8E=E5=9F=8E?= Date: Wed, 4 Feb 2026 18:21:28 +0800 Subject: [PATCH 11/14] refactor: replace Container with ScrollView in settings screens Update various settings screens to use ScrollView with padding instead of Container for better scrolling behavior and consistent layout. Also adjust drawer handling in McpScreen to use custom hook. --- src/screens/mcp/McpScreen.tsx | 14 ++++--- src/screens/settings/SettingsScreen.tsx | 37 +++++++++---------- src/screens/settings/about/AboutScreen.tsx | 22 +++-------- .../assistant/AssistantSettingsScreen.tsx | 10 ++--- .../settings/data/BasicDataSettingsScreen.tsx | 9 ++--- .../settings/data/DataSettingsScreen.tsx | 26 ++++++------- .../general/GeneralSettingsScreen.tsx | 9 +++-- .../websearch/WebSearchSettingsScreen.tsx | 19 ++++------ 8 files changed, 65 insertions(+), 81 deletions(-) diff --git a/src/screens/mcp/McpScreen.tsx b/src/screens/mcp/McpScreen.tsx index 86b4493f..3e8c8cbf 100644 --- a/src/screens/mcp/McpScreen.tsx +++ b/src/screens/mcp/McpScreen.tsx @@ -1,4 +1,4 @@ -import { DrawerActions, useNavigation } from '@react-navigation/native' +import { useNavigation } from '@react-navigation/native' import React, { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { View } from 'react-native' @@ -13,18 +13,20 @@ import { } from '@/componentsV2' import { McpMarketContent } from '@/componentsV2/features/MCP/McpMarketContent' import { Menu, Plus, Store } from '@/componentsV2/icons/LucideIcon' +import { useDrawer } from '@/hooks/useDrawer' import { useMcpServers } from '@/hooks/useMcp' import { useSearch } from '@/hooks/useSearch' import { useSkeletonLoading } from '@/hooks/useSkeletonLoading' import { useToast } from '@/hooks/useToast' import { mcpService } from '@/services/McpService' import type { MCPServer } from '@/types/mcp' -import type { DrawerNavigationProps, McpNavigationProps } from '@/types/naviagate' +import type { McpNavigationProps } from '@/types/naviagate' import { uuid } from '@/utils' export default function McpScreen() { const { t } = useTranslation() - const navigation = useNavigation() + const navigation = useNavigation() + const { openDrawer, isDrawerAlwaysVisible } = useDrawer() const toast = useToast() const { mcpServers, isLoading, updateMcpServers } = useMcpServers() const { @@ -39,7 +41,7 @@ export default function McpScreen() { const showSkeleton = useSkeletonLoading(isLoading) const handleMenuPress = () => { - navigation.dispatch(DrawerActions.openDrawer()) + openDrawer() } const handleNavigateToMarket = () => { @@ -78,10 +80,10 @@ export default function McpScreen() { , onPress: handleMenuPress - }} + } : undefined} rightButtons={[ { icon: , diff --git a/src/screens/settings/SettingsScreen.tsx b/src/screens/settings/SettingsScreen.tsx index 0cc39556..d8def230 100644 --- a/src/screens/settings/SettingsScreen.tsx +++ b/src/screens/settings/SettingsScreen.tsx @@ -5,7 +5,6 @@ import { ScrollView, View } from 'react-native' import { GestureDetector } from 'react-native-gesture-handler' import { - Container, Group, GroupTitle, HeaderBar, @@ -109,25 +108,23 @@ export default function SettingsScreen() { - - - - {settingsItems.map((group, index) => ( - - {group.items.map((item, index) => ( - - ))} - - ))} - - - + + + {settingsItems.map((group, index) => ( + + {group.items.map((item, index) => ( + + ))} + + ))} + + diff --git a/src/screens/settings/about/AboutScreen.tsx b/src/screens/settings/about/AboutScreen.tsx index eab4f6db..f95cd439 100644 --- a/src/screens/settings/about/AboutScreen.tsx +++ b/src/screens/settings/about/AboutScreen.tsx @@ -1,20 +1,10 @@ import * as ExpoLinking from 'expo-linking' import React from 'react' import { useTranslation } from 'react-i18next' +import { ScrollView } from 'react-native' import FastSquircleView from 'react-native-fast-squircle' -import { - Container, - Group, - HeaderBar, - Image, - PressableRow, - Row, - SafeAreaContainer, - Text, - XStack, - YStack -} from '@/componentsV2' +import { Group, HeaderBar, Image, PressableRow, Row, SafeAreaContainer, Text, XStack, YStack } from '@/componentsV2' import { ArrowUpRight, Copyright, Github, Globe, Mail, Rss } from '@/componentsV2/icons/LucideIcon' import { loggerService } from '@/services/LoggerService' @@ -42,8 +32,8 @@ export default function AboutScreen() { onPress: async () => await openLink('https://github.com/CherryHQ/cherry-studio-app') }} /> - - + + {/* Logo and Description */} @@ -57,7 +47,7 @@ export default function AboutScreen() { cornerSmoothing={0.6}> - + {t('common.cherry_studio')} {t('common.cherry_studio_description')} @@ -109,7 +99,7 @@ export default function AboutScreen() { - + ) } diff --git a/src/screens/settings/assistant/AssistantSettingsScreen.tsx b/src/screens/settings/assistant/AssistantSettingsScreen.tsx index 6c1d53d9..a4185e32 100644 --- a/src/screens/settings/assistant/AssistantSettingsScreen.tsx +++ b/src/screens/settings/assistant/AssistantSettingsScreen.tsx @@ -3,9 +3,9 @@ import type { StackNavigationProp } from '@react-navigation/stack' import { Button } from 'heroui-native' import React from 'react' import { useTranslation } from 'react-i18next' -import { ActivityIndicator } from 'react-native' +import { ActivityIndicator, ScrollView } from 'react-native' -import { Container, HeaderBar, IconButton, Image, SafeAreaContainer, Text, XStack, YStack } from '@/componentsV2' +import { HeaderBar, IconButton, Image, SafeAreaContainer, Text, XStack, YStack } from '@/componentsV2' import { presentModelSheet } from '@/componentsV2/features/Sheet/ModelSheet' import { ChevronDown, Languages, MessageSquareMore, Rocket, Settings2 } from '@/componentsV2/icons/LucideIcon' import { useAssistant } from '@/hooks/useAssistant' @@ -37,7 +37,7 @@ function ModelPicker({ assistant, onPress }: { assistant: Assistant; onPress: () {model ? ( <> @@ -160,7 +160,7 @@ export default function AssistantSettingsScreen() { return ( - + {assistantItems.map(item => ( ))} - + ) } diff --git a/src/screens/settings/data/BasicDataSettingsScreen.tsx b/src/screens/settings/data/BasicDataSettingsScreen.tsx index 86667166..9e45b721 100644 --- a/src/screens/settings/data/BasicDataSettingsScreen.tsx +++ b/src/screens/settings/data/BasicDataSettingsScreen.tsx @@ -7,10 +7,9 @@ import * as IntentLauncher from 'expo-intent-launcher' import { delay } from 'lodash' import React, { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { InteractionManager, Platform } from 'react-native' +import { InteractionManager, Platform, ScrollView } from 'react-native' import { - Container, dismissDialog, Group, GroupTitle, @@ -270,8 +269,8 @@ export default function BasicDataSettingsScreen() { - - + + {settingsItems.map(group => ( {group.items.map(item => ( @@ -280,7 +279,7 @@ export default function BasicDataSettingsScreen() { ))} - + - - - - {settingsItems.map(group => ( - - {group.items.map(item => ( - - ))} - - ))} - - - + + + {settingsItems.map(group => ( + + {group.items.map(item => ( + + ))} + + ))} + + ) } diff --git a/src/screens/settings/general/GeneralSettingsScreen.tsx b/src/screens/settings/general/GeneralSettingsScreen.tsx index 7b6348e3..a5ab15ce 100644 --- a/src/screens/settings/general/GeneralSettingsScreen.tsx +++ b/src/screens/settings/general/GeneralSettingsScreen.tsx @@ -4,18 +4,21 @@ import { useTranslation } from 'react-i18next' import { Container, Group, GroupTitle, HeaderBar, SafeAreaContainer, Text, XStack, YStack } from '@/componentsV2' import { LanguageDropdown } from '@/componentsV2/features/SettingsScreen/general/LanguageDropdown' +import { TabletSidebarPositionDropdown } from '@/componentsV2/features/SettingsScreen/general/TabletSidebarPositionDropdown' import { ThemeDropdown } from '@/componentsV2/features/SettingsScreen/general/ThemeDropdown' import { usePreference } from '@/hooks/usePreference' +import { useResponsive } from '@/hooks/useResponsive' export default function GeneralSettingsScreen() { const { t } = useTranslation() + const { isTablet } = useResponsive() const [developerMode, setDeveloperMode] = usePreference('app.developer_mode') return ( - - + + {/* Display settings */} {t('settings.general.display.title')} @@ -49,7 +52,7 @@ export default function GeneralSettingsScreen() { - + ) } diff --git a/src/screens/settings/websearch/WebSearchSettingsScreen.tsx b/src/screens/settings/websearch/WebSearchSettingsScreen.tsx index 3fe98538..b042e0ca 100644 --- a/src/screens/settings/websearch/WebSearchSettingsScreen.tsx +++ b/src/screens/settings/websearch/WebSearchSettingsScreen.tsx @@ -1,9 +1,8 @@ import React from 'react' import { useTranslation } from 'react-i18next' -import { View } from 'react-native' import { KeyboardAwareScrollView } from 'react-native-keyboard-controller' -import { Container, HeaderBar, SafeAreaContainer, YStack } from '@/componentsV2' +import { HeaderBar, SafeAreaContainer, YStack } from '@/componentsV2' import GeneralSettings from './GeneralSettings' import ProviderSettings from './ProviderSettings' @@ -13,17 +12,13 @@ export default function WebSearchSettingsScreen() { return ( - - - - - - + + + + - - - - + + ) From 90809a5e74b61ff8e3ab46dd7d29abfc2cecc6de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=9F=8E=E5=9F=8E?= Date: Wed, 4 Feb 2026 18:22:37 +0800 Subject: [PATCH 12/14] feat: add tablet sidebar position setting Introduce a new dropdown in general settings to select tablet sidebar position (left/right). Includes new icons, preference schema updates, and UI component. --- .../general/TabletSidebarPositionDropdown.tsx | 47 +++++++++++++++++++ src/componentsV2/icons/LucideIcon/index.tsx | 6 +++ .../general/GeneralSettingsScreen.tsx | 16 ++++++- .../data/preference/preferenceSchemas.ts | 6 +++ src/shared/data/preference/preferenceTypes.ts | 3 ++ 5 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 src/componentsV2/features/SettingsScreen/general/TabletSidebarPositionDropdown.tsx diff --git a/src/componentsV2/features/SettingsScreen/general/TabletSidebarPositionDropdown.tsx b/src/componentsV2/features/SettingsScreen/general/TabletSidebarPositionDropdown.tsx new file mode 100644 index 00000000..6a2a6bb8 --- /dev/null +++ b/src/componentsV2/features/SettingsScreen/general/TabletSidebarPositionDropdown.tsx @@ -0,0 +1,47 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { Pressable } from 'react-native' + +import SelectionDropdown, { type SelectionDropdownItem } from '@/componentsV2/base/SelectionDropdown' +import Text from '@/componentsV2/base/Text' +import { ChevronsUpDown, PanelLeft, PanelRight } from '@/componentsV2/icons' +import { usePreference } from '@/hooks/usePreference' +import type { TabletSidebarPosition } from '@/shared/data/preference/preferenceTypes' + +const positionOptions: { value: TabletSidebarPosition; labelKey: string; icon: React.ReactNode }[] = [ + { value: 'left', labelKey: 'settings.general.tablet_sidebar.left', icon: }, + { value: 'right', labelKey: 'settings.general.tablet_sidebar.right', icon: } +] + +export function TabletSidebarPositionDropdown() { + const { t } = useTranslation() + const [currentPosition, setCurrentPosition] = usePreference('ui.tablet_sidebar_position') + + const handlePositionChange = (position: TabletSidebarPosition) => { + setCurrentPosition(position) + } + + const positionDropdownOptions: SelectionDropdownItem[] = positionOptions.map(opt => ({ + id: opt.value, + label: t(opt.labelKey), + icon: opt.icon, + isSelected: currentPosition === opt.value, + onSelect: () => handlePositionChange(opt.value) + })) + + const getCurrentPositionLabel = () => { + const current = positionOptions.find(item => item.value === currentPosition) + return current ? t(current.labelKey) : t('settings.general.tablet_sidebar.left') + } + + return ( + + + + {getCurrentPositionLabel()} + + + + + ) +} diff --git a/src/componentsV2/icons/LucideIcon/index.tsx b/src/componentsV2/icons/LucideIcon/index.tsx index e9c51a49..f48e055c 100644 --- a/src/componentsV2/icons/LucideIcon/index.tsx +++ b/src/componentsV2/icons/LucideIcon/index.tsx @@ -56,6 +56,8 @@ import { MoreHorizontal, Package, Palette, + PanelLeft, + PanelRight, PenLine, Plus, Radio, @@ -159,6 +161,8 @@ const MicIcon = createIcon(Mic) const MinusIcon = createIcon(Minus) const MoreHorizontalIcon = createIcon(MoreHorizontal) const PackageIcon = createIcon(Package) +const PanelLeftIcon = createIcon(PanelLeft) +const PanelRightIcon = createIcon(PanelRight) const PenLineIcon = createIcon(PenLine) const PlusIcon = createIcon(Plus) const RadioIcon = createIcon(Radio) @@ -244,6 +248,8 @@ export { MoreHorizontalIcon as MoreHorizontal, PackageIcon as Package, PaletteIcon as Palette, + PanelLeftIcon as PanelLeft, + PanelRightIcon as PanelRight, PenLineIcon as PenLine, PlusIcon as Plus, RadioIcon as Radio, diff --git a/src/screens/settings/general/GeneralSettingsScreen.tsx b/src/screens/settings/general/GeneralSettingsScreen.tsx index a5ab15ce..895becca 100644 --- a/src/screens/settings/general/GeneralSettingsScreen.tsx +++ b/src/screens/settings/general/GeneralSettingsScreen.tsx @@ -1,8 +1,9 @@ import { Switch } from 'heroui-native' import React from 'react' import { useTranslation } from 'react-i18next' +import { ScrollView } from 'react-native' -import { Container, Group, GroupTitle, HeaderBar, SafeAreaContainer, Text, XStack, YStack } from '@/componentsV2' +import { Group, GroupTitle, HeaderBar, SafeAreaContainer, Text, XStack, YStack } from '@/componentsV2' import { LanguageDropdown } from '@/componentsV2/features/SettingsScreen/general/LanguageDropdown' import { TabletSidebarPositionDropdown } from '@/componentsV2/features/SettingsScreen/general/TabletSidebarPositionDropdown' import { ThemeDropdown } from '@/componentsV2/features/SettingsScreen/general/ThemeDropdown' @@ -41,6 +42,19 @@ export default function GeneralSettingsScreen() { + {/* Tablet sidebar position - only visible on tablet devices */} + {isTablet && ( + + {t('settings.general.tablet_sidebar.title')} + + + {t('settings.general.tablet_sidebar.position')} + + + + + )} + {/* Developer settings */} {t('settings.general.developer_mode.title')} diff --git a/src/shared/data/preference/preferenceSchemas.ts b/src/shared/data/preference/preferenceSchemas.ts index 34ea3a2c..16160744 100644 --- a/src/shared/data/preference/preferenceSchemas.ts +++ b/src/shared/data/preference/preferenceSchemas.ts @@ -40,6 +40,11 @@ export const DefaultPreferences: PreferenceSchemas = { // - system: Follow system theme preference 'ui.theme_mode': ThemeMode.system, + // Tablet sidebar position (only visible on tablet devices) + // - left: Sidebar on the left side + // - right: Sidebar on the right side + 'ui.tablet_sidebar_position': 'left', + // === Topic State === // Currently active conversation topic ID // Empty string means no active topic @@ -86,6 +91,7 @@ export const PreferenceDescriptions: Record Date: Wed, 4 Feb 2026 18:22:50 +0800 Subject: [PATCH 13/14] feat: add tablet landscape layout with persistent sidebar Introduce a responsive layout for tablets in landscape mode with a persistent sidebar. The sidebar contains navigation elements and assistant list. Mobile and portrait layouts remain unchanged with drawer navigation. Added useDrawer hook to centralize drawer logic. --- src/App.tsx | 5 +- .../features/ChatScreen/Header/index.tsx | 10 +- .../layout/DrawerGestureWrapper/index.tsx | 13 +- .../layout/TabletSidebar/index.tsx | 162 ++++++++++++++++++ src/hooks/useResponsive.ts | 29 ++-- src/navigators/AppDrawerNavigator.tsx | 126 ++++++++++++-- .../assistant/AssistantMarketScreen.tsx | 10 +- src/screens/assistant/AssistantScreen.tsx | 10 +- src/screens/home/ChatScreen.tsx | 9 +- 9 files changed, 318 insertions(+), 56 deletions(-) create mode 100644 src/componentsV2/layout/TabletSidebar/index.tsx diff --git a/src/App.tsx b/src/App.tsx index b8f960c8..9809028e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,7 +8,7 @@ import { useDrizzleStudio } from 'expo-drizzle-studio-plugin' import { useFonts } from 'expo-font' import * as SplashScreen from 'expo-splash-screen' import { HeroUINativeProvider } from 'heroui-native' -import React, { Suspense, useEffect } from 'react' +import React, { useEffect } from 'react' import { ActivityIndicator } from 'react-native' import { SystemBars } from 'react-native-edge-to-edge' import { GestureHandlerRootView } from 'react-native-gesture-handler' @@ -30,6 +30,7 @@ import { ShortcutCallbackManager } from './aiCore/tools/SystemTools/ShortcutCall import { DialogProvider } from './hooks/useDialog' import { ToastProvider } from './hooks/useToast' import MainStackNavigator from './navigators/MainStackNavigator' +import { navigationRef } from './navigators/navigationRef' import { runAppDataMigrations } from './services/AppInitializationService' // Prevent the splash screen from auto-hiding before asset loading is complete. @@ -103,7 +104,7 @@ function ThemedApp() { return ( - + diff --git a/src/componentsV2/features/ChatScreen/Header/index.tsx b/src/componentsV2/features/ChatScreen/Header/index.tsx index 6503df58..ac1f0737 100644 --- a/src/componentsV2/features/ChatScreen/Header/index.tsx +++ b/src/componentsV2/features/ChatScreen/Header/index.tsx @@ -1,12 +1,10 @@ -import type { DrawerNavigationProp } from '@react-navigation/drawer' -import type { ParamListBase } from '@react-navigation/native' -import { DrawerActions, useNavigation } from '@react-navigation/native' import React from 'react' import { IconButton } from '@/componentsV2/base/IconButton' import { Menu } from '@/componentsV2/icons/LucideIcon' import XStack from '@/componentsV2/layout/XStack' import { useAssistant } from '@/hooks/useAssistant' +import { useDrawer } from '@/hooks/useDrawer' import type { Topic } from '@/types/assistant' import { AssistantSelection } from './AssistantSelection' @@ -18,11 +16,11 @@ interface HeaderBarProps { } export const ChatScreenHeader = ({ topic }: HeaderBarProps) => { - const navigation = useNavigation>() + const { openDrawer, isDrawerAlwaysVisible } = useDrawer() const { assistant, isLoading } = useAssistant(topic.assistantId) const handleMenuPress = () => { - navigation.dispatch(DrawerActions.openDrawer()) + openDrawer() } if (isLoading || !assistant) { @@ -32,7 +30,7 @@ export const ChatScreenHeader = ({ topic }: HeaderBarProps) => { return ( - } /> + {!isDrawerAlwaysVisible && } />} diff --git a/src/componentsV2/layout/DrawerGestureWrapper/index.tsx b/src/componentsV2/layout/DrawerGestureWrapper/index.tsx index 69ef4509..4f685a21 100644 --- a/src/componentsV2/layout/DrawerGestureWrapper/index.tsx +++ b/src/componentsV2/layout/DrawerGestureWrapper/index.tsx @@ -1,9 +1,9 @@ -import type { DrawerNavigationProp } from '@react-navigation/drawer' -import { DrawerActions, useNavigation } from '@react-navigation/native' import type { PropsWithChildren } from 'react' import React from 'react' import { PanGestureHandler, State } from 'react-native-gesture-handler' +import { useDrawer } from '@/hooks/useDrawer' + interface DrawerGestureWrapperProps extends PropsWithChildren { enabled?: boolean } @@ -11,12 +11,13 @@ interface DrawerGestureWrapperProps extends PropsWithChildren { /** * Common wrapper component for handling drawer opening gesture * Swipe right from anywhere on the screen to open the drawer + * In tablet landscape mode, the drawer is always visible so gestures are ignored */ export const DrawerGestureWrapper = ({ children, enabled = true }: DrawerGestureWrapperProps) => { - const navigation = useNavigation>() + const { openDrawer, isDrawerAlwaysVisible } = useDrawer() const handleSwipeGesture = (event: any) => { - if (!enabled) return + if (!enabled || isDrawerAlwaysVisible) return const { translationX, velocityX, state } = event.nativeEvent @@ -28,12 +29,12 @@ export const DrawerGestureWrapper = ({ children, enabled = true }: DrawerGesture const hasExcellentDistance = translationX > 80 if ((hasGoodDistance && hasGoodVelocity) || hasExcellentDistance) { - navigation.dispatch(DrawerActions.openDrawer()) + openDrawer() } } } - if (!enabled) { + if (!enabled || isDrawerAlwaysVisible) { return <>{children} } diff --git a/src/componentsV2/layout/TabletSidebar/index.tsx b/src/componentsV2/layout/TabletSidebar/index.tsx new file mode 100644 index 00000000..841ab8fd --- /dev/null +++ b/src/componentsV2/layout/TabletSidebar/index.tsx @@ -0,0 +1,162 @@ +import { Divider } from 'heroui-native' +import React from 'react' +import { useTranslation } from 'react-i18next' +import { StyleSheet, View } from 'react-native' + +import { IconButton } from '@/componentsV2/base/IconButton' +import Image from '@/componentsV2/base/Image' +import Text from '@/componentsV2/base/Text' +import { AssistantList } from '@/componentsV2/features/Menu/AssistantList' +import { MenuTabContent } from '@/componentsV2/features/Menu/MenuTabContent' +import { MarketIcon, MCPIcon, Settings } from '@/componentsV2/icons' +import PressableRow from '@/componentsV2/layout/PressableRow' +import RowRightArrow from '@/componentsV2/layout/Row/RowRightArrow' +import XStack from '@/componentsV2/layout/XStack' +import YStack from '@/componentsV2/layout/YStack' +import { useAssistants } from '@/hooks/useAssistant' +import { usePreference } from '@/hooks/usePreference' +import { useSafeArea } from '@/hooks/useSafeArea' +import { useSettings } from '@/hooks/useSettings' +import { useTheme } from '@/hooks/useTheme' +import { useCurrentTopic } from '@/hooks/useTopic' +import { navigationRef } from '@/navigators/navigationRef' +import { loggerService } from '@/services/LoggerService' +import { topicService } from '@/services/TopicService' +import type { Assistant } from '@/types/assistant' + +const logger = loggerService.withContext('TabletSidebar') + +const SIDEBAR_WIDTH = 320 + +export function TabletSidebar() { + const { t } = useTranslation() + const { isDark } = useTheme() + const { avatar, userName } = useSettings() + const insets = useSafeArea() + const { switchTopic } = useCurrentTopic() + const [sidebarPosition] = usePreference('ui.tablet_sidebar_position') + const isRightSide = sidebarPosition === 'right' + + const { assistants, isLoading: isAssistantsLoading } = useAssistants() + + const handleNavigateAssistantScreen = () => { + navigationRef.current?.navigate('Assistant', { screen: 'AssistantScreen' }) + } + + const handleNavigateAssistantMarketScreen = () => { + navigationRef.current?.navigate('AssistantMarket', { screen: 'AssistantMarketScreen' }) + } + + const handleNavigateMcpScreen = () => { + navigationRef.current?.navigate('Mcp', { screen: 'McpScreen' }) + } + + const handleNavigateSettingsScreen = () => { + navigationRef.current?.navigate('Home', { screen: 'SettingsScreen' }) + } + + const handleNavigatePersonalScreen = () => { + navigationRef.current?.navigate('Home', { screen: 'AboutSettings', params: { screen: 'PersonalScreen' } }) + } + + const handleNavigateChatScreen = (topicId: string) => { + navigationRef.current?.navigate('Home', { screen: 'ChatScreen', params: { topicId: topicId } }) + } + + const handleAssistantItemPress = async (assistant: Assistant) => { + try { + const assistantTopics = await topicService.getTopicsByAssistantId(assistant.id) + const latestTopic = assistantTopics[0] + + if (latestTopic) { + await switchTopic(latestTopic.id) + handleNavigateChatScreen(latestTopic.id) + return + } + + const newTopic = await topicService.createTopic(assistant) + await switchTopic(newTopic.id) + handleNavigateChatScreen(newTopic.id) + } catch (error) { + logger.error('Failed to open assistant topic from sidebar', error as Error) + } + } + + return ( + + + + + + + {t('assistants.market.title')} + + + + + + + + {t('mcp.server.title')} + + + + + + + + + + + + + + + + + + + + + {userName || t('common.cherry_studio')} + + } onPress={handleNavigateSettingsScreen} style={{ paddingRight: 16 }} /> + + + ) +} + +const styles = StyleSheet.create({ + sidebar: { + width: SIDEBAR_WIDTH, + height: '100%' + }, + sidebarLeft: { + borderRightWidth: StyleSheet.hairlineWidth, + borderRightColor: 'rgba(0,0,0,0.1)' + }, + sidebarRight: { + borderLeftWidth: StyleSheet.hairlineWidth, + borderLeftColor: 'rgba(0,0,0,0.1)' + } +}) diff --git a/src/hooks/useResponsive.ts b/src/hooks/useResponsive.ts index 8a5d2bae..b9b726ea 100644 --- a/src/hooks/useResponsive.ts +++ b/src/hooks/useResponsive.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react' +import { useEffect, useMemo } from 'react' import type { ScaledSize } from 'react-native' import { Dimensions, useWindowDimensions } from 'react-native' @@ -53,19 +53,22 @@ const getOrientation = (width: number, height: number): Orientation => { */ export function useResponsive(): ResponsiveInfo { const { width, height } = useWindowDimensions() - const deviceType = getDeviceType(width, height) - const orientation = getOrientation(width, height) - return { - deviceType, - orientation, - isTablet: deviceType === 'tablet', - isPhone: deviceType === 'phone', - isPortrait: orientation === 'portrait', - isLandscape: orientation === 'landscape', - width, - height - } + return useMemo(() => { + const deviceType = getDeviceType(width, height) + const orientation = getOrientation(width, height) + + return { + deviceType, + orientation, + isTablet: deviceType === 'tablet', + isPhone: deviceType === 'phone', + isPortrait: orientation === 'portrait', + isLandscape: orientation === 'landscape', + width, + height + } + }, [width, height]) } /** diff --git a/src/navigators/AppDrawerNavigator.tsx b/src/navigators/AppDrawerNavigator.tsx index a1d07f96..772722cf 100644 --- a/src/navigators/AppDrawerNavigator.tsx +++ b/src/navigators/AppDrawerNavigator.tsx @@ -4,14 +4,18 @@ import '@/i18n' import type { DrawerNavigationOptions } from '@react-navigation/drawer' import { createDrawerNavigator } from '@react-navigation/drawer' import { getFocusedRouteNameFromRoute, type RouteProp } from '@react-navigation/native' -import React from 'react' +import React, { useMemo } from 'react' +import { StyleSheet, View } from 'react-native' import CustomDrawerContent from '@/componentsV2/features/Menu/CustomDrawerContent' -import AssistantMarketStackNavigator from '@/navigators/AssistantMarketStackNavigator' -import AssistantStackNavigator from '@/navigators/AssistantStackNavigator' -import HomeStackNavigator from '@/navigators/HomeStackNavigator' +import { TabletSidebar } from '@/componentsV2/layout/TabletSidebar' +import { usePreference } from '@/hooks/usePreference' +import { useResponsive } from '@/hooks/useResponsive' import { Width } from '@/utils/device' +import AssistantMarketStackNavigator from './AssistantMarketStackNavigator' +import AssistantStackNavigator from './AssistantStackNavigator' +import HomeStackNavigator from './HomeStackNavigator' import McpStackNavigator from './McpStackNavigator' const Drawer = createDrawerNavigator() @@ -72,18 +76,106 @@ const getMcpScreenOptions = ({ } } -export default function AppDrawerNavigator() { - return ( - } screenOptions={screenOptions}> - {/* Main grouped navigators */} - - - - - - {/* Individual screens for backward compatibility */} - {/* - */} - + + +/** + * 平板横屏双栏布局导航器 + * 使用 DrawerNavigator 但隐藏抽屉UI,通过固定侧边栏控制导航 + */ +function TabletLandscapeNavigator() { + const [sidebarPosition] = usePreference('ui.tablet_sidebar_position') + const isRightSide = sidebarPosition === 'right' + + const drawerNavigator = useMemo( + () => ( + + {/* 固定侧边栏 */} + + + + + {/* 内容区域 - 使用 DrawerNavigator 但隐藏抽屉 */} + + } + screenOptions={{ + ...screenOptions, + // 隐藏抽屉UI + drawerType: 'permanent', + drawerStyle: { width: 0, opacity: 0 }, + swipeEnabled: false + }}> + + + + + + + + ), + [isRightSide] + ) + + return drawerNavigator +} + +/** + * 移动端抽屉导航器 + */ +function MobileDrawerNavigator() { + const drawerNavigator = useMemo( + () => ( + } screenOptions={screenOptions}> + + + + + + ), + [] ) + + return drawerNavigator } + +/** + * 统一的导航器组件 + */ +export default function AppDrawerNavigator() { + const { isTablet, isLandscape } = useResponsive() + const isTabletLandscape = isTablet && isLandscape + + // 平板横屏时:使用双栏布局 + if (isTabletLandscape) { + return + } + + // 移动端或平板竖屏:使用抽屉导航 + return +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + flexDirection: 'row' + }, + containerReversed: { + flexDirection: 'row-reverse' + }, + sidebarContainer: { + width: 320, + height: '100%' + }, + sidebarLeft: { + borderRightWidth: StyleSheet.hairlineWidth, + borderRightColor: 'rgba(0,0,0,0.1)' + }, + sidebarRight: { + borderLeftWidth: StyleSheet.hairlineWidth, + borderLeftColor: 'rgba(0,0,0,0.1)' + }, + content: { + flex: 1, + height: '100%' + } +}) diff --git a/src/screens/assistant/AssistantMarketScreen.tsx b/src/screens/assistant/AssistantMarketScreen.tsx index 4b18d981..114d3b5f 100644 --- a/src/screens/assistant/AssistantMarketScreen.tsx +++ b/src/screens/assistant/AssistantMarketScreen.tsx @@ -1,4 +1,4 @@ -import { DrawerActions, useNavigation } from '@react-navigation/native' +import { useNavigation } from '@react-navigation/native' import React, { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { View } from 'react-native' @@ -15,6 +15,7 @@ import { presentAssistantItemSheet } from '@/componentsV2/features/Assistant/Ass import AssistantsTabContent from '@/componentsV2/features/Assistant/AssistantsTabContent' import { Menu } from '@/componentsV2/icons' import { useBuiltInAssistants } from '@/hooks/useAssistant' +import { useDrawer } from '@/hooks/useDrawer' import { useSearch } from '@/hooks/useSearch' import { useSkeletonLoading } from '@/hooks/useSkeletonLoading' import type { Assistant } from '@/types/assistant' @@ -23,6 +24,7 @@ import type { DrawerNavigationProps } from '@/types/naviagate' export default function AssistantMarketScreen() { const { t } = useTranslation() const navigation = useNavigation() + const { openDrawer, isDrawerAlwaysVisible } = useDrawer() const { assistants: builtInAssistants } = useBuiltInAssistants() const { @@ -38,7 +40,7 @@ export default function AssistantMarketScreen() { const showSkeleton = useSkeletonLoading(isLoading) const handleMenuPress = () => { - navigation.dispatch(DrawerActions.openDrawer()) + openDrawer() } const onChatNavigation = async (topicId: string) => { @@ -59,10 +61,10 @@ export default function AssistantMarketScreen() { , onPress: handleMenuPress - }} + } : undefined} /> () + const { openDrawer, isDrawerAlwaysVisible } = useDrawer() const toast = useToast() const { assistants, isLoading } = useAssistants() @@ -81,7 +83,7 @@ export default function AssistantScreen() { } const handleMenuPress = () => { - navigation.dispatch(DrawerActions.openDrawer()) + openDrawer() } const handleEnterMultiSelectMode = useCallback((assistantId: string) => { @@ -184,10 +186,10 @@ export default function AssistantScreen() { ) : ( , onPress: handleMenuPress - }} + } : undefined} rightButtons={[ { icon: , diff --git a/src/screens/home/ChatScreen.tsx b/src/screens/home/ChatScreen.tsx index e2db234b..1febf6c7 100644 --- a/src/screens/home/ChatScreen.tsx +++ b/src/screens/home/ChatScreen.tsx @@ -1,5 +1,4 @@ -import type { DrawerNavigationProp } from '@react-navigation/drawer' -import { DrawerActions, useNavigation } from '@react-navigation/native' +import { useNavigation } from '@react-navigation/native' import type { StackNavigationProp } from '@react-navigation/stack' import React from 'react' import { ActivityIndicator, Platform, View } from 'react-native' @@ -13,6 +12,7 @@ import { MessageInputContainer } from '@/componentsV2/features/ChatScreen/Messag import { CitationSheet } from '@/componentsV2/features/Sheet/CitationSheet' import { useAssistant } from '@/hooks/useAssistant' import { useBottom } from '@/hooks/useBottom' +import { useDrawer } from '@/hooks/useDrawer' import { usePreference } from '@/hooks/usePreference' import { useCurrentTopic } from '@/hooks/useTopic' import type { HomeStackParamList } from '@/navigators/HomeStackNavigator' @@ -21,11 +21,12 @@ import ChatContent from './ChatContent' KeyboardController.preload() -type ChatScreenNavigationProp = DrawerNavigationProp & StackNavigationProp +type ChatScreenNavigationProp = StackNavigationProp const ChatScreen = () => { const insets = useSafeAreaInsets() const navigation = useNavigation() + const { openDrawer } = useDrawer() const [topicId] = usePreference('topic.current_id') const { currentTopic } = useCurrentTopic() @@ -44,7 +45,7 @@ const ChatScreen = () => { const hasExcellentDistance = translationX > 80 if ((hasGoodDistance && hasGoodVelocity) || hasExcellentDistance) { - navigation.dispatch(DrawerActions.openDrawer()) + openDrawer() } } // 左滑 → 跳转到 TopicScreen From b9fc2b596a6d7d6ffb911f62b9053dde762c8dcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=9F=8E=E5=9F=8E?= Date: Wed, 4 Feb 2026 18:25:17 +0800 Subject: [PATCH 14/14] feat: add navigation cleanup and optimize useResponsive hook Add cleanup for navigation ref to prevent memory leaks Replace useMemo with direct computation in useResponsive hook Update navigationRef to use mutable ref object --- src/App.tsx | 9 ++++++++- src/hooks/useResponsive.ts | 29 ++++++++++++++--------------- src/navigators/navigationRef.ts | 25 +++++++++++++++++++++---- 3 files changed, 43 insertions(+), 20 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 9809028e..62874b54 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -30,7 +30,7 @@ import { ShortcutCallbackManager } from './aiCore/tools/SystemTools/ShortcutCall import { DialogProvider } from './hooks/useDialog' import { ToastProvider } from './hooks/useToast' import MainStackNavigator from './navigators/MainStackNavigator' -import { navigationRef } from './navigators/navigationRef' +import { navigationRef, resetNavigationRef } from './navigators/navigationRef' import { runAppDataMigrations } from './services/AppInitializationService' // Prevent the splash screen from auto-hiding before asset loading is complete. @@ -101,6 +101,13 @@ function ThemedApp() { Uniwind.setTheme(isDark ? 'dark' : 'light') }, [isDark]) + // Cleanup navigation ref on unmount to prevent memory leaks + useEffect(() => { + return () => { + resetNavigationRef() + } + }, []) + return ( diff --git a/src/hooks/useResponsive.ts b/src/hooks/useResponsive.ts index b9b726ea..82065696 100644 --- a/src/hooks/useResponsive.ts +++ b/src/hooks/useResponsive.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from 'react' +import { useEffect } from 'react' import type { ScaledSize } from 'react-native' import { Dimensions, useWindowDimensions } from 'react-native' @@ -54,21 +54,20 @@ const getOrientation = (width: number, height: number): Orientation => { export function useResponsive(): ResponsiveInfo { const { width, height } = useWindowDimensions() - return useMemo(() => { - const deviceType = getDeviceType(width, height) - const orientation = getOrientation(width, height) + // Direct computation - useMemo not needed for cheap calculations + const deviceType = getDeviceType(width, height) + const orientation = getOrientation(width, height) - return { - deviceType, - orientation, - isTablet: deviceType === 'tablet', - isPhone: deviceType === 'phone', - isPortrait: orientation === 'portrait', - isLandscape: orientation === 'landscape', - width, - height - } - }, [width, height]) + return { + deviceType, + orientation, + isTablet: deviceType === 'tablet', + isPhone: deviceType === 'phone', + isPortrait: orientation === 'portrait', + isLandscape: orientation === 'landscape', + width, + height + } } /** diff --git a/src/navigators/navigationRef.ts b/src/navigators/navigationRef.ts index 7ee04dab..bc3f7e89 100644 --- a/src/navigators/navigationRef.ts +++ b/src/navigators/navigationRef.ts @@ -1,16 +1,33 @@ import type { NavigationContainerRef, ParamListBase } from '@react-navigation/native' -import { createRef } from 'react' +// Use a mutable ref object instead of createRef to avoid potential memory leaks +// and allow proper cleanup when NavigationContainer unmounts +export const navigationRef: { current: NavigationContainerRef | null } = { + current: null +} -export const navigationRef = createRef>() - +/** + * Check if navigation is ready (NavigationContainer is mounted) + */ export function isNavigationReady(): boolean { return navigationRef.current !== null && navigationRef.current !== undefined } - +/** + * Safely navigate to a screen + * @param name - Screen name + * @param params - Navigation params + */ export function safeNavigate(name: string, params?: object): void { if (isNavigationReady()) { navigationRef.current?.navigate(name, params) } } + +/** + * Reset the navigation ref (call this when NavigationContainer unmounts) + * This prevents memory leaks by releasing the reference + */ +export function resetNavigationRef(): void { + navigationRef.current = null +}