Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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, resetNavigationRef } from './navigators/navigationRef'
import { runAppDataMigrations } from './services/AppInitializationService'

// Prevent the splash screen from auto-hiding before asset loading is complete.
Expand Down Expand Up @@ -100,10 +101,17 @@ function ThemedApp() {
Uniwind.setTheme(isDark ? 'dark' : 'light')
}, [isDark])

// Cleanup navigation ref on unmount to prevent memory leaks
useEffect(() => {
return () => {
resetNavigationRef()
}
}, [])

return (
<HeroUINativeProvider>
<KeyboardProvider>
<NavigationContainer theme={isDark ? DarkTheme : DefaultTheme}>
<NavigationContainer ref={navigationRef} theme={isDark ? DarkTheme : DefaultTheme}>
<SystemBars style={isDark ? 'light' : 'dark'} />
<DialogProvider>
<ToastProvider>
Expand Down
10 changes: 4 additions & 6 deletions src/componentsV2/features/ChatScreen/Header/index.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -18,11 +16,11 @@ interface HeaderBarProps {
}

export const ChatScreenHeader = ({ topic }: HeaderBarProps) => {
const navigation = useNavigation<DrawerNavigationProp<ParamListBase>>()
const { openDrawer, isDrawerAlwaysVisible } = useDrawer()
const { assistant, isLoading } = useAssistant(topic.assistantId)

const handleMenuPress = () => {
navigation.dispatch(DrawerActions.openDrawer())
openDrawer()
}

if (isLoading || !assistant) {
Expand All @@ -32,7 +30,7 @@ export const ChatScreenHeader = ({ topic }: HeaderBarProps) => {
return (
<XStack className="relative h-11 items-center justify-between px-3.5">
<XStack className="min-w-10 items-center">
<IconButton onPress={handleMenuPress} icon={<Menu size={24} />} />
{!isDrawerAlwaysVisible && <IconButton onPress={handleMenuPress} icon={<Menu size={24} />} />}
</XStack>
<XStack className="min-w-10 items-center justify-end gap-4">
<NewTopicButton assistant={assistant} />
Expand Down
Original file line number Diff line number Diff line change
@@ -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: <PanelLeft size={18} /> },
{ value: 'right', labelKey: 'settings.general.tablet_sidebar.right', icon: <PanelRight size={18} /> }
]

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 (
<SelectionDropdown items={positionDropdownOptions}>
<Pressable className="bg-card flex-row items-center gap-2 rounded-xl active:opacity-80">
<Text className="text-foreground-secondary text-sm" numberOfLines={1}>
{getCurrentPositionLabel()}
</Text>
<ChevronsUpDown size={16} className="text-foreground-secondary" />
</Pressable>
</SelectionDropdown>
)
}
6 changes: 6 additions & 0 deletions src/componentsV2/icons/LucideIcon/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ import {
MoreHorizontal,
Package,
Palette,
PanelLeft,
PanelRight,
PenLine,
Plus,
Radio,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 7 additions & 6 deletions src/componentsV2/layout/DrawerGestureWrapper/index.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
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
}

/**
* 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<DrawerNavigationProp<any>>()
const { openDrawer, isDrawerAlwaysVisible } = useDrawer()

const handleSwipeGesture = (event: any) => {
if (!enabled) return
if (!enabled || isDrawerAlwaysVisible) return

const { translationX, velocityX, state } = event.nativeEvent

Expand All @@ -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}</>
}

Expand Down
162 changes: 162 additions & 0 deletions src/componentsV2/layout/TabletSidebar/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View
style={[
styles.sidebar,
isRightSide ? styles.sidebarRight : styles.sidebarLeft,
{
paddingTop: insets.top,
paddingBottom: insets.bottom,
backgroundColor: isDark ? '#121213' : '#f7f7f7'
}
]}>
<YStack className="flex-1 gap-2.5">
<YStack className="gap-1.5 px-2.5">
<PressableRow
className="flex-row items-center justify-between rounded-lg px-2.5 py-2.5"
onPress={handleNavigateAssistantMarketScreen}>
<XStack className="items-center justify-center gap-2.5">
<MarketIcon size={24} />
<Text className="text-base">{t('assistants.market.title')}</Text>
</XStack>
<RowRightArrow />
</PressableRow>

<PressableRow
className="flex-row items-center justify-between rounded-lg px-2.5 py-2.5"
onPress={handleNavigateMcpScreen}>
<XStack className="items-center justify-center gap-2.5">
<MCPIcon size={24} />
<Text className="text-base">{t('mcp.server.title')}</Text>
</XStack>
<RowRightArrow />
</PressableRow>
<YStack className="px-2.5">
<Divider />
</YStack>
</YStack>

<MenuTabContent title={t('assistants.title.mine')} onSeeAllPress={handleNavigateAssistantScreen}>
<AssistantList
assistants={assistants}
isLoading={isAssistantsLoading}
onAssistantPress={handleAssistantItemPress}
/>
</MenuTabContent>
</YStack>

<YStack className="px-5 pb-2.5">
<Divider />
</YStack>

<XStack className="items-center justify-between">
<PressableRow className="items-center gap-2.5" onPress={handleNavigatePersonalScreen}>
<Image
className="h-12 w-12 rounded-full"
source={avatar ? { uri: avatar } : require('@/assets/images/favicon.png')}
/>
<Text className="text-base">{userName || t('common.cherry_studio')}</Text>
</PressableRow>
<IconButton icon={<Settings size={24} />} onPress={handleNavigateSettingsScreen} style={{ paddingRight: 16 }} />
</XStack>
</View>
)
}

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)'
}
})
Loading