refactor: convert navigation to type-safe routes with polymorphic serialization

This commit is contained in:
Garfie 2026-04-03 20:10:52 -05:00
parent 9be9a57b9b
commit 2756efa812
16 changed files with 412 additions and 278 deletions

View file

@ -2,6 +2,7 @@ plugins {
id("librechat.mobile.application") id("librechat.mobile.application")
id("librechat.mobile.compose") id("librechat.mobile.compose")
id("librechat.mobile.koin") id("librechat.mobile.koin")
id("librechat.kotlin.serialization")
} }
android { android {

View file

@ -1,8 +1,11 @@
package com.garfiec.librechat.navigation package com.garfiec.librechat.navigation
import kotlinx.serialization.modules.SerializersModule
import org.koin.core.module.dsl.viewModelOf import org.koin.core.module.dsl.viewModelOf
import org.koin.core.qualifier.named
import org.koin.dsl.module import org.koin.dsl.module
val appModule = module { val appModule = module {
viewModelOf(::NavHostViewModel) viewModelOf(::NavHostViewModel)
single<SerializersModule>(named("navigation")) { navigationSerializersModule }
} }

View file

@ -32,42 +32,58 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavDestination
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.toRoute
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import kotlin.reflect.KClass
import com.garfiec.librechat.MainActivity import com.garfiec.librechat.MainActivity
import com.garfiec.librechat.R import com.garfiec.librechat.R
import com.garfiec.librechat.core.ui.components.BannerDisplay import com.garfiec.librechat.core.ui.components.BannerDisplay
import com.garfiec.librechat.feature.agents.navigation.AGENT_EDITOR_CREATE_ROUTE import com.garfiec.librechat.feature.agents.navigation.AgentDetail
import com.garfiec.librechat.feature.agents.navigation.AgentEditorCreate
import com.garfiec.librechat.feature.agents.navigation.AgentEditorEdit
import com.garfiec.librechat.feature.agents.navigation.AgentMarketplace
import com.garfiec.librechat.feature.agents.navigation.agentsGraph import com.garfiec.librechat.feature.agents.navigation.agentsGraph
import com.garfiec.librechat.feature.auth.navigation.AUTH_GRAPH_ROUTE import com.garfiec.librechat.feature.auth.navigation.AuthRoute
import com.garfiec.librechat.feature.auth.navigation.ServerUrl
import com.garfiec.librechat.feature.auth.navigation.authGraph import com.garfiec.librechat.feature.auth.navigation.authGraph
import com.garfiec.librechat.feature.chat.navigation.CHAT_GRAPH_ROUTE import com.garfiec.librechat.feature.chat.navigation.Chat
import com.garfiec.librechat.feature.chat.navigation.CHAT_ROUTE import com.garfiec.librechat.feature.chat.navigation.ChatRoute
import com.garfiec.librechat.feature.chat.navigation.NEW_CHAT_ROUTE import com.garfiec.librechat.feature.chat.navigation.NewChat
import com.garfiec.librechat.feature.chat.navigation.chatGraph import com.garfiec.librechat.feature.chat.navigation.chatGraph
import com.garfiec.librechat.feature.chat.navigation.navigateToChat import com.garfiec.librechat.feature.chat.navigation.navigateToChat
import com.garfiec.librechat.feature.conversations.navigation.ArchivedConversations
import com.garfiec.librechat.feature.conversations.navigation.conversationsGraph import com.garfiec.librechat.feature.conversations.navigation.conversationsGraph
import com.garfiec.librechat.feature.files.navigation.Files
import com.garfiec.librechat.feature.files.navigation.filesGraph import com.garfiec.librechat.feature.files.navigation.filesGraph
import com.garfiec.librechat.feature.settings.navigation.API_KEYS_ROUTE import com.garfiec.librechat.feature.settings.navigation.ApiKeys
import com.garfiec.librechat.feature.settings.navigation.PRESET_MANAGER_ROUTE import com.garfiec.librechat.feature.settings.navigation.PresetManager
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_ACCOUNT_ROUTE import com.garfiec.librechat.feature.settings.navigation.SettingsAccount
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_CHAT_ROUTE import com.garfiec.librechat.feature.settings.navigation.SettingsChat
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_DATA_ROUTE import com.garfiec.librechat.feature.settings.navigation.SettingsData
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_GENERAL_ROUTE import com.garfiec.librechat.feature.settings.navigation.SettingsGeneral
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_TABBED_ROUTE import com.garfiec.librechat.feature.settings.navigation.SettingsRoute
import com.garfiec.librechat.feature.settings.navigation.SHARED_LINKS_ROUTE import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed
import com.garfiec.librechat.feature.settings.navigation.SharedLinks
import com.garfiec.librechat.feature.settings.navigation.settingsGraph import com.garfiec.librechat.feature.settings.navigation.settingsGraph
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
/** Maps a [SettingsCategory] to its corresponding navigation route. */ /** Checks if this destination's route matches the given typed route class. */
fun SettingsCategory.toRoute(): String = when (this) { private fun NavDestination?.isRoute(routeClass: KClass<*>): Boolean {
SettingsCategory.GENERAL -> SETTINGS_GENERAL_ROUTE val qualifiedName = routeClass.qualifiedName ?: return false
SettingsCategory.CHAT -> SETTINGS_CHAT_ROUTE return this?.route?.startsWith(qualifiedName) == true
SettingsCategory.ACCOUNT -> SETTINGS_ACCOUNT_ROUTE }
SettingsCategory.DATA -> SETTINGS_DATA_ROUTE
/** Maps a [SettingsCategory] to its corresponding typed navigation route. */
fun SettingsCategory.toRoute(): SettingsRoute = when (this) {
SettingsCategory.GENERAL -> SettingsGeneral
SettingsCategory.CHAT -> SettingsChat
SettingsCategory.ACCOUNT -> SettingsAccount
SettingsCategory.DATA -> SettingsData
} }
@Composable @Composable
@ -85,14 +101,13 @@ fun LibreChatNavHost(
val isLoggedIn by navHostViewModel.isLoggedIn.collectAsStateWithLifecycle() val isLoggedIn by navHostViewModel.isLoggedIn.collectAsStateWithLifecycle()
val isInAuthFlow = currentDestination?.route == AUTH_GRAPH_ROUTE || val isInAuthFlow = currentDestination.isRoute(AuthRoute::class) ||
currentDestination?.parent?.route == AUTH_GRAPH_ROUTE currentDestination?.parent.isRoute(AuthRoute::class)
// Track active conversation from nav back stack // Track active conversation from nav back stack
LaunchedEffect(navBackStackEntry) { LaunchedEffect(navBackStackEntry) {
val route = navBackStackEntry?.destination?.route val conversationId = if (navBackStackEntry?.destination.isRoute(Chat::class)) {
val conversationId = if (route == CHAT_ROUTE) { navBackStackEntry?.toRoute<Chat>()?.conversationId
navBackStackEntry?.arguments?.getString("conversationId")
} else { } else {
null null
} }
@ -102,13 +117,13 @@ fun LibreChatNavHost(
// Handle session expiry // Handle session expiry
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
navHostViewModel.sessionExpired.collect { navHostViewModel.sessionExpired.collect {
navController.navigate(AUTH_GRAPH_ROUTE) { navController.navigate(ServerUrl) {
popUpTo(0) { inclusive = true } popUpTo(0) { inclusive = true }
} }
} }
} }
val startDestination = if (isLoggedIn) CHAT_GRAPH_ROUTE else AUTH_GRAPH_ROUTE val startDestination: Any = if (isLoggedIn) ChatRoute::class else AuthRoute::class
val isTablet = windowSizeClass?.widthSizeClass?.let { val isTablet = windowSizeClass?.widthSizeClass?.let {
it >= WindowWidthSizeClass.Medium it >= WindowWidthSizeClass.Medium
@ -193,7 +208,7 @@ private fun VersionMismatchDialog(
private fun PhoneLayout( private fun PhoneLayout(
navController: androidx.navigation.NavHostController, navController: androidx.navigation.NavHostController,
navHostViewModel: NavHostViewModel, navHostViewModel: NavHostViewModel,
startDestination: String, startDestination: Any,
isInAuthFlow: Boolean, isInAuthFlow: Boolean,
deepLinkUri: Uri?, deepLinkUri: Uri?,
onDeepLinkConsumed: () -> Unit, onDeepLinkConsumed: () -> Unit,
@ -227,11 +242,12 @@ private fun PhoneLayout(
// non-chat screen (e.g. settings, agents, files). // non-chat screen (e.g. settings, agents, files).
LaunchedEffect(shareNavigationTrigger) { LaunchedEffect(shareNavigationTrigger) {
if (shareNavigationTrigger > 0) { if (shareNavigationTrigger > 0) {
val currentRoute = navController.currentBackStackEntry?.destination?.route val currentDest = navController.currentBackStackEntry?.destination
val isOnChatScreen = currentRoute == CHAT_ROUTE || currentRoute == NEW_CHAT_ROUTE val isOnChatScreen = currentDest.isRoute(Chat::class) ||
currentDest.isRoute(NewChat::class)
if (!isOnChatScreen) { if (!isOnChatScreen) {
navController.navigate(NEW_CHAT_ROUTE) { navController.navigate(NewChat) {
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false } popUpTo<ChatRoute> { inclusive = false }
launchSingleTop = true launchSingleTop = true
} }
} }
@ -257,10 +273,10 @@ private fun PhoneLayout(
onNewChat = { onNewChat = {
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
// Skip navigation if already on the new chat screen // Skip navigation if already on the new chat screen
val currentRoute = navController.currentBackStackEntry?.destination?.route val currentDest = navController.currentBackStackEntry?.destination
if (currentRoute != NEW_CHAT_ROUTE) { if (!currentDest.isRoute(NewChat::class)) {
navController.navigate(NEW_CHAT_ROUTE) { navController.navigate(NewChat) {
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false } popUpTo<ChatRoute> { inclusive = false }
launchSingleTop = true launchSingleTop = true
} }
} }
@ -271,7 +287,7 @@ private fun PhoneLayout(
}, },
onSettingsClick = { onSettingsClick = {
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
navController.navigate(SETTINGS_TABBED_ROUTE) { navController.navigate(SettingsTabbed) {
launchSingleTop = true launchSingleTop = true
} }
}, },
@ -283,13 +299,13 @@ private fun PhoneLayout(
}, },
onAgentsClick = { onAgentsClick = {
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
navController.navigate(TopLevelDestination.AGENTS.route) { navController.navigate(AgentMarketplace) {
launchSingleTop = true launchSingleTop = true
} }
}, },
onFilesClick = { onFilesClick = {
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
navController.navigate(TopLevelDestination.FILES.route) { navController.navigate(Files) {
launchSingleTop = true launchSingleTop = true
} }
}, },
@ -343,8 +359,8 @@ private fun PhoneLayout(
navController = navController, navController = navController,
onAuthComplete = { onAuthComplete = {
navHostViewModel.onAuthComplete() navHostViewModel.onAuthComplete()
navController.navigate(CHAT_GRAPH_ROUTE) { navController.navigate(NewChat) {
popUpTo(AUTH_GRAPH_ROUTE) { inclusive = true } popUpTo<AuthRoute> { inclusive = true }
} }
}, },
) )
@ -359,7 +375,7 @@ private fun PhoneLayout(
navController.navigateToChat(conversationId) navController.navigateToChat(conversationId)
}, },
onNavigateToArchived = { onNavigateToArchived = {
navController.navigate("conversations/archived") navController.navigate(ArchivedConversations)
}, },
onNavigateBackFromArchived = { onNavigateBackFromArchived = {
navController.popBackStack() navController.popBackStack()
@ -367,38 +383,38 @@ private fun PhoneLayout(
) )
agentsGraph( agentsGraph(
onAgentClick = { agentId -> onAgentClick = { agentId ->
navController.navigate("agents/$agentId") navController.navigate(AgentDetail(agentId = agentId))
}, },
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
onStartChat = { agentId -> onStartChat = { agentId ->
navController.navigate(NEW_CHAT_ROUTE) { navController.navigate(NewChat) {
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false } popUpTo<ChatRoute> { inclusive = false }
launchSingleTop = true launchSingleTop = true
} }
}, },
onCreateAgent = { onCreateAgent = {
navController.navigate(AGENT_EDITOR_CREATE_ROUTE) navController.navigate(AgentEditorCreate)
}, },
onEditAgent = { agentId -> onEditAgent = { agentId ->
navController.navigate("agents/editor/$agentId") navController.navigate(AgentEditorEdit(agentId = agentId))
}, },
) )
filesGraph() filesGraph()
settingsGraph( settingsGraph(
onLogout = { onLogout = {
navHostViewModel.logout() navHostViewModel.logout()
navController.navigate(AUTH_GRAPH_ROUTE) { navController.navigate(ServerUrl) {
popUpTo(0) { inclusive = true } popUpTo(0) { inclusive = true }
} }
}, },
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
onNavigateToArchived = { onNavigateToArchived = {
navController.navigate("conversations/archived") { navController.navigate(ArchivedConversations) {
launchSingleTop = true launchSingleTop = true
} }
}, },
onNavigateToSharedLinks = { onNavigateToSharedLinks = {
navController.navigate(SHARED_LINKS_ROUTE) { navController.navigate(SharedLinks) {
launchSingleTop = true launchSingleTop = true
} }
}, },
@ -406,7 +422,7 @@ private fun PhoneLayout(
navController.popBackStack() navController.popBackStack()
}, },
onNavigateToPresets = { onNavigateToPresets = {
navController.navigate(PRESET_MANAGER_ROUTE) { navController.navigate(PresetManager) {
launchSingleTop = true launchSingleTop = true
} }
}, },
@ -414,7 +430,7 @@ private fun PhoneLayout(
navController.popBackStack() navController.popBackStack()
}, },
onNavigateToApiKeys = { onNavigateToApiKeys = {
navController.navigate(API_KEYS_ROUTE) { navController.navigate(ApiKeys) {
launchSingleTop = true launchSingleTop = true
} }
}, },

View file

@ -0,0 +1,23 @@
package com.garfiec.librechat.navigation
import com.garfiec.librechat.feature.agents.navigation.agentsSerializersModule
import com.garfiec.librechat.feature.auth.navigation.authSerializersModule
import com.garfiec.librechat.feature.chat.navigation.chatSerializersModule
import com.garfiec.librechat.feature.conversations.navigation.conversationsSerializersModule
import com.garfiec.librechat.feature.files.navigation.filesSerializersModule
import com.garfiec.librechat.feature.settings.navigation.settingsSerializersModule
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.plus
/**
* Combined [SerializersModule] for all navigation route types.
* Registers polymorphic serializers for each feature module's sealed route hierarchy.
* This will be used by Nav 3's SavedStateConfiguration for type-safe state saving.
*/
val navigationSerializersModule: SerializersModule =
authSerializersModule +
chatSerializersModule +
conversationsSerializersModule +
agentsSerializersModule +
filesSerializersModule +
settingsSerializersModule

View file

@ -37,29 +37,43 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavDestination
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import kotlin.reflect.KClass
import com.garfiec.librechat.MainActivity import com.garfiec.librechat.MainActivity
import com.garfiec.librechat.core.ui.components.BannerDisplay import com.garfiec.librechat.core.ui.components.BannerDisplay
import com.garfiec.librechat.feature.agents.navigation.AGENT_EDITOR_CREATE_ROUTE import com.garfiec.librechat.feature.agents.navigation.AgentDetail
import com.garfiec.librechat.feature.agents.navigation.AgentEditorCreate
import com.garfiec.librechat.feature.agents.navigation.AgentEditorEdit
import com.garfiec.librechat.feature.agents.navigation.AgentMarketplace
import com.garfiec.librechat.feature.agents.navigation.agentsGraph import com.garfiec.librechat.feature.agents.navigation.agentsGraph
import com.garfiec.librechat.feature.auth.navigation.AUTH_GRAPH_ROUTE import com.garfiec.librechat.feature.auth.navigation.AuthRoute
import com.garfiec.librechat.feature.auth.navigation.ServerUrl
import com.garfiec.librechat.feature.auth.navigation.authGraph import com.garfiec.librechat.feature.auth.navigation.authGraph
import com.garfiec.librechat.feature.chat.navigation.CHAT_GRAPH_ROUTE import com.garfiec.librechat.feature.chat.navigation.Chat
import com.garfiec.librechat.feature.chat.navigation.CHAT_ROUTE import com.garfiec.librechat.feature.chat.navigation.ChatRoute
import com.garfiec.librechat.feature.chat.navigation.NEW_CHAT_ROUTE import com.garfiec.librechat.feature.chat.navigation.NewChat
import com.garfiec.librechat.feature.chat.navigation.chatGraph import com.garfiec.librechat.feature.chat.navigation.chatGraph
import com.garfiec.librechat.feature.chat.navigation.navigateToChat import com.garfiec.librechat.feature.chat.navigation.navigateToChat
import com.garfiec.librechat.feature.conversations.navigation.ArchivedConversations
import com.garfiec.librechat.feature.conversations.navigation.conversationsGraph import com.garfiec.librechat.feature.conversations.navigation.conversationsGraph
import com.garfiec.librechat.feature.files.navigation.Files
import com.garfiec.librechat.feature.files.navigation.filesGraph import com.garfiec.librechat.feature.files.navigation.filesGraph
import com.garfiec.librechat.feature.settings.navigation.API_KEYS_ROUTE import com.garfiec.librechat.feature.settings.navigation.ApiKeys
import com.garfiec.librechat.feature.settings.navigation.PRESET_MANAGER_ROUTE import com.garfiec.librechat.feature.settings.navigation.PresetManager
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_TABBED_ROUTE import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed
import com.garfiec.librechat.feature.settings.navigation.SHARED_LINKS_ROUTE import com.garfiec.librechat.feature.settings.navigation.SharedLinks
import com.garfiec.librechat.feature.settings.navigation.settingsGraph import com.garfiec.librechat.feature.settings.navigation.settingsGraph
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
/** Checks if this destination's route matches the given typed route class. */
private fun NavDestination?.isRoute(routeClass: KClass<*>): Boolean {
val qualifiedName = routeClass.qualifiedName ?: return false
return this?.route?.startsWith(qualifiedName) == true
}
private val SidebarWidth = 320.dp private val SidebarWidth = 320.dp
/** Velocity (px/s) above which a fling commits the gesture regardless of position. */ /** Velocity (px/s) above which a fling commits the gesture regardless of position. */
@ -69,7 +83,7 @@ private const val FLING_VELOCITY_THRESHOLD = 800f
fun TabletLayout( fun TabletLayout(
navController: NavHostController, navController: NavHostController,
navHostViewModel: NavHostViewModel, navHostViewModel: NavHostViewModel,
startDestination: String, startDestination: Any,
isInAuthFlow: Boolean, isInAuthFlow: Boolean,
deepLinkUri: Uri?, deepLinkUri: Uri?,
onDeepLinkConsumed: () -> Unit, onDeepLinkConsumed: () -> Unit,
@ -122,11 +136,12 @@ fun TabletLayout(
// non-chat screen (e.g. settings, agents, files). // non-chat screen (e.g. settings, agents, files).
LaunchedEffect(shareNavigationTrigger) { LaunchedEffect(shareNavigationTrigger) {
if (shareNavigationTrigger > 0) { if (shareNavigationTrigger > 0) {
val currentRoute = navController.currentBackStackEntry?.destination?.route val currentDest = navController.currentBackStackEntry?.destination
val isOnChatScreen = currentRoute == CHAT_ROUTE || currentRoute == NEW_CHAT_ROUTE val isOnChatScreen = currentDest.isRoute(Chat::class) ||
currentDest.isRoute(NewChat::class)
if (!isOnChatScreen) { if (!isOnChatScreen) {
navController.navigate(NEW_CHAT_ROUTE) { navController.navigate(NewChat) {
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false } popUpTo<ChatRoute> { inclusive = false }
launchSingleTop = true launchSingleTop = true
} }
} }
@ -215,10 +230,10 @@ fun TabletLayout(
viewModel = navHostViewModel, viewModel = navHostViewModel,
onNewChat = { onNewChat = {
// Skip navigation if already on the new chat screen // Skip navigation if already on the new chat screen
val currentRoute = navController.currentBackStackEntry?.destination?.route val currentDest = navController.currentBackStackEntry?.destination
if (currentRoute != NEW_CHAT_ROUTE) { if (!currentDest.isRoute(NewChat::class)) {
navController.navigate(NEW_CHAT_ROUTE) { navController.navigate(NewChat) {
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false } popUpTo<ChatRoute> { inclusive = false }
launchSingleTop = true launchSingleTop = true
} }
} }
@ -227,7 +242,7 @@ fun TabletLayout(
navController.navigateToChat(conversationId) navController.navigateToChat(conversationId)
}, },
onSettingsClick = { onSettingsClick = {
navController.navigate(SETTINGS_TABBED_ROUTE) { navController.navigate(SettingsTabbed) {
launchSingleTop = true launchSingleTop = true
} }
}, },
@ -237,12 +252,12 @@ fun TabletLayout(
} }
}, },
onAgentsClick = { onAgentsClick = {
navController.navigate(TopLevelDestination.AGENTS.route) { navController.navigate(AgentMarketplace) {
launchSingleTop = true launchSingleTop = true
} }
}, },
onFilesClick = { onFilesClick = {
navController.navigate(TopLevelDestination.FILES.route) { navController.navigate(Files) {
launchSingleTop = true launchSingleTop = true
} }
}, },
@ -308,7 +323,7 @@ fun TabletLayout(
private fun MainContent( private fun MainContent(
navController: NavHostController, navController: NavHostController,
navHostViewModel: NavHostViewModel, navHostViewModel: NavHostViewModel,
startDestination: String, startDestination: Any,
isInAuthFlow: Boolean, isInAuthFlow: Boolean,
banners: List<com.garfiec.librechat.core.model.Banner>, banners: List<com.garfiec.librechat.core.model.Banner>,
dismissedBannerIds: Set<String>, dismissedBannerIds: Set<String>,
@ -360,8 +375,8 @@ private fun MainContent(
navController = navController, navController = navController,
onAuthComplete = { onAuthComplete = {
navHostViewModel.onAuthComplete() navHostViewModel.onAuthComplete()
navController.navigate(CHAT_GRAPH_ROUTE) { navController.navigate(NewChat) {
popUpTo(AUTH_GRAPH_ROUTE) { inclusive = true } popUpTo<AuthRoute> { inclusive = true }
} }
}, },
) )
@ -374,7 +389,7 @@ private fun MainContent(
navController.navigateToChat(conversationId) navController.navigateToChat(conversationId)
}, },
onNavigateToArchived = { onNavigateToArchived = {
navController.navigate("conversations/archived") navController.navigate(ArchivedConversations)
}, },
onNavigateBackFromArchived = { onNavigateBackFromArchived = {
navController.popBackStack() navController.popBackStack()
@ -382,38 +397,38 @@ private fun MainContent(
) )
agentsGraph( agentsGraph(
onAgentClick = { agentId -> onAgentClick = { agentId ->
navController.navigate("agents/$agentId") navController.navigate(AgentDetail(agentId = agentId))
}, },
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
onStartChat = { agentId -> onStartChat = { agentId ->
navController.navigate(NEW_CHAT_ROUTE) { navController.navigate(NewChat) {
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false } popUpTo<ChatRoute> { inclusive = false }
launchSingleTop = true launchSingleTop = true
} }
}, },
onCreateAgent = { onCreateAgent = {
navController.navigate(AGENT_EDITOR_CREATE_ROUTE) navController.navigate(AgentEditorCreate)
}, },
onEditAgent = { agentId -> onEditAgent = { agentId ->
navController.navigate("agents/editor/$agentId") navController.navigate(AgentEditorEdit(agentId = agentId))
}, },
) )
filesGraph() filesGraph()
settingsGraph( settingsGraph(
onLogout = { onLogout = {
navHostViewModel.logout() navHostViewModel.logout()
navController.navigate(AUTH_GRAPH_ROUTE) { navController.navigate(ServerUrl) {
popUpTo(0) { inclusive = true } popUpTo(0) { inclusive = true }
} }
}, },
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
onNavigateToArchived = { onNavigateToArchived = {
navController.navigate("conversations/archived") { navController.navigate(ArchivedConversations) {
launchSingleTop = true launchSingleTop = true
} }
}, },
onNavigateToSharedLinks = { onNavigateToSharedLinks = {
navController.navigate(SHARED_LINKS_ROUTE) { navController.navigate(SharedLinks) {
launchSingleTop = true launchSingleTop = true
} }
}, },
@ -421,7 +436,7 @@ private fun MainContent(
navController.popBackStack() navController.popBackStack()
}, },
onNavigateToPresets = { onNavigateToPresets = {
navController.navigate(PRESET_MANAGER_ROUTE) { navController.navigate(PresetManager) {
launchSingleTop = true launchSingleTop = true
} }
}, },
@ -429,7 +444,7 @@ private fun MainContent(
navController.popBackStack() navController.popBackStack()
}, },
onNavigateToApiKeys = { onNavigateToApiKeys = {
navController.navigate(API_KEYS_ROUTE) { navController.navigate(ApiKeys) {
launchSingleTop = true launchSingleTop = true
} }
}, },

View file

@ -1,17 +1,21 @@
package com.garfiec.librechat.feature.agents.navigation package com.garfiec.librechat.feature.agents.navigation
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.garfiec.librechat.feature.agents.screen.AgentDetailScreen import com.garfiec.librechat.feature.agents.screen.AgentDetailScreen
import com.garfiec.librechat.feature.agents.screen.AgentEditorScreen import com.garfiec.librechat.feature.agents.screen.AgentEditorScreen
import com.garfiec.librechat.feature.agents.screen.AgentMarketplaceScreen import com.garfiec.librechat.feature.agents.screen.AgentMarketplaceScreen
import kotlinx.serialization.Serializable
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import kotlinx.serialization.modules.subclass
const val AGENTS_ROUTE = "agents" @Serializable sealed interface AgentsRoute
const val AGENT_DETAIL_ROUTE = "agents/{agentId}"
const val AGENT_EDITOR_CREATE_ROUTE = "agents/editor/create" @Serializable data object AgentMarketplace : AgentsRoute
const val AGENT_EDITOR_EDIT_ROUTE = "agents/editor/{agentId}" @Serializable data class AgentDetail(val agentId: String) : AgentsRoute
@Serializable data object AgentEditorCreate : AgentsRoute
@Serializable data class AgentEditorEdit(val agentId: String) : AgentsRoute
fun NavGraphBuilder.agentsGraph( fun NavGraphBuilder.agentsGraph(
onAgentClick: (String) -> Unit, onAgentClick: (String) -> Unit,
@ -20,19 +24,14 @@ fun NavGraphBuilder.agentsGraph(
onCreateAgent: () -> Unit, onCreateAgent: () -> Unit,
onEditAgent: (String) -> Unit, onEditAgent: (String) -> Unit,
) { ) {
composable(AGENTS_ROUTE) { composable<AgentMarketplace> {
AgentMarketplaceScreen( AgentMarketplaceScreen(
onAgentClick = onAgentClick, onAgentClick = onAgentClick,
onCreateAgent = onCreateAgent, onCreateAgent = onCreateAgent,
onBack = onBack, onBack = onBack,
) )
} }
composable( composable<AgentDetail> {
route = AGENT_DETAIL_ROUTE,
arguments = listOf(
navArgument("agentId") { type = NavType.StringType },
),
) {
AgentDetailScreen( AgentDetailScreen(
onBack = onBack, onBack = onBack,
onStartChat = onStartChat, onStartChat = onStartChat,
@ -40,21 +39,25 @@ fun NavGraphBuilder.agentsGraph(
onDuplicated = onAgentClick, onDuplicated = onAgentClick,
) )
} }
composable(AGENT_EDITOR_CREATE_ROUTE) { composable<AgentEditorCreate> {
AgentEditorScreen( AgentEditorScreen(
onBack = onBack, onBack = onBack,
onSaved = { agentId -> onAgentClick(agentId) }, onSaved = { agentId -> onAgentClick(agentId) },
) )
} }
composable( composable<AgentEditorEdit> {
route = AGENT_EDITOR_EDIT_ROUTE,
arguments = listOf(
navArgument("agentId") { type = NavType.StringType },
),
) {
AgentEditorScreen( AgentEditorScreen(
onBack = onBack, onBack = onBack,
onSaved = { agentId -> onAgentClick(agentId) }, onSaved = { agentId -> onAgentClick(agentId) },
) )
} }
} }
val agentsSerializersModule = SerializersModule {
polymorphic(AgentsRoute::class) {
subclass(AgentMarketplace::class, AgentMarketplace.serializer())
subclass(AgentDetail::class, AgentDetail.serializer())
subclass(AgentEditorCreate::class, AgentEditorCreate.serializer())
subclass(AgentEditorEdit::class, AgentEditorEdit.serializer())
}
}

View file

@ -2,9 +2,7 @@ package com.garfiec.librechat.feature.auth.navigation
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import androidx.navigation.navigation import androidx.navigation.navigation
import com.garfiec.librechat.core.ui.components.ScreenTransitionWrapper import com.garfiec.librechat.core.ui.components.ScreenTransitionWrapper
import com.garfiec.librechat.feature.auth.screen.ForgotPasswordScreen import com.garfiec.librechat.feature.auth.screen.ForgotPasswordScreen
@ -15,84 +13,70 @@ import com.garfiec.librechat.feature.auth.screen.ServerUrlScreen
import com.garfiec.librechat.feature.auth.screen.TermsScreen import com.garfiec.librechat.feature.auth.screen.TermsScreen
import com.garfiec.librechat.feature.auth.screen.TwoFactorScreen import com.garfiec.librechat.feature.auth.screen.TwoFactorScreen
import com.garfiec.librechat.feature.auth.screen.VerifyEmailScreen import com.garfiec.librechat.feature.auth.screen.VerifyEmailScreen
const val AUTH_GRAPH_ROUTE = "auth_graph" import kotlinx.serialization.Serializable
const val SERVER_URL_ROUTE = "server_url" import kotlinx.serialization.modules.SerializersModule
const val LOGIN_ROUTE = "login" import kotlinx.serialization.modules.polymorphic
const val REGISTER_ROUTE = "register" import kotlinx.serialization.modules.subclass
const val FORGOT_PASSWORD_ROUTE = "forgot_password"
const val TWO_FACTOR_ROUTE = "two_factor/{tempToken}" @Serializable sealed interface AuthRoute
const val VERIFY_EMAIL_ROUTE = "verify_email/{email}"
const val TERMS_ROUTE = "auth/terms" @Serializable data object ServerUrl : AuthRoute
const val RESET_PASSWORD_ROUTE = "reset_password/{userId}/{token}" @Serializable data object Login : AuthRoute
@Serializable data object Register : AuthRoute
@Serializable data object ForgotPassword : AuthRoute
@Serializable data class TwoFactor(val tempToken: String) : AuthRoute
@Serializable data class VerifyEmail(val email: String) : AuthRoute
@Serializable data object Terms : AuthRoute
@Serializable data class ResetPassword(val userId: String, val token: String) : AuthRoute
fun NavController.navigateToVerifyEmail(email: String) { fun NavController.navigateToVerifyEmail(email: String) {
navigate("verify_email/${encodeNavArg(email)}") navigate(VerifyEmail(email = email))
} }
fun NavController.navigateToResetPassword(userId: String, token: String) { fun NavController.navigateToResetPassword(userId: String, token: String) {
navigate("reset_password/${encodeNavArg(userId)}/${encodeNavArg(token)}") navigate(ResetPassword(userId = userId, token = token))
}
private fun encodeNavArg(value: String): String = buildString {
for (c in value) {
when {
c.isLetterOrDigit() || c in "-._~" -> append(c)
else -> {
val bytes = c.toString().encodeToByteArray()
for (b in bytes) {
append('%')
append(
(b.toInt() and 0xFF).toString(16).uppercase().padStart(2, '0'),
)
}
}
}
}
} }
fun NavGraphBuilder.authGraph( fun NavGraphBuilder.authGraph(
navController: NavController, navController: NavController,
onAuthComplete: () -> Unit, onAuthComplete: () -> Unit,
) { ) {
navigation(startDestination = SERVER_URL_ROUTE, route = AUTH_GRAPH_ROUTE) { navigation<AuthRoute>(startDestination = ServerUrl::class) {
composable(SERVER_URL_ROUTE) { composable<ServerUrl> {
ScreenTransitionWrapper(transition) { ScreenTransitionWrapper(transition) {
ServerUrlScreen( ServerUrlScreen(
onServerValidated = { navController.navigate(LOGIN_ROUTE) }, onServerValidated = { navController.navigate(Login) },
) )
} }
} }
composable(LOGIN_ROUTE) { composable<Login> {
ScreenTransitionWrapper(transition) { ScreenTransitionWrapper(transition) {
LoginScreen( LoginScreen(
onLoginSuccess = onAuthComplete, onLoginSuccess = onAuthComplete,
onNavigateToRegister = { navController.navigate(REGISTER_ROUTE) }, onNavigateToRegister = { navController.navigate(Register) },
onNavigateToForgotPassword = { navController.navigate(FORGOT_PASSWORD_ROUTE) }, onNavigateToForgotPassword = { navController.navigate(ForgotPassword) },
onNavigateToTwoFactor = { tempToken -> onNavigateToTwoFactor = { tempToken ->
navController.navigate("two_factor/$tempToken") navController.navigate(TwoFactor(tempToken = tempToken))
}, },
) )
} }
} }
composable(REGISTER_ROUTE) { composable<Register> {
ScreenTransitionWrapper(transition) { ScreenTransitionWrapper(transition) {
RegisterScreen( RegisterScreen(
onRegistered = { navController.popBackStack(LOGIN_ROUTE, inclusive = false) }, onRegistered = { navController.popBackStack<Login>(inclusive = false) },
onNavigateToLogin = { navController.popBackStack() }, onNavigateToLogin = { navController.popBackStack() },
) )
} }
} }
composable(FORGOT_PASSWORD_ROUTE) { composable<ForgotPassword> {
ScreenTransitionWrapper(transition) { ScreenTransitionWrapper(transition) {
ForgotPasswordScreen( ForgotPasswordScreen(
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
) )
} }
} }
composable( composable<TwoFactor> {
route = TWO_FACTOR_ROUTE,
arguments = listOf(navArgument("tempToken") { type = NavType.StringType }),
) {
ScreenTransitionWrapper(transition) { ScreenTransitionWrapper(transition) {
TwoFactorScreen( TwoFactorScreen(
onVerified = onAuthComplete, onVerified = onAuthComplete,
@ -100,18 +84,15 @@ fun NavGraphBuilder.authGraph(
) )
} }
} }
composable( composable<VerifyEmail> {
route = VERIFY_EMAIL_ROUTE,
arguments = listOf(navArgument("email") { type = NavType.StringType }),
) {
ScreenTransitionWrapper(transition) { ScreenTransitionWrapper(transition) {
VerifyEmailScreen( VerifyEmailScreen(
onVerified = { navController.popBackStack(LOGIN_ROUTE, inclusive = false) }, onVerified = { navController.popBackStack<Login>(inclusive = false) },
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
) )
} }
} }
composable(TERMS_ROUTE) { composable<Terms> {
ScreenTransitionWrapper(transition) { ScreenTransitionWrapper(transition) {
TermsScreen( TermsScreen(
onAccepted = onAuthComplete, onAccepted = onAuthComplete,
@ -119,19 +100,26 @@ fun NavGraphBuilder.authGraph(
) )
} }
} }
composable( composable<ResetPassword> {
route = RESET_PASSWORD_ROUTE,
arguments = listOf(
navArgument("userId") { type = NavType.StringType },
navArgument("token") { type = NavType.StringType },
),
) {
ScreenTransitionWrapper(transition) { ScreenTransitionWrapper(transition) {
ResetPasswordScreen( ResetPasswordScreen(
onResetComplete = { navController.popBackStack(LOGIN_ROUTE, inclusive = false) }, onResetComplete = { navController.popBackStack<Login>(inclusive = false) },
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
) )
} }
} }
} }
} }
val authSerializersModule = SerializersModule {
polymorphic(AuthRoute::class) {
subclass(ServerUrl::class, ServerUrl.serializer())
subclass(Login::class, Login.serializer())
subclass(Register::class, Register.serializer())
subclass(ForgotPassword::class, ForgotPassword.serializer())
subclass(TwoFactor::class, TwoFactor.serializer())
subclass(VerifyEmail::class, VerifyEmail.serializer())
subclass(Terms::class, Terms.serializer())
subclass(ResetPassword::class, ResetPassword.serializer())
}
}

View file

@ -3,27 +3,38 @@ package com.garfiec.librechat.feature.chat.navigation
import androidx.compose.animation.EnterTransition import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition import androidx.compose.animation.ExitTransition
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import androidx.navigation.navigation import androidx.navigation.navigation
import kotlin.reflect.KClass
import com.garfiec.librechat.feature.chat.prompts.PromptEditorScreen import com.garfiec.librechat.feature.chat.prompts.PromptEditorScreen
import com.garfiec.librechat.feature.chat.prompts.PromptsLibraryScreen import com.garfiec.librechat.feature.chat.prompts.PromptsLibraryScreen
import com.garfiec.librechat.feature.chat.screen.ChatScreen import com.garfiec.librechat.feature.chat.screen.ChatScreen
import com.garfiec.librechat.feature.chat.screen.NewChatScreen import com.garfiec.librechat.feature.chat.screen.NewChatScreen
import kotlinx.serialization.Serializable
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import kotlinx.serialization.modules.subclass
const val CHAT_GRAPH_ROUTE = "chat_graph" @Serializable sealed interface ChatRoute
const val NEW_CHAT_ROUTE = "new_chat"
const val CHAT_ROUTE = "chat/{conversationId}" @Serializable data object NewChat : ChatRoute
const val PROMPTS_LIBRARY_ROUTE = "prompts_library" @Serializable data class Chat(val conversationId: String? = null) : ChatRoute
const val PROMPT_EDITOR_ROUTE = "prompt_editor?groupId={groupId}" @Serializable data object PromptsLibrary : ChatRoute
@Serializable data class PromptEditor(val groupId: String? = null) : ChatRoute
/** Checks if this destination's route matches the given typed route class. */
internal fun NavDestination?.isRoute(routeClass: KClass<*>): Boolean {
val qualifiedName = routeClass.qualifiedName ?: return false
return this?.route?.startsWith(qualifiedName) == true
}
fun NavController.navigateToChat(conversationId: String) { fun NavController.navigateToChat(conversationId: String) {
val currentEntry = currentBackStackEntry val currentEntry = currentBackStackEntry
val isCurrentlyInChat = currentEntry?.destination?.route == CHAT_ROUTE val isCurrentlyInChat = currentEntry?.destination.isRoute(Chat::class)
val currentDestId = currentEntry?.destination?.id val currentDestId = currentEntry?.destination?.id
navigate("chat/$conversationId") { navigate(Chat(conversationId = conversationId)) {
if (isCurrentlyInChat && currentDestId != null) { if (isCurrentlyInChat && currentDestId != null) {
// Already viewing a chat -- replace it so switching chats // Already viewing a chat -- replace it so switching chats
// doesn't stack entries. Back will go to whatever was // doesn't stack entries. Back will go to whatever was
@ -36,24 +47,19 @@ fun NavController.navigateToChat(conversationId: String) {
} }
fun NavController.navigateToPromptsLibrary() { fun NavController.navigateToPromptsLibrary() {
navigate(PROMPTS_LIBRARY_ROUTE) navigate(PromptsLibrary)
} }
fun NavController.navigateToPromptEditor(groupId: String? = null) { fun NavController.navigateToPromptEditor(groupId: String? = null) {
if (groupId != null) { navigate(PromptEditor(groupId = groupId))
navigate("prompt_editor?groupId=$groupId")
} else {
navigate("prompt_editor")
}
} }
fun NavGraphBuilder.chatGraph( fun NavGraphBuilder.chatGraph(
navController: NavController, navController: NavController,
onOpenDrawer: (() -> Unit)? = null, onOpenDrawer: (() -> Unit)? = null,
) { ) {
navigation(startDestination = NEW_CHAT_ROUTE, route = CHAT_GRAPH_ROUTE) { navigation<ChatRoute>(startDestination = NewChat::class) {
composable( composable<NewChat>(
route = NEW_CHAT_ROUTE,
enterTransition = { EnterTransition.None }, enterTransition = { EnterTransition.None },
exitTransition = { ExitTransition.None }, exitTransition = { ExitTransition.None },
popEnterTransition = { null }, popEnterTransition = { null },
@ -67,15 +73,7 @@ fun NavGraphBuilder.chatGraph(
onNavigateToPromptsLibrary = { navController.navigateToPromptsLibrary() }, onNavigateToPromptsLibrary = { navController.navigateToPromptsLibrary() },
) )
} }
composable( composable<Chat>(
route = CHAT_ROUTE,
arguments = listOf(
navArgument("conversationId") {
type = NavType.StringType
nullable = true
defaultValue = null
},
),
enterTransition = { EnterTransition.None }, enterTransition = { EnterTransition.None },
exitTransition = { ExitTransition.None }, exitTransition = { ExitTransition.None },
popEnterTransition = { null }, popEnterTransition = { null },
@ -88,7 +86,7 @@ fun NavGraphBuilder.chatGraph(
onNavigateToConversation = { conversationId -> navController.navigateToChat(conversationId) }, onNavigateToConversation = { conversationId -> navController.navigateToChat(conversationId) },
) )
} }
composable(PROMPTS_LIBRARY_ROUTE) { composable<PromptsLibrary> {
PromptsLibraryScreen( PromptsLibraryScreen(
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
onUseInChat = { promptText -> onUseInChat = { promptText ->
@ -99,19 +97,19 @@ fun NavGraphBuilder.chatGraph(
}, },
) )
} }
composable( composable<PromptEditor> {
route = PROMPT_EDITOR_ROUTE,
arguments = listOf(
navArgument("groupId") {
type = NavType.StringType
nullable = true
defaultValue = null
},
),
) {
PromptEditorScreen( PromptEditorScreen(
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
) )
} }
} }
} }
val chatSerializersModule = SerializersModule {
polymorphic(ChatRoute::class) {
subclass(NewChat::class, NewChat.serializer())
subclass(Chat::class, Chat.serializer())
subclass(PromptsLibrary::class, PromptsLibrary.serializer())
subclass(PromptEditor::class, PromptEditor.serializer())
}
}

View file

@ -5,16 +5,22 @@ import androidx.navigation.compose.composable
import com.garfiec.librechat.core.ui.components.ScreenTransitionWrapper import com.garfiec.librechat.core.ui.components.ScreenTransitionWrapper
import com.garfiec.librechat.feature.conversations.screen.ArchivedConversationsScreen import com.garfiec.librechat.feature.conversations.screen.ArchivedConversationsScreen
import com.garfiec.librechat.feature.conversations.screen.ConversationListScreen import com.garfiec.librechat.feature.conversations.screen.ConversationListScreen
import kotlinx.serialization.Serializable
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import kotlinx.serialization.modules.subclass
const val CONVERSATIONS_ROUTE = "conversations" @Serializable sealed interface ConversationsRoute
const val ARCHIVED_ROUTE = "conversations/archived"
@Serializable data object Conversations : ConversationsRoute
@Serializable data object ArchivedConversations : ConversationsRoute
fun NavGraphBuilder.conversationsGraph( fun NavGraphBuilder.conversationsGraph(
onConversationClick: (String) -> Unit, onConversationClick: (String) -> Unit,
onNavigateToArchived: () -> Unit = {}, onNavigateToArchived: () -> Unit = {},
onNavigateBackFromArchived: () -> Unit = {}, onNavigateBackFromArchived: () -> Unit = {},
) { ) {
composable(CONVERSATIONS_ROUTE) { composable<Conversations> {
ScreenTransitionWrapper(transition) { ScreenTransitionWrapper(transition) {
ConversationListScreen( ConversationListScreen(
onConversationClick = onConversationClick, onConversationClick = onConversationClick,
@ -22,7 +28,7 @@ fun NavGraphBuilder.conversationsGraph(
) )
} }
} }
composable(ARCHIVED_ROUTE) { composable<ArchivedConversations> {
ScreenTransitionWrapper(transition) { ScreenTransitionWrapper(transition) {
ArchivedConversationsScreen( ArchivedConversationsScreen(
onNavigateBack = onNavigateBackFromArchived, onNavigateBack = onNavigateBackFromArchived,
@ -30,3 +36,10 @@ fun NavGraphBuilder.conversationsGraph(
} }
} }
} }
val conversationsSerializersModule = SerializersModule {
polymorphic(ConversationsRoute::class) {
subclass(Conversations::class, Conversations.serializer())
subclass(ArchivedConversations::class, ArchivedConversations.serializer())
}
}

View file

@ -4,15 +4,27 @@ import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import com.garfiec.librechat.core.ui.components.ScreenTransitionWrapper import com.garfiec.librechat.core.ui.components.ScreenTransitionWrapper
import com.garfiec.librechat.feature.files.screen.FilesScreen import com.garfiec.librechat.feature.files.screen.FilesScreen
import kotlinx.serialization.Serializable
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import kotlinx.serialization.modules.subclass
const val FILES_ROUTE = "files" @Serializable sealed interface FilesRoute
@Serializable data object Files : FilesRoute
fun NavGraphBuilder.filesGraph( fun NavGraphBuilder.filesGraph(
onBack: (() -> Unit)? = null, onBack: (() -> Unit)? = null,
) { ) {
composable(FILES_ROUTE) { composable<Files> {
ScreenTransitionWrapper(transition) { ScreenTransitionWrapper(transition) {
FilesScreen(onBack = onBack) FilesScreen(onBack = onBack)
} }
} }
} }
val filesSerializersModule = SerializersModule {
polymorphic(FilesRoute::class) {
subclass(Files::class, Files.serializer())
}
}

View file

@ -3,13 +3,14 @@ package com.garfiec.librechat.feature.settings.navigation
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import com.garfiec.librechat.feature.settings.screen.McpServersScreen import com.garfiec.librechat.feature.settings.screen.McpServersScreen
import kotlinx.serialization.Serializable
const val MCP_SERVERS_ROUTE = "settings/mcp" @Serializable data object McpServers : SettingsRoute
fun NavGraphBuilder.mcpServersScreen( fun NavGraphBuilder.mcpServersScreen(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
) { ) {
composable(MCP_SERVERS_ROUTE) { composable<McpServers> {
McpServersScreen( McpServersScreen(
onNavigateBack = onNavigateBack, onNavigateBack = onNavigateBack,
) )

View file

@ -3,13 +3,14 @@ package com.garfiec.librechat.feature.settings.navigation
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import com.garfiec.librechat.feature.settings.screen.MemoriesScreen import com.garfiec.librechat.feature.settings.screen.MemoriesScreen
import kotlinx.serialization.Serializable
const val MEMORIES_ROUTE = "settings/memories" @Serializable data object Memories : SettingsRoute
fun NavGraphBuilder.memoriesScreen( fun NavGraphBuilder.memoriesScreen(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
) { ) {
composable(MEMORIES_ROUTE) { composable<Memories> {
MemoriesScreen( MemoriesScreen(
onNavigateBack = onNavigateBack, onNavigateBack = onNavigateBack,
) )

View file

@ -13,16 +13,22 @@ import com.garfiec.librechat.feature.settings.screen.PresetManagerScreen
import com.garfiec.librechat.feature.settings.screen.SharedLinksScreen import com.garfiec.librechat.feature.settings.screen.SharedLinksScreen
import com.garfiec.librechat.feature.settings.screen.TabbedSettingsScreen import com.garfiec.librechat.feature.settings.screen.TabbedSettingsScreen
import com.garfiec.librechat.feature.settings.viewmodel.SettingsViewModel import com.garfiec.librechat.feature.settings.viewmodel.SettingsViewModel
import kotlinx.serialization.Serializable
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import kotlinx.serialization.modules.subclass
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
const val SETTINGS_TABBED_ROUTE = "settings" @Serializable sealed interface SettingsRoute
const val SETTINGS_GENERAL_ROUTE = "settings/general"
const val SETTINGS_CHAT_ROUTE = "settings/chat" @Serializable data object SettingsTabbed : SettingsRoute
const val SETTINGS_ACCOUNT_ROUTE = "settings/account" @Serializable data object SettingsGeneral : SettingsRoute
const val SETTINGS_DATA_ROUTE = "settings/data" @Serializable data object SettingsChat : SettingsRoute
const val SHARED_LINKS_ROUTE = "settings/shared-links" @Serializable data object SettingsAccount : SettingsRoute
const val PRESET_MANAGER_ROUTE = "settings/presets" @Serializable data object SettingsData : SettingsRoute
const val API_KEYS_ROUTE = "settings/api_keys" @Serializable data object SharedLinks : SettingsRoute
@Serializable data object PresetManager : SettingsRoute
@Serializable data object ApiKeys : SettingsRoute
fun NavGraphBuilder.settingsGraph( fun NavGraphBuilder.settingsGraph(
onLogout: () -> Unit, onLogout: () -> Unit,
@ -35,7 +41,7 @@ fun NavGraphBuilder.settingsGraph(
onNavigateToApiKeys: () -> Unit = {}, onNavigateToApiKeys: () -> Unit = {},
onNavigateBackFromApiKeys: () -> Unit = {}, onNavigateBackFromApiKeys: () -> Unit = {},
) { ) {
composable(SETTINGS_TABBED_ROUTE) { composable<SettingsTabbed> {
TabbedSettingsScreen( TabbedSettingsScreen(
onNavigateBack = onNavigateBack, onNavigateBack = onNavigateBack,
onLogout = onLogout, onLogout = onLogout,
@ -46,32 +52,32 @@ fun NavGraphBuilder.settingsGraph(
) )
} }
composable(SETTINGS_GENERAL_ROUTE) { composable<SettingsGeneral> {
GeneralSettingsScreen( GeneralSettingsScreen(
onNavigateBack = onNavigateBack, onNavigateBack = onNavigateBack,
) )
} }
composable(SETTINGS_CHAT_ROUTE) { composable<SettingsChat> {
ChatSettingsScreen( ChatSettingsScreen(
onNavigateBack = onNavigateBack, onNavigateBack = onNavigateBack,
onNavigateToPresets = onNavigateToPresets, onNavigateToPresets = onNavigateToPresets,
) )
} }
composable(SETTINGS_ACCOUNT_ROUTE) { composable<SettingsAccount> {
AccountSettingsScreen( AccountSettingsScreen(
onLogout = onLogout, onLogout = onLogout,
onNavigateBack = onNavigateBack, onNavigateBack = onNavigateBack,
onNavigateToApiKeys = onNavigateToApiKeys, onNavigateToApiKeys = onNavigateToApiKeys,
) )
} }
composable(SETTINGS_DATA_ROUTE) { composable<SettingsData> {
DataSettingsScreen( DataSettingsScreen(
onNavigateBack = onNavigateBack, onNavigateBack = onNavigateBack,
onNavigateToArchived = onNavigateToArchived, onNavigateToArchived = onNavigateToArchived,
onNavigateToSharedLinks = onNavigateToSharedLinks, onNavigateToSharedLinks = onNavigateToSharedLinks,
) )
} }
composable(SHARED_LINKS_ROUTE) { composable<SharedLinks> {
val viewModel: SettingsViewModel = koinViewModel() val viewModel: SettingsViewModel = koinViewModel()
val uiState = viewModel.uiState.collectAsStateWithLifecycle() val uiState = viewModel.uiState.collectAsStateWithLifecycle()
@ -90,14 +96,29 @@ fun NavGraphBuilder.settingsGraph(
onNavigateBack = onNavigateBackFromSharedLinks, onNavigateBack = onNavigateBackFromSharedLinks,
) )
} }
composable(PRESET_MANAGER_ROUTE) { composable<PresetManager> {
PresetManagerScreen( PresetManagerScreen(
onNavigateBack = onNavigateBackFromPresets, onNavigateBack = onNavigateBackFromPresets,
) )
} }
composable(API_KEYS_ROUTE) { composable<ApiKeys> {
ApiKeysScreen( ApiKeysScreen(
onNavigateBack = onNavigateBackFromApiKeys, onNavigateBack = onNavigateBackFromApiKeys,
) )
} }
} }
val settingsSerializersModule = SerializersModule {
polymorphic(SettingsRoute::class) {
subclass(SettingsTabbed::class, SettingsTabbed.serializer())
subclass(SettingsGeneral::class, SettingsGeneral.serializer())
subclass(SettingsChat::class, SettingsChat.serializer())
subclass(SettingsAccount::class, SettingsAccount.serializer())
subclass(SettingsData::class, SettingsData.serializer())
subclass(SharedLinks::class, SharedLinks.serializer())
subclass(PresetManager::class, PresetManager.serializer())
subclass(ApiKeys::class, ApiKeys.serializer())
subclass(Memories::class, Memories.serializer())
subclass(McpServers::class, McpServers.serializer())
}
}

View file

@ -1,8 +1,11 @@
package com.garfiec.librechat.shared.navigation package com.garfiec.librechat.shared.navigation
import kotlinx.serialization.modules.SerializersModule
import org.koin.core.module.dsl.viewModelOf import org.koin.core.module.dsl.viewModelOf
import org.koin.core.qualifier.named
import org.koin.dsl.module import org.koin.dsl.module
val sharedAppModule = module { val sharedAppModule = module {
viewModelOf(::NavHostViewModel) viewModelOf(::NavHostViewModel)
single<SerializersModule>(named("navigation")) { navigationSerializersModule }
} }

View file

@ -29,30 +29,39 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.savedstate.read import androidx.navigation.NavDestination
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.toRoute
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import kotlin.reflect.KClass
import com.garfiec.librechat.core.ui.components.BannerDisplay import com.garfiec.librechat.core.ui.components.BannerDisplay
import com.garfiec.librechat.feature.agents.navigation.AGENT_EDITOR_CREATE_ROUTE import com.garfiec.librechat.feature.agents.navigation.AgentDetail
import com.garfiec.librechat.feature.agents.navigation.AgentEditorCreate
import com.garfiec.librechat.feature.agents.navigation.AgentEditorEdit
import com.garfiec.librechat.feature.agents.navigation.AgentMarketplace
import com.garfiec.librechat.feature.agents.navigation.agentsGraph import com.garfiec.librechat.feature.agents.navigation.agentsGraph
import com.garfiec.librechat.feature.auth.navigation.AUTH_GRAPH_ROUTE import com.garfiec.librechat.feature.auth.navigation.AuthRoute
import com.garfiec.librechat.feature.auth.navigation.ServerUrl
import com.garfiec.librechat.feature.auth.navigation.authGraph import com.garfiec.librechat.feature.auth.navigation.authGraph
import com.garfiec.librechat.feature.chat.navigation.CHAT_GRAPH_ROUTE import com.garfiec.librechat.feature.chat.navigation.Chat
import com.garfiec.librechat.feature.chat.navigation.CHAT_ROUTE import com.garfiec.librechat.feature.chat.navigation.ChatRoute
import com.garfiec.librechat.feature.chat.navigation.NEW_CHAT_ROUTE import com.garfiec.librechat.feature.chat.navigation.NewChat
import com.garfiec.librechat.feature.chat.navigation.chatGraph import com.garfiec.librechat.feature.chat.navigation.chatGraph
import com.garfiec.librechat.feature.chat.navigation.navigateToChat import com.garfiec.librechat.feature.chat.navigation.navigateToChat
import com.garfiec.librechat.feature.conversations.navigation.ArchivedConversations
import com.garfiec.librechat.feature.conversations.navigation.conversationsGraph import com.garfiec.librechat.feature.conversations.navigation.conversationsGraph
import com.garfiec.librechat.feature.files.navigation.Files
import com.garfiec.librechat.feature.files.navigation.filesGraph import com.garfiec.librechat.feature.files.navigation.filesGraph
import com.garfiec.librechat.feature.settings.navigation.API_KEYS_ROUTE import com.garfiec.librechat.feature.settings.navigation.ApiKeys
import com.garfiec.librechat.feature.settings.navigation.PRESET_MANAGER_ROUTE import com.garfiec.librechat.feature.settings.navigation.PresetManager
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_ACCOUNT_ROUTE import com.garfiec.librechat.feature.settings.navigation.SettingsAccount
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_CHAT_ROUTE import com.garfiec.librechat.feature.settings.navigation.SettingsChat
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_DATA_ROUTE import com.garfiec.librechat.feature.settings.navigation.SettingsData
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_GENERAL_ROUTE import com.garfiec.librechat.feature.settings.navigation.SettingsGeneral
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_TABBED_ROUTE import com.garfiec.librechat.feature.settings.navigation.SettingsRoute
import com.garfiec.librechat.feature.settings.navigation.SHARED_LINKS_ROUTE import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed
import com.garfiec.librechat.feature.settings.navigation.SharedLinks
import com.garfiec.librechat.feature.settings.navigation.settingsGraph import com.garfiec.librechat.feature.settings.navigation.settingsGraph
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import librechat_mobile.shared.generated.resources.Res import librechat_mobile.shared.generated.resources.Res
@ -63,12 +72,18 @@ import librechat_mobile.shared.generated.resources.version_mismatch_title
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
/** Maps a [SettingsCategory] to its corresponding navigation route. */ /** Checks if this destination's route matches the given typed route class. */
fun SettingsCategory.toRoute(): String = when (this) { private fun NavDestination?.isRoute(routeClass: KClass<*>): Boolean {
SettingsCategory.GENERAL -> SETTINGS_GENERAL_ROUTE val qualifiedName = routeClass.qualifiedName ?: return false
SettingsCategory.CHAT -> SETTINGS_CHAT_ROUTE return this?.route?.startsWith(qualifiedName) == true
SettingsCategory.ACCOUNT -> SETTINGS_ACCOUNT_ROUTE }
SettingsCategory.DATA -> SETTINGS_DATA_ROUTE
/** Maps a [SettingsCategory] to its corresponding typed navigation route. */
fun SettingsCategory.toRoute(): SettingsRoute = when (this) {
SettingsCategory.GENERAL -> SettingsGeneral
SettingsCategory.CHAT -> SettingsChat
SettingsCategory.ACCOUNT -> SettingsAccount
SettingsCategory.DATA -> SettingsData
} }
/** /**
@ -90,15 +105,13 @@ fun LibreChatNavHost(
val isLoggedIn by navHostViewModel.isLoggedIn.collectAsStateWithLifecycle() val isLoggedIn by navHostViewModel.isLoggedIn.collectAsStateWithLifecycle()
val isInAuthFlow = currentDestination?.route == AUTH_GRAPH_ROUTE || val isInAuthFlow = currentDestination.isRoute(AuthRoute::class) ||
currentDestination?.parent?.route == AUTH_GRAPH_ROUTE currentDestination?.parent.isRoute(AuthRoute::class)
// Track active conversation from nav back stack. // Track active conversation from nav back stack
// Use SavedState.read {} to extract nav args (KMP-compatible, no Bundle.getString).
LaunchedEffect(navBackStackEntry) { LaunchedEffect(navBackStackEntry) {
val destRoute = navBackStackEntry?.destination?.route val conversationId = if (navBackStackEntry?.destination.isRoute(Chat::class)) {
val conversationId = if (destRoute == CHAT_ROUTE) { navBackStackEntry?.toRoute<Chat>()?.conversationId
navBackStackEntry?.arguments?.read { getStringOrNull("conversationId") }
} else { } else {
null null
} }
@ -108,13 +121,13 @@ fun LibreChatNavHost(
// Handle session expiry // Handle session expiry
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
navHostViewModel.sessionExpired.collect { navHostViewModel.sessionExpired.collect {
navController.navigate(AUTH_GRAPH_ROUTE) { navController.navigate(ServerUrl) {
popUpTo(0) { inclusive = true } popUpTo(0) { inclusive = true }
} }
} }
} }
val startDestination = if (isLoggedIn) CHAT_GRAPH_ROUTE else AUTH_GRAPH_ROUTE val startDestination: Any = if (isLoggedIn) ChatRoute::class else AuthRoute::class
PhoneLayout( PhoneLayout(
navController = navController, navController = navController,
@ -174,7 +187,7 @@ private fun VersionMismatchDialog(
private fun PhoneLayout( private fun PhoneLayout(
navController: androidx.navigation.NavHostController, navController: androidx.navigation.NavHostController,
navHostViewModel: NavHostViewModel, navHostViewModel: NavHostViewModel,
startDestination: String, startDestination: Any,
isInAuthFlow: Boolean, isInAuthFlow: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -199,10 +212,10 @@ private fun PhoneLayout(
viewModel = navHostViewModel, viewModel = navHostViewModel,
onNewChat = { onNewChat = {
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
val currentRoute = navController.currentBackStackEntry?.destination?.route val currentRoute = navController.currentBackStackEntry?.destination
if (currentRoute != NEW_CHAT_ROUTE) { if (!currentRoute.isRoute(NewChat::class)) {
navController.navigate(NEW_CHAT_ROUTE) { navController.navigate(NewChat) {
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false } popUpTo<ChatRoute> { inclusive = false }
launchSingleTop = true launchSingleTop = true
} }
} }
@ -213,7 +226,7 @@ private fun PhoneLayout(
}, },
onSettingsClick = { onSettingsClick = {
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
navController.navigate(SETTINGS_TABBED_ROUTE) { navController.navigate(SettingsTabbed) {
launchSingleTop = true launchSingleTop = true
} }
}, },
@ -224,13 +237,13 @@ private fun PhoneLayout(
}, },
onAgentsClick = { onAgentsClick = {
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
navController.navigate(TopLevelDestination.AGENTS.route) { navController.navigate(AgentMarketplace) {
launchSingleTop = true launchSingleTop = true
} }
}, },
onFilesClick = { onFilesClick = {
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
navController.navigate(TopLevelDestination.FILES.route) { navController.navigate(Files) {
launchSingleTop = true launchSingleTop = true
} }
}, },
@ -283,8 +296,8 @@ private fun PhoneLayout(
navController = navController, navController = navController,
onAuthComplete = { onAuthComplete = {
navHostViewModel.onAuthComplete() navHostViewModel.onAuthComplete()
navController.navigate(CHAT_GRAPH_ROUTE) { navController.navigate(NewChat) {
popUpTo(AUTH_GRAPH_ROUTE) { inclusive = true } popUpTo<AuthRoute> { inclusive = true }
} }
}, },
) )
@ -299,7 +312,7 @@ private fun PhoneLayout(
navController.navigateToChat(conversationId) navController.navigateToChat(conversationId)
}, },
onNavigateToArchived = { onNavigateToArchived = {
navController.navigate("conversations/archived") navController.navigate(ArchivedConversations)
}, },
onNavigateBackFromArchived = { onNavigateBackFromArchived = {
navController.popBackStack() navController.popBackStack()
@ -307,20 +320,20 @@ private fun PhoneLayout(
) )
agentsGraph( agentsGraph(
onAgentClick = { agentId -> onAgentClick = { agentId ->
navController.navigate("agents/$agentId") navController.navigate(AgentDetail(agentId = agentId))
}, },
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
onStartChat = { agentId -> onStartChat = { agentId ->
navController.navigate(NEW_CHAT_ROUTE) { navController.navigate(NewChat) {
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false } popUpTo<ChatRoute> { inclusive = false }
launchSingleTop = true launchSingleTop = true
} }
}, },
onCreateAgent = { onCreateAgent = {
navController.navigate(AGENT_EDITOR_CREATE_ROUTE) navController.navigate(AgentEditorCreate)
}, },
onEditAgent = { agentId -> onEditAgent = { agentId ->
navController.navigate("agents/editor/$agentId") navController.navigate(AgentEditorEdit(agentId = agentId))
}, },
) )
filesGraph( filesGraph(
@ -329,18 +342,18 @@ private fun PhoneLayout(
settingsGraph( settingsGraph(
onLogout = { onLogout = {
navHostViewModel.logout() navHostViewModel.logout()
navController.navigate(AUTH_GRAPH_ROUTE) { navController.navigate(ServerUrl) {
popUpTo(0) { inclusive = true } popUpTo(0) { inclusive = true }
} }
}, },
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
onNavigateToArchived = { onNavigateToArchived = {
navController.navigate("conversations/archived") { navController.navigate(ArchivedConversations) {
launchSingleTop = true launchSingleTop = true
} }
}, },
onNavigateToSharedLinks = { onNavigateToSharedLinks = {
navController.navigate(SHARED_LINKS_ROUTE) { navController.navigate(SharedLinks) {
launchSingleTop = true launchSingleTop = true
} }
}, },
@ -348,7 +361,7 @@ private fun PhoneLayout(
navController.popBackStack() navController.popBackStack()
}, },
onNavigateToPresets = { onNavigateToPresets = {
navController.navigate(PRESET_MANAGER_ROUTE) { navController.navigate(PresetManager) {
launchSingleTop = true launchSingleTop = true
} }
}, },
@ -356,7 +369,7 @@ private fun PhoneLayout(
navController.popBackStack() navController.popBackStack()
}, },
onNavigateToApiKeys = { onNavigateToApiKeys = {
navController.navigate(API_KEYS_ROUTE) { navController.navigate(ApiKeys) {
launchSingleTop = true launchSingleTop = true
} }
}, },

View file

@ -0,0 +1,23 @@
package com.garfiec.librechat.shared.navigation
import com.garfiec.librechat.feature.agents.navigation.agentsSerializersModule
import com.garfiec.librechat.feature.auth.navigation.authSerializersModule
import com.garfiec.librechat.feature.chat.navigation.chatSerializersModule
import com.garfiec.librechat.feature.conversations.navigation.conversationsSerializersModule
import com.garfiec.librechat.feature.files.navigation.filesSerializersModule
import com.garfiec.librechat.feature.settings.navigation.settingsSerializersModule
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.plus
/**
* Combined [SerializersModule] for all navigation route types.
* Registers polymorphic serializers for each feature module's sealed route hierarchy.
* This will be used by Nav 3's SavedStateConfiguration for type-safe state saving.
*/
val navigationSerializersModule: SerializersModule =
authSerializersModule +
chatSerializersModule +
conversationsSerializersModule +
agentsSerializersModule +
filesSerializersModule +
settingsSerializersModule