diff --git a/CLAUDE.md b/CLAUDE.md index 34aa252..ead9692 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,18 +4,19 @@ Native mobile client for LibreChat (Android & iOS). Connects to existing LibreCh ## Tech Stack -- **UI**: Jetpack Compose + Compose Navigation +- **UI**: Jetpack Compose + Navigation Compose 3 (Nav 3) - **DI**: Koin (KMP-ready) - **Network**: Ktor Client (OkHttp engine) - **Serialization**: Kotlinx Serialization - **Local Storage**: Room (cache), DataStore (prefs), EncryptedSharedPreferences (tokens) -- **Build**: Gradle 8.11.1, AGP 8.7.3, Kotlin 2.1.0, compileSdk 35, minSdk 26 +- **Build**: Gradle 9.4.1, AGP 9.1.0, Kotlin 2.3.20, compileSdk 36, minSdk 26 ## Module Layout ``` app/ → Single Activity, adaptive navigation (phone/tablet) -build-logic/ → 7 convention plugins for consistent Gradle config +shared/ → KMP shared framework (iOS entry point, shared navigation) +build-logic/ → 13 convention plugins for consistent Gradle config core/common/ → Result type, dispatcher DI, coroutine scopes, extensions core/model/ → @Serializable data classes (pure Kotlin, no Android deps) core/network/ → Ktor client, 16 API services, SSE client, auth interceptor @@ -34,7 +35,7 @@ Each module has its own `CLAUDE.md` with specific guidance. ## Architecture Rules - Feature modules depend on `:core:*` only, never on each other -- Single Activity with Compose Navigation +- Single Activity with Nav 3 (`NavDisplay` + `NavBackStack` + `entryProvider`) - Unidirectional data flow: UI → ViewModel → Repository → API/Room - Room is a read-through cache; server is source of truth - Custom SSE parser over raw ByteReadChannel (not Ktor SSE plugin) diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 0c8762b..17bbd57 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -1,14 +1,5 @@ # Known Issues -## Tablet back navigation doesn't dismiss sidebar first - -**Status**: Known issue, to be fixed later - -**Symptoms**: In tablet mode with the sidebar open, pressing back navigates away from the current chat instead of dismissing the sidebar first. The sidebar remains covering the screen. - -**Root cause**: The `BackHandler` in `TabletLayout.kt` is present but the system navigation's back handler takes priority. Needs investigation into back handler dispatch ordering relative to the NavHost. - -**Workaround**: Tap the hamburger menu button to toggle the sidebar closed. ## Chat search: sub-message scroll precision diff --git a/app/CLAUDE.md b/app/CLAUDE.md index e94e42a..26cee9e 100644 --- a/app/CLAUDE.md +++ b/app/CLAUDE.md @@ -4,11 +4,13 @@ Single Activity architecture. `MainActivity` is the sole entry point. ## Navigation -`LibreChatNavHost` is the root composable. It uses adaptive layout based on `WindowSizeClass`: +`LibreChatNavHost` is the root composable using Nav 3's `NavDisplay` with `NavBackStack` and `entryProvider`. It uses adaptive layout based on `WindowSizeClass`: - **Phone**: `ModalNavigationDrawer` as primary navigation (sidebar-first pattern matching the web frontend). No bottom navigation bar. Drawer opens via hamburger button in chat header or swipe gesture. - **Tablet** (600dp+ width): Persistent side panel with full conversation list. No `NavigationRail` or bottom bar. +Feature modules provide entries via `EntryProviderScope` extensions (e.g., `authEntries()`, `chatEntries()`). Navigation is driven by `onNavigate: (NavKey) -> Unit` and `onBack: () -> Unit` lambdas — feature modules never receive `NavBackStack` directly. + ## Sidebar / Drawer Content `DrawerContent` is the rich sidebar matching the web's left panel: @@ -22,8 +24,8 @@ Single Activity architecture. `MainActivity` is the sole entry point. ## Top-Level Destinations -Defined in `TopLevelDestination` enum: Chat, Conversations, Agents, Files, Settings. -Conversations are integrated into the drawer body. Agents, Files, and Settings are accessible via drawer footer links. All routes remain registered in the NavHost. +Top-level routes: `NewChat` (Chat), `Conversations`, `AgentMarketplace` (Agents), `Files`, `SettingsTabbed` (Settings). +Conversations are integrated into the drawer body. Agents, Files, and Settings are accessible via drawer footer links. All routes are registered in the `NavDisplay` entry provider. ## Active Conversation Tracking @@ -32,8 +34,8 @@ Conversations are integrated into the drawer body. Agents, Files, and Settings a ## Auth & Session - `NavHostViewModel` observes auth state via `isLoggedIn` flow -- Start destination is `CHAT_GRAPH_ROUTE` if logged in, `AUTH_GRAPH_ROUTE` otherwise -- `sessionExpired` flow triggers nav to auth graph with full backstack clear +- Start destination is `NewChat` if logged in, `ServerUrl` otherwise +- `sessionExpired` flow triggers nav to auth flow with full back stack clear - Drawer gestures are hidden during auth flow ## Deep Linking @@ -66,6 +68,6 @@ It applies convention plugins: `librechat.mobile.application`, `librechat.mobile - **Gotcha**: `displayFrom`/`displayTo` are ISO 8601 strings parsed with `Instant.parse()` — wrap in `runCatching` since the format from the server is not guaranteed ### Preset Navigation -- `PRESET_MANAGER_ROUTE` registered in `SettingsNavigation.kt` +- `PresetManager` route registered in `SettingsNavigation.kt` - `PresetManagerScreen` accessible from Settings → Chat section -- Navigation wired in both `LibreChatNavHost` and `TabletLayout` +- Navigation wired through `MainNavDisplay` entry provider, accessible from both phone and tablet layouts diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 744496a..c9d45c4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,6 +2,7 @@ plugins { id("librechat.mobile.application") id("librechat.mobile.compose") id("librechat.mobile.koin") + id("librechat.kotlin.serialization") } android { @@ -30,7 +31,8 @@ dependencies { implementation(project(":feature:files")) implementation(libs.activity.compose) - implementation(libs.navigation.compose) + implementation(libs.navigation3.ui.kmp) + implementation(libs.lifecycle.viewmodel.navigation3.kmp) implementation(libs.koin.compose) implementation(libs.koin.compose.viewmodel) implementation(libs.koin.compose.viewmodel.navigation) diff --git a/app/src/main/kotlin/com/garfiec/librechat/MainActivity.kt b/app/src/main/kotlin/com/garfiec/librechat/MainActivity.kt index dfbb34a..9a010d3 100644 --- a/app/src/main/kotlin/com/garfiec/librechat/MainActivity.kt +++ b/app/src/main/kotlin/com/garfiec/librechat/MainActivity.kt @@ -51,8 +51,8 @@ class MainActivity : ComponentActivity() { /** * Incremented each time a share intent arrives. - * The NavHost observes this and either: - * - Navigates to NEW_CHAT_ROUTE if the user is not on a chat screen, or + * The NavDisplay observes this and either: + * - Navigates to NewChat if the user is not on a chat screen, or * - Lets the active ChatViewModel consume the shared content in-place if already on a chat screen. */ private var shareNavigationTrigger by mutableStateOf(0) diff --git a/app/src/main/kotlin/com/garfiec/librechat/navigation/AppModule.kt b/app/src/main/kotlin/com/garfiec/librechat/navigation/AppModule.kt index 5af22ad..1569555 100644 --- a/app/src/main/kotlin/com/garfiec/librechat/navigation/AppModule.kt +++ b/app/src/main/kotlin/com/garfiec/librechat/navigation/AppModule.kt @@ -1,8 +1,11 @@ package com.garfiec.librechat.navigation +import kotlinx.serialization.modules.SerializersModule import org.koin.core.module.dsl.viewModelOf +import org.koin.core.qualifier.named import org.koin.dsl.module val appModule = module { viewModelOf(::NavHostViewModel) + single(named("navigation")) { navigationSerializersModule } } diff --git a/app/src/main/kotlin/com/garfiec/librechat/navigation/DrawerContent.kt b/app/src/main/kotlin/com/garfiec/librechat/navigation/DrawerContent.kt index 48c8385..bd21e90 100644 --- a/app/src/main/kotlin/com/garfiec/librechat/navigation/DrawerContent.kt +++ b/app/src/main/kotlin/com/garfiec/librechat/navigation/DrawerContent.kt @@ -81,7 +81,7 @@ private val ActiveIndicatorShape = RoundedCornerShape(2.dp) /** * Stateful DrawerContent that collects its own state from the ViewModel. * State changes only recompose inside this composable — not the parent - * PhoneLayout/TabletLayout, which avoids recomposing the NavHost/main content. + * PhoneLayout/TabletLayout, which avoids recomposing the NavDisplay/main content. */ @Composable fun DrawerContent( diff --git a/app/src/main/kotlin/com/garfiec/librechat/navigation/LibreChatNavHost.kt b/app/src/main/kotlin/com/garfiec/librechat/navigation/LibreChatNavHost.kt index f17adc8..16705d1 100644 --- a/app/src/main/kotlin/com/garfiec/librechat/navigation/LibreChatNavHost.kt +++ b/app/src/main/kotlin/com/garfiec/librechat/navigation/LibreChatNavHost.kt @@ -1,7 +1,6 @@ package com.garfiec.librechat.navigation import android.net.Uri -import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.tween @@ -9,7 +8,9 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut -import androidx.compose.animation.slideOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -29,45 +30,48 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.currentBackStackEntryAsState -import androidx.navigation.compose.rememberNavController +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.ui.NavDisplay +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import com.garfiec.librechat.MainActivity import com.garfiec.librechat.R 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.agentsGraph -import com.garfiec.librechat.feature.auth.navigation.AUTH_GRAPH_ROUTE -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_ROUTE -import com.garfiec.librechat.feature.chat.navigation.NEW_CHAT_ROUTE -import com.garfiec.librechat.feature.chat.navigation.chatGraph -import com.garfiec.librechat.feature.chat.navigation.navigateToChat -import com.garfiec.librechat.feature.conversations.navigation.conversationsGraph -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.PRESET_MANAGER_ROUTE -import com.garfiec.librechat.feature.settings.navigation.SETTINGS_ACCOUNT_ROUTE -import com.garfiec.librechat.feature.settings.navigation.SETTINGS_CHAT_ROUTE -import com.garfiec.librechat.feature.settings.navigation.SETTINGS_DATA_ROUTE -import com.garfiec.librechat.feature.settings.navigation.SETTINGS_GENERAL_ROUTE -import com.garfiec.librechat.feature.settings.navigation.SETTINGS_TABBED_ROUTE -import com.garfiec.librechat.feature.settings.navigation.SHARED_LINKS_ROUTE -import com.garfiec.librechat.feature.settings.navigation.settingsGraph +import com.garfiec.librechat.feature.agents.navigation.AgentMarketplace +import com.garfiec.librechat.feature.agents.navigation.agentsEntries +import com.garfiec.librechat.feature.auth.navigation.AuthRoute +import com.garfiec.librechat.feature.auth.navigation.ServerUrl +import com.garfiec.librechat.feature.auth.navigation.authEntries +import com.garfiec.librechat.feature.chat.navigation.Chat +import com.garfiec.librechat.feature.chat.navigation.NewChat +import com.garfiec.librechat.feature.chat.navigation.chatEntries +import com.garfiec.librechat.feature.conversations.navigation.ArchivedConversations +import com.garfiec.librechat.feature.conversations.navigation.conversationsEntries +import com.garfiec.librechat.feature.files.navigation.Files +import com.garfiec.librechat.feature.files.navigation.filesEntries +import com.garfiec.librechat.feature.settings.navigation.SettingsAccount +import com.garfiec.librechat.feature.settings.navigation.SettingsChat +import com.garfiec.librechat.feature.settings.navigation.SettingsData +import com.garfiec.librechat.feature.settings.navigation.SettingsGeneral +import com.garfiec.librechat.feature.settings.navigation.SettingsRoute +import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed +import com.garfiec.librechat.feature.settings.navigation.memoriesEntry +import com.garfiec.librechat.feature.settings.navigation.mcpServersEntry +import com.garfiec.librechat.feature.settings.navigation.settingsEntries import kotlinx.coroutines.launch import org.koin.compose.viewmodel.koinViewModel import co.touchlab.kermit.Logger -/** Maps a [SettingsCategory] to its corresponding navigation route. */ -fun SettingsCategory.toRoute(): String = when (this) { - SettingsCategory.GENERAL -> SETTINGS_GENERAL_ROUTE - SettingsCategory.CHAT -> SETTINGS_CHAT_ROUTE - 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 @@ -79,47 +83,40 @@ fun LibreChatNavHost( shareNavigationTrigger: Int = 0, navHostViewModel: NavHostViewModel = koinViewModel(), ) { - val navController = rememberNavController() - val navBackStackEntry by navController.currentBackStackEntryAsState() - val currentDestination = navBackStackEntry?.destination - val isLoggedIn by navHostViewModel.isLoggedIn.collectAsStateWithLifecycle() - val isInAuthFlow = currentDestination?.route == AUTH_GRAPH_ROUTE || - currentDestination?.parent?.route == AUTH_GRAPH_ROUTE + // Stable start key — auth redirect handled via LaunchedEffect below. + val backStack = rememberNavBackStack(navigationSavedStateConfig, NewChat) + val navigator = Navigator(backStack) + + // Redirect to auth if not logged in (on first composition only). + LaunchedEffect(Unit) { + if (!navHostViewModel.isLoggedIn.value) { + navigator.navigateToAuth() + } + } // Track active conversation from nav back stack - LaunchedEffect(navBackStackEntry) { - val route = navBackStackEntry?.destination?.route - val conversationId = if (route == CHAT_ROUTE) { - navBackStackEntry?.arguments?.getString("conversationId") - } else { - null - } + LaunchedEffect(navigator.currentRoute) { + val conversationId = (navigator.currentRoute as? Chat)?.conversationId navHostViewModel.setActiveConversation(conversationId) } // Handle session expiry LaunchedEffect(Unit) { navHostViewModel.sessionExpired.collect { - navController.navigate(AUTH_GRAPH_ROUTE) { - popUpTo(0) { inclusive = true } - } + navigator.navigateToAuth() } } - val startDestination = if (isLoggedIn) CHAT_GRAPH_ROUTE else AUTH_GRAPH_ROUTE - val isTablet = windowSizeClass?.widthSizeClass?.let { it >= WindowWidthSizeClass.Medium } ?: false if (isTablet) { TabletLayout( - navController = navController, + navigator = navigator, navHostViewModel = navHostViewModel, - startDestination = startDestination, - isInAuthFlow = isInAuthFlow, deepLinkUri = deepLinkUri, onDeepLinkConsumed = onDeepLinkConsumed, shareNavigationTrigger = shareNavigationTrigger, @@ -127,10 +124,8 @@ fun LibreChatNavHost( ) } else { PhoneLayout( - navController = navController, + navigator = navigator, navHostViewModel = navHostViewModel, - startDestination = startDestination, - isInAuthFlow = isInAuthFlow, deepLinkUri = deepLinkUri, onDeepLinkConsumed = onDeepLinkConsumed, shareNavigationTrigger = shareNavigationTrigger, @@ -191,10 +186,8 @@ private fun VersionMismatchDialog( @Composable private fun PhoneLayout( - navController: androidx.navigation.NavHostController, + navigator: Navigator, navHostViewModel: NavHostViewModel, - startDestination: String, - isInAuthFlow: Boolean, deepLinkUri: Uri?, onDeepLinkConsumed: () -> Unit, shareNavigationTrigger: Int, @@ -202,7 +195,6 @@ private fun PhoneLayout( ) { val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() - // Banner state only -- drawer state is collected inside DrawerContent itself val banners by navHostViewModel.banners.collectAsStateWithLifecycle() val dismissedBannerIds by navHostViewModel.dismissedBannerIds.collectAsStateWithLifecycle() @@ -212,7 +204,7 @@ private fun PhoneLayout( if (uri.scheme == "librechat" && uri.host == "conversation") { uri.lastPathSegment?.let { conversationId -> if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) { - navController.navigateToChat(conversationId) + navigator.navigateToChat(conversationId) } else { Logger.w { "Ignoring deep link with invalid conversation ID: $conversationId" } } @@ -222,21 +214,14 @@ private fun PhoneLayout( } } - // Handle share intent: if already on a chat screen, let the active ChatViewModel - // consume the shared content reactively. Only navigate to new chat when on a - // non-chat screen (e.g. settings, agents, files). + // Handle share intent LaunchedEffect(shareNavigationTrigger) { if (shareNavigationTrigger > 0) { - val currentRoute = navController.currentBackStackEntry?.destination?.route - val isOnChatScreen = currentRoute == CHAT_ROUTE || currentRoute == NEW_CHAT_ROUTE + val currentRoute = navigator.currentRoute + val isOnChatScreen = currentRoute is Chat || currentRoute is NewChat if (!isOnChatScreen) { - navController.navigate(NEW_CHAT_ROUTE) { - popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false } - launchSingleTop = true - } + navigator.navigateToTopLevel(NewChat) } - // If already on a chat screen, SharedIntentConsumer.shareAvailable - // will notify the active ChatViewModel to consume the content. } } @@ -249,57 +234,42 @@ private fun PhoneLayout( ModalNavigationDrawer( drawerState = drawerState, - gesturesEnabled = !isInAuthFlow, + gesturesEnabled = !navigator.isInAuthFlow, drawerContent = { ModalDrawerSheet { SidebarScaffold( viewModel = navHostViewModel, onNewChat = { scope.launch { drawerState.close() } - // Skip navigation if already on the new chat screen - val currentRoute = navController.currentBackStackEntry?.destination?.route - if (currentRoute != NEW_CHAT_ROUTE) { - navController.navigate(NEW_CHAT_ROUTE) { - popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false } - launchSingleTop = true - } + if (navigator.currentRoute !is NewChat) { + navigator.navigateToTopLevel(NewChat) } }, onConversationClick = { conversationId -> scope.launch { drawerState.close() } - navController.navigateToChat(conversationId) + navigator.navigateToChat(conversationId) }, onSettingsClick = { scope.launch { drawerState.close() } - navController.navigate(SETTINGS_TABBED_ROUTE) { - launchSingleTop = true - } + navigator.navigate(SettingsTabbed) }, onSettingsCategorySelected = { category -> - // Navigate to the settings sub-page but keep the drawer open - navController.navigate(category.toRoute()) { - launchSingleTop = true - } + navigator.navigate(category.toRoute()) }, onAgentsClick = { scope.launch { drawerState.close() } - navController.navigate(TopLevelDestination.AGENTS.route) { - launchSingleTop = true - } + navigator.navigate(AgentMarketplace) }, onFilesClick = { scope.launch { drawerState.close() } - navController.navigate(TopLevelDestination.FILES.route) { - launchSingleTop = true - } + navigator.navigate(Files) }, ) } }, ) { - // No bottom bar -- drawer is the primary navigation Column(modifier = modifier.fillMaxSize()) { - if (!isInAuthFlow && banners.isNotEmpty()) { + if (!navigator.isInAuthFlow && banners.isNotEmpty()) { BannerDisplay( banners = banners, dismissedIds = dismissedBannerIds, @@ -307,122 +277,100 @@ private fun PhoneLayout( modifier = Modifier.padding(top = 4.dp), ) } - NavHost( - navController = navController, - startDestination = startDestination, + MainNavDisplay( + navigator = navigator, + navHostViewModel = navHostViewModel, + onOpenDrawer = { scope.launch { drawerState.open() } }, modifier = Modifier.fillMaxSize(), - enterTransition = { - slideIntoContainer( - towards = AnimatedContentTransitionScope.SlideDirection.Start, - animationSpec = tween(300), - ) - }, - exitTransition = { - fadeOut(animationSpec = tween(150)) - }, - popEnterTransition = { - fadeIn( - initialAlpha = 0.5f, - animationSpec = tween(300, easing = LinearEasing), - ) + scaleIn( - initialScale = 0.92f, - animationSpec = tween(300, easing = LinearEasing), - ) - }, - popExitTransition = { - slideOut( - targetOffset = { IntOffset((it.width * 0.15f).toInt(), 0) }, - animationSpec = tween(300, easing = FastOutSlowInEasing), - ) + scaleOut( - targetScale = 0.92f, - animationSpec = tween(300, easing = LinearEasing), - ) - }, - ) { - authGraph( - navController = navController, - onAuthComplete = { - navHostViewModel.onAuthComplete() - navController.navigate(CHAT_GRAPH_ROUTE) { - popUpTo(AUTH_GRAPH_ROUTE) { inclusive = true } - } - }, - ) - chatGraph( - navController = navController, - onOpenDrawer = { - scope.launch { drawerState.open() } - }, - ) - conversationsGraph( - onConversationClick = { conversationId -> - navController.navigateToChat(conversationId) - }, - onNavigateToArchived = { - navController.navigate("conversations/archived") - }, - onNavigateBackFromArchived = { - navController.popBackStack() - }, - ) - agentsGraph( - onAgentClick = { agentId -> - navController.navigate("agents/$agentId") - }, - onBack = { navController.popBackStack() }, - onStartChat = { agentId -> - navController.navigate(NEW_CHAT_ROUTE) { - popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false } - launchSingleTop = true - } - }, - onCreateAgent = { - navController.navigate(AGENT_EDITOR_CREATE_ROUTE) - }, - onEditAgent = { agentId -> - navController.navigate("agents/editor/$agentId") - }, - ) - filesGraph() - settingsGraph( - onLogout = { - navHostViewModel.logout() - navController.navigate(AUTH_GRAPH_ROUTE) { - popUpTo(0) { inclusive = true } - } - }, - onNavigateBack = { navController.popBackStack() }, - onNavigateToArchived = { - navController.navigate("conversations/archived") { - launchSingleTop = true - } - }, - onNavigateToSharedLinks = { - navController.navigate(SHARED_LINKS_ROUTE) { - launchSingleTop = true - } - }, - onNavigateBackFromSharedLinks = { - navController.popBackStack() - }, - onNavigateToPresets = { - navController.navigate(PRESET_MANAGER_ROUTE) { - launchSingleTop = true - } - }, - onNavigateBackFromPresets = { - navController.popBackStack() - }, - onNavigateToApiKeys = { - navController.navigate(API_KEYS_ROUTE) { - launchSingleTop = true - } - }, - onNavigateBackFromApiKeys = { - navController.popBackStack() - }, - ) - } + ) } } } + +@Composable +internal fun MainNavDisplay( + navigator: Navigator, + navHostViewModel: NavHostViewModel, + onOpenDrawer: (() -> Unit)? = null, + modifier: Modifier = Modifier, +) { + NavDisplay( + backStack = navigator.backStack, + onBack = { navigator.goBack() }, + modifier = modifier, + transitionSpec = { + slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(300)) togetherWith + fadeOut(animationSpec = tween(150)) + }, + popTransitionSpec = { + (fadeIn(initialAlpha = 0.5f, animationSpec = tween(300, easing = LinearEasing)) + + scaleIn(initialScale = 0.92f, animationSpec = tween(300, easing = LinearEasing))) togetherWith + (slideOutHorizontally( + targetOffsetX = { (it * 0.15f).toInt() }, + animationSpec = tween(300, easing = FastOutSlowInEasing), + ) + scaleOut( + targetScale = 0.92f, + animationSpec = tween(300, easing = LinearEasing), + )) + }, + predictivePopTransitionSpec = { + (fadeIn(initialAlpha = 0.5f, animationSpec = tween(300, easing = LinearEasing)) + + scaleIn(initialScale = 0.92f, animationSpec = tween(300, easing = LinearEasing))) togetherWith + (slideOutHorizontally( + targetOffsetX = { (it * 0.15f).toInt() }, + animationSpec = tween(300, easing = FastOutSlowInEasing), + ) + scaleOut( + targetScale = 0.92f, + animationSpec = tween(300, easing = LinearEasing), + )) + }, + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + entryProvider = entryProvider { + authEntries( + onNavigate = { navigator.navigate(it) }, + onBack = { navigator.goBack() }, + onAuthComplete = { + navHostViewModel.onAuthComplete() + navigator.navigateToChat() + }, + ) + chatEntries( + onNavigate = { navigator.navigate(it) }, + onBack = { navigator.goBack() }, + onNavigateToChat = { navigator.navigateToChat(it) }, + onOpenDrawer = onOpenDrawer, + ) + conversationsEntries( + onConversationClick = { navigator.navigateToChat(it) }, + onNavigateToArchived = { navigator.navigate(ArchivedConversations) }, + onBack = { navigator.goBack() }, + ) + agentsEntries( + onNavigate = { navigator.navigate(it) }, + onBack = { navigator.goBack() }, + onStartChat = { _ -> navigator.navigateToTopLevel(NewChat) }, + ) + filesEntries( + onBack = { navigator.goBack() }, + ) + settingsEntries( + onNavigate = { navigator.navigate(it) }, + onBack = { navigator.goBack() }, + onLogout = { + navHostViewModel.logout() + navigator.navigateToAuth() + }, + onNavigateToArchived = { navigator.navigate(ArchivedConversations) }, + ) + memoriesEntry( + onBack = { navigator.goBack() }, + ) + mcpServersEntry( + onBack = { navigator.goBack() }, + ) + }, + ) +} diff --git a/app/src/main/kotlin/com/garfiec/librechat/navigation/NavigationSerializers.kt b/app/src/main/kotlin/com/garfiec/librechat/navigation/NavigationSerializers.kt new file mode 100644 index 0000000..e82989e --- /dev/null +++ b/app/src/main/kotlin/com/garfiec/librechat/navigation/NavigationSerializers.kt @@ -0,0 +1,32 @@ +package com.garfiec.librechat.navigation + +import androidx.savedstate.serialization.SavedStateConfiguration +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. + * Used by Nav 3's SavedStateConfiguration for type-safe state saving. + */ +val navigationSerializersModule: SerializersModule = + authSerializersModule + + chatSerializersModule + + conversationsSerializersModule + + agentsSerializersModule + + filesSerializersModule + + settingsSerializersModule + +/** + * Nav 3 [SavedStateConfiguration] with polymorphic serialization for all route types. + * Required for KMP (iOS) since Nav 3 cannot use reflection-based serialization on non-JVM platforms. + */ +val navigationSavedStateConfig = SavedStateConfiguration { + serializersModule = navigationSerializersModule +} diff --git a/app/src/main/kotlin/com/garfiec/librechat/navigation/Navigator.kt b/app/src/main/kotlin/com/garfiec/librechat/navigation/Navigator.kt new file mode 100644 index 0000000..a01a2da --- /dev/null +++ b/app/src/main/kotlin/com/garfiec/librechat/navigation/Navigator.kt @@ -0,0 +1,60 @@ +package com.garfiec.librechat.navigation + +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import com.garfiec.librechat.feature.auth.navigation.AuthRoute +import com.garfiec.librechat.feature.auth.navigation.ServerUrl +import com.garfiec.librechat.feature.chat.navigation.Chat +import com.garfiec.librechat.feature.chat.navigation.NewChat + +/** + * Encapsulates all back stack mutations for Nav 3 navigation. + * Follows UDF: UI events → Navigator → back stack state → NavDisplay recomposition. + */ +class Navigator(val backStack: NavBackStack) { + + val currentRoute: NavKey? get() = backStack.lastOrNull() + + val isInAuthFlow: Boolean get() = currentRoute is AuthRoute + + /** Navigate forward to a route. */ + fun navigate(route: NavKey) { + backStack.add(route) + } + + /** Pop the top entry. Safe on empty stacks. */ + fun goBack() { + backStack.removeLastOrNull() + } + + /** Navigate to a chat, replacing any current chat on the stack. */ + fun navigateToChat(conversationId: String) { + if (backStack.lastOrNull() is Chat) { + backStack.removeLastOrNull() + } + backStack.add(Chat(conversationId)) + } + + /** Navigate to a top-level destination, popping to root first. */ + fun navigateToTopLevel(route: NavKey) { + while (backStack.size > 1) { + backStack.removeLastOrNull() + } + if (backStack.lastOrNull()?.let { it::class } != route::class) { + backStack.removeLastOrNull() + backStack.add(route) + } + } + + /** Clear back stack and navigate to auth (session expiry / logout). */ + fun navigateToAuth() { + backStack.clear() + backStack.add(ServerUrl) + } + + /** Clear back stack and navigate to chat (auth complete). */ + fun navigateToChat() { + backStack.clear() + backStack.add(NewChat) + } +} diff --git a/app/src/main/kotlin/com/garfiec/librechat/navigation/TabletLayout.kt b/app/src/main/kotlin/com/garfiec/librechat/navigation/TabletLayout.kt index aafb685..56af52a 100644 --- a/app/src/main/kotlin/com/garfiec/librechat/navigation/TabletLayout.kt +++ b/app/src/main/kotlin/com/garfiec/librechat/navigation/TabletLayout.kt @@ -2,18 +2,9 @@ package com.garfiec.librechat.navigation import android.net.Uri import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.animation.slideOut import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -34,29 +25,16 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavHostController -import androidx.navigation.compose.NavHost +import androidx.navigation3.runtime.NavKey import com.garfiec.librechat.MainActivity 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.agentsGraph -import com.garfiec.librechat.feature.auth.navigation.AUTH_GRAPH_ROUTE -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_ROUTE -import com.garfiec.librechat.feature.chat.navigation.NEW_CHAT_ROUTE -import com.garfiec.librechat.feature.chat.navigation.chatGraph -import com.garfiec.librechat.feature.chat.navigation.navigateToChat -import com.garfiec.librechat.feature.conversations.navigation.conversationsGraph -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.PRESET_MANAGER_ROUTE -import com.garfiec.librechat.feature.settings.navigation.SETTINGS_TABBED_ROUTE -import com.garfiec.librechat.feature.settings.navigation.SHARED_LINKS_ROUTE -import com.garfiec.librechat.feature.settings.navigation.settingsGraph +import com.garfiec.librechat.feature.agents.navigation.AgentMarketplace +import com.garfiec.librechat.feature.chat.navigation.Chat +import com.garfiec.librechat.feature.chat.navigation.NewChat +import com.garfiec.librechat.feature.files.navigation.Files +import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed import kotlinx.coroutines.launch import co.touchlab.kermit.Logger @@ -67,10 +45,8 @@ private const val FLING_VELOCITY_THRESHOLD = 800f @Composable fun TabletLayout( - navController: NavHostController, + navigator: Navigator, navHostViewModel: NavHostViewModel, - startDestination: String, - isInAuthFlow: Boolean, deepLinkUri: Uri?, onDeepLinkConsumed: () -> Unit, modifier: Modifier = Modifier, @@ -81,8 +57,6 @@ fun TabletLayout( val dismissedBannerIds by navHostViewModel.dismissedBannerIds.collectAsStateWithLifecycle() // Persisted sidebar state from DataStore -- single source of truth in the ViewModel. - // The ViewModel's StateFlow initial value is read synchronously from DataStore, - // so the first composition already has the correct persisted state (no flicker). val isSidebarOpen by navHostViewModel.tabletSidebarOpen.collectAsStateWithLifecycle() // Whether swipe gesture is enabled (from settings) @@ -92,7 +66,6 @@ fun TabletLayout( val sidebarWidthPx = with(density) { SidebarWidth.toPx() } // Animatable tracks sidebar reveal in pixels: 0 = closed, sidebarWidthPx = open. - // During a drag it follows the finger; on release it animates to 0 or sidebarWidthPx. val sidebarOffset = remember { Animatable(if (isSidebarOpen) sidebarWidthPx else 0f) } val scope = rememberCoroutineScope() @@ -107,7 +80,7 @@ fun TabletLayout( if (uri.scheme == "librechat" && uri.host == "conversation") { uri.lastPathSegment?.let { conversationId -> if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) { - navController.navigateToChat(conversationId) + navigator.navigateToChat(conversationId) } else { Logger.w { "Ignoring deep link with invalid conversation ID: $conversationId" } } @@ -117,18 +90,13 @@ fun TabletLayout( } } - // Handle share intent: if already on a chat screen, let the active ChatViewModel - // consume the shared content reactively. Only navigate to new chat when on a - // non-chat screen (e.g. settings, agents, files). + // Handle share intent LaunchedEffect(shareNavigationTrigger) { if (shareNavigationTrigger > 0) { - val currentRoute = navController.currentBackStackEntry?.destination?.route - val isOnChatScreen = currentRoute == CHAT_ROUTE || currentRoute == NEW_CHAT_ROUTE + val currentRoute = navigator.currentRoute + val isOnChatScreen = currentRoute is Chat || currentRoute is NewChat if (!isOnChatScreen) { - navController.navigate(NEW_CHAT_ROUTE) { - popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false } - launchSingleTop = true - } + navigator.navigateToTopLevel(NewChat) } } } @@ -148,7 +116,7 @@ fun TabletLayout( } } - if (!isInAuthFlow) { + if (!navigator.isInAuthFlow) { val swipeModifier = if (gestureEnabled) { Modifier.pointerInput(Unit) { val velocityTracker = VelocityTracker() @@ -159,7 +127,6 @@ fun TabletLayout( onDragEnd = { val velocity = velocityTracker.calculateVelocity().x val currentOffset = sidebarOffset.value - // Decide target: fling velocity wins, otherwise use 50% threshold val shouldOpen = when { velocity > FLING_VELOCITY_THRESHOLD -> true velocity < -FLING_VELOCITY_THRESHOLD -> false @@ -177,7 +144,6 @@ fun TabletLayout( } }, onDragCancel = { - // Snap back to the current committed state val target = if (isSidebarOpen) sidebarWidthPx else 0f scope.launch { sidebarOffset.animateTo( @@ -214,37 +180,24 @@ fun TabletLayout( SidebarScaffold( viewModel = navHostViewModel, onNewChat = { - // Skip navigation if already on the new chat screen - val currentRoute = navController.currentBackStackEntry?.destination?.route - if (currentRoute != NEW_CHAT_ROUTE) { - navController.navigate(NEW_CHAT_ROUTE) { - popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false } - launchSingleTop = true - } + if (navigator.currentRoute !is NewChat) { + navigator.navigateToTopLevel(NewChat) } }, onConversationClick = { conversationId -> - navController.navigateToChat(conversationId) + navigator.navigateToChat(conversationId) }, onSettingsClick = { - navController.navigate(SETTINGS_TABBED_ROUTE) { - launchSingleTop = true - } + navigator.navigate(SettingsTabbed) }, onSettingsCategorySelected = { category -> - navController.navigate(category.toRoute()) { - launchSingleTop = true - } + navigator.navigate(category.toRoute()) }, onAgentsClick = { - navController.navigate(TopLevelDestination.AGENTS.route) { - launchSingleTop = true - } + navigator.navigate(AgentMarketplace) }, onFilesClick = { - navController.navigate(TopLevelDestination.FILES.route) { - launchSingleTop = true - } + navigator.navigate(Files) }, modifier = Modifier .width(SidebarWidth) @@ -256,9 +209,8 @@ fun TabletLayout( } // Slot 1: Main content -- resizes to fill remaining space MainContent( - navController = navController, + navigator = navigator, navHostViewModel = navHostViewModel, - startDestination = startDestination, isInAuthFlow = false, banners = banners, dismissedBannerIds = dismissedBannerIds, @@ -272,29 +224,24 @@ fun TabletLayout( val sidebarPx = SidebarWidth.roundToPx() val animatedPx = sidebarOffset.value.toInt() - // Sidebar: always measured at full 320dp width val sidebarPlaceable = measurables[0].measure( constraints.copy(minWidth = sidebarPx, maxWidth = sidebarPx), ) - // Main content: resizes to fill screen minus the visible sidebar portion val mainWidth = (constraints.maxWidth - animatedPx).coerceAtLeast(0) val mainPlaceable = measurables[1].measure( constraints.copy(minWidth = mainWidth, maxWidth = mainWidth), ) layout(constraints.maxWidth, constraints.maxHeight) { - // Sidebar slides: -320px (off-screen) -> 0px (fully visible) sidebarPlaceable.placeRelative(animatedPx - sidebarPx, 0) - // Main content starts right after the visible sidebar portion mainPlaceable.placeRelative(animatedPx, 0) } } } else { // Auth flow -- no sidebar, just main content MainContent( - navController = navController, + navigator = navigator, navHostViewModel = navHostViewModel, - startDestination = startDestination, isInAuthFlow = true, banners = banners, dismissedBannerIds = dismissedBannerIds, @@ -306,9 +253,8 @@ fun TabletLayout( @Composable private fun MainContent( - navController: NavHostController, + navigator: Navigator, navHostViewModel: NavHostViewModel, - startDestination: String, isInAuthFlow: Boolean, banners: List, dismissedBannerIds: Set, @@ -324,119 +270,11 @@ private fun MainContent( modifier = Modifier.padding(top = 4.dp), ) } - NavHost( - navController = navController, - startDestination = startDestination, + MainNavDisplay( + navigator = navigator, + navHostViewModel = navHostViewModel, + onOpenDrawer = onToggleDrawer, modifier = Modifier.fillMaxSize(), - enterTransition = { - slideIntoContainer( - towards = AnimatedContentTransitionScope.SlideDirection.Start, - animationSpec = tween(300), - ) - }, - exitTransition = { - fadeOut(animationSpec = tween(150)) - }, - popEnterTransition = { - fadeIn( - initialAlpha = 0.5f, - animationSpec = tween(300, easing = LinearEasing), - ) + scaleIn( - initialScale = 0.92f, - animationSpec = tween(300, easing = LinearEasing), - ) - }, - popExitTransition = { - slideOut( - targetOffset = { IntOffset((it.width * 0.15f).toInt(), 0) }, - animationSpec = tween(300, easing = FastOutSlowInEasing), - ) + scaleOut( - targetScale = 0.92f, - animationSpec = tween(300, easing = LinearEasing), - ) - }, - ) { - authGraph( - navController = navController, - onAuthComplete = { - navHostViewModel.onAuthComplete() - navController.navigate(CHAT_GRAPH_ROUTE) { - popUpTo(AUTH_GRAPH_ROUTE) { inclusive = true } - } - }, - ) - chatGraph( - navController = navController, - onOpenDrawer = onToggleDrawer, - ) - conversationsGraph( - onConversationClick = { conversationId -> - navController.navigateToChat(conversationId) - }, - onNavigateToArchived = { - navController.navigate("conversations/archived") - }, - onNavigateBackFromArchived = { - navController.popBackStack() - }, - ) - agentsGraph( - onAgentClick = { agentId -> - navController.navigate("agents/$agentId") - }, - onBack = { navController.popBackStack() }, - onStartChat = { agentId -> - navController.navigate(NEW_CHAT_ROUTE) { - popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false } - launchSingleTop = true - } - }, - onCreateAgent = { - navController.navigate(AGENT_EDITOR_CREATE_ROUTE) - }, - onEditAgent = { agentId -> - navController.navigate("agents/editor/$agentId") - }, - ) - filesGraph() - settingsGraph( - onLogout = { - navHostViewModel.logout() - navController.navigate(AUTH_GRAPH_ROUTE) { - popUpTo(0) { inclusive = true } - } - }, - onNavigateBack = { navController.popBackStack() }, - onNavigateToArchived = { - navController.navigate("conversations/archived") { - launchSingleTop = true - } - }, - onNavigateToSharedLinks = { - navController.navigate(SHARED_LINKS_ROUTE) { - launchSingleTop = true - } - }, - onNavigateBackFromSharedLinks = { - navController.popBackStack() - }, - onNavigateToPresets = { - navController.navigate(PRESET_MANAGER_ROUTE) { - launchSingleTop = true - } - }, - onNavigateBackFromPresets = { - navController.popBackStack() - }, - onNavigateToApiKeys = { - navController.navigate(API_KEYS_ROUTE) { - launchSingleTop = true - } - }, - onNavigateBackFromApiKeys = { - navController.popBackStack() - }, - ) - } + ) } } diff --git a/app/src/main/kotlin/com/garfiec/librechat/navigation/TopLevelDestination.kt b/app/src/main/kotlin/com/garfiec/librechat/navigation/TopLevelDestination.kt deleted file mode 100644 index 814a1a0..0000000 --- a/app/src/main/kotlin/com/garfiec/librechat/navigation/TopLevelDestination.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.garfiec.librechat.navigation - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Chat -import androidx.compose.material.icons.filled.Folder -import androidx.compose.material.icons.filled.Forum -import androidx.compose.material.icons.filled.SmartToy -import androidx.compose.ui.graphics.vector.ImageVector - -/** - * Destinations accessible from the navigation drawer footer. - * The primary navigation is now sidebar-first (drawer with conversation list), - * matching the web frontend pattern. Bottom nav has been removed entirely. - * - * Conversations are integrated into the drawer body (not a separate destination). - * Agents and Files are accessible via drawer footer links. - * Settings are accessed through the sidebar settings nav menu (see [SettingsCategory]). - */ -enum class TopLevelDestination( - val route: String, - val icon: ImageVector, - val label: String, -) { - CHAT("chat_graph", Icons.Default.Chat, "Chat"), - CONVERSATIONS("conversations", Icons.Default.Forum, "Conversations"), - AGENTS("agents", Icons.Default.SmartToy, "Agents"), - FILES("files", Icons.Default.Folder, "Files"), -} diff --git a/app/src/test/kotlin/com/garfiec/librechat/navigation/NavigatorTest.kt b/app/src/test/kotlin/com/garfiec/librechat/navigation/NavigatorTest.kt new file mode 100644 index 0000000..1898a16 --- /dev/null +++ b/app/src/test/kotlin/com/garfiec/librechat/navigation/NavigatorTest.kt @@ -0,0 +1,138 @@ +package com.garfiec.librechat.navigation + +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import com.garfiec.librechat.feature.agents.navigation.AgentMarketplace +import com.garfiec.librechat.feature.auth.navigation.Login +import com.garfiec.librechat.feature.auth.navigation.ServerUrl +import com.garfiec.librechat.feature.chat.navigation.Chat +import com.garfiec.librechat.feature.chat.navigation.NewChat +import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class NavigatorTest { + + private fun createNavigator(vararg keys: NavKey): Navigator { + return Navigator(NavBackStack(*keys)) + } + + @Test + fun `currentRoute returns last entry`() { + val navigator = createNavigator(NewChat) + assertEquals(NewChat, navigator.currentRoute) + } + + @Test + fun `currentRoute returns null for empty stack`() { + val navigator = createNavigator() + assertNull(navigator.currentRoute) + } + + @Test + fun `isInAuthFlow returns true for auth routes`() { + val navigator = createNavigator(ServerUrl) + assertTrue(navigator.isInAuthFlow) + } + + @Test + fun `isInAuthFlow returns true for nested auth routes`() { + val navigator = createNavigator(ServerUrl, Login) + assertTrue(navigator.isInAuthFlow) + } + + @Test + fun `isInAuthFlow returns false for non-auth routes`() { + val navigator = createNavigator(NewChat) + assertFalse(navigator.isInAuthFlow) + } + + @Test + fun `navigate adds route to back stack`() { + val navigator = createNavigator(NewChat) + navigator.navigate(SettingsTabbed) + assertEquals(listOf(NewChat, SettingsTabbed), navigator.backStack.toList()) + } + + @Test + fun `goBack removes top entry`() { + val navigator = createNavigator(NewChat, SettingsTabbed) + navigator.goBack() + assertEquals(listOf(NewChat), navigator.backStack.toList()) + } + + @Test + fun `goBack on empty stack does not crash`() { + val navigator = createNavigator() + navigator.goBack() + assertTrue(navigator.backStack.isEmpty()) + } + + @Test + fun `navigateToChat with conversationId adds Chat route`() { + val navigator = createNavigator(NewChat) + navigator.navigateToChat("conv-123") + assertEquals( + listOf(NewChat, Chat(conversationId = "conv-123")), + navigator.backStack.toList(), + ) + } + + @Test + fun `navigateToChat replaces current chat`() { + val navigator = createNavigator(NewChat, Chat("conv-1")) + navigator.navigateToChat("conv-2") + assertEquals( + listOf(NewChat, Chat(conversationId = "conv-2")), + navigator.backStack.toList(), + ) + } + + @Test + fun `navigateToChat does not replace non-chat route`() { + val navigator = createNavigator(NewChat, SettingsTabbed) + navigator.navigateToChat("conv-1") + assertEquals( + listOf(NewChat, SettingsTabbed, Chat(conversationId = "conv-1")), + navigator.backStack.toList(), + ) + } + + @Test + fun `navigateToTopLevel pops to root and replaces`() { + val navigator = createNavigator(NewChat, Chat("conv-1"), SettingsTabbed) + navigator.navigateToTopLevel(AgentMarketplace) + assertEquals(listOf(AgentMarketplace), navigator.backStack.toList()) + } + + @Test + fun `navigateToTopLevel with same route does not duplicate`() { + val navigator = createNavigator(NewChat) + navigator.navigateToTopLevel(NewChat) + assertEquals(listOf(NewChat), navigator.backStack.toList()) + } + + @Test + fun `navigateToTopLevel replaces different root`() { + val navigator = createNavigator(NewChat) + navigator.navigateToTopLevel(AgentMarketplace) + assertEquals(listOf(AgentMarketplace), navigator.backStack.toList()) + } + + @Test + fun `navigateToAuth clears stack and adds ServerUrl`() { + val navigator = createNavigator(NewChat, Chat("conv-1"), SettingsTabbed) + navigator.navigateToAuth() + assertEquals(listOf(ServerUrl), navigator.backStack.toList()) + } + + @Test + fun `navigateToChat no-arg clears stack and adds NewChat`() { + val navigator = createNavigator(ServerUrl, Login) + navigator.navigateToChat() + assertEquals(listOf(NewChat), navigator.backStack.toList()) + } +} diff --git a/build-logic/CLAUDE.md b/build-logic/CLAUDE.md index 1354b7b..49361f2 100644 --- a/build-logic/CLAUDE.md +++ b/build-logic/CLAUDE.md @@ -2,16 +2,31 @@ Convention plugins that apply consistent Gradle configuration across all modules. -## Convention Plugins (7 total) +## Convention Plugins (13 total) +### Android | Plugin ID | Class | What it does | |-----------|-------|-------------| -| `librechat.mobile.application` | `AndroidApplicationConventionPlugin` | AGP application plugin, compileSdk 35, minSdk 26, JDK 17 | +| `librechat.mobile.application` | `AndroidApplicationConventionPlugin` | AGP application plugin, compileSdk 36, minSdk 26, JDK 17 | | `librechat.mobile.library` | `AndroidLibraryConventionPlugin` | AGP library plugin, same SDK/JDK config | | `librechat.mobile.compose` | `AndroidComposeConventionPlugin` | Compose compiler, Compose BOM, Material 3 | | `librechat.mobile.koin` | `AndroidKoinConventionPlugin` | Koin core + Android dependencies | -| `librechat.mobile.feature` | `AndroidFeatureConventionPlugin` | Auto-applies: library + compose + koin + serialization | +| `librechat.mobile.feature` | `AndroidFeatureConventionPlugin` | Auto-applies: library + compose + koin + serialization + Nav 3 | | `librechat.mobile.room` | `AndroidRoomConventionPlugin` | Room + KSP, schema export config | +| `librechat.mobile.detekt` | `AndroidDetektConventionPlugin` | Detekt static analysis | + +### KMP +| Plugin ID | Class | What it does | +|-----------|-------|-------------| +| `librechat.kmp.library` | `KmpLibraryConventionPlugin` | KMP library setup (Android + iOS targets) | +| `librechat.kmp.compose` | `KmpComposeConventionPlugin` | Compose Multiplatform + Material 3 | +| `librechat.kmp.koin` | `KmpKoinConventionPlugin` | Koin multiplatform dependencies | +| `librechat.kmp.feature` | `KmpFeatureConventionPlugin` | Auto-applies: kmp library + compose + koin + serialization + Nav 3 | +| `librechat.kmp.room` | `KmpRoomConventionPlugin` | Room multiplatform + KSP | + +### Shared +| Plugin ID | Class | What it does | +|-----------|-------|-------------| | `librechat.kotlin.serialization` | `KotlinSerializationConventionPlugin` | Kotlinx Serialization plugin + JSON dependency | ## Critical: apply() Signature @@ -32,6 +47,6 @@ build.gradle.kts files. Use `libs.` accessors (e.g., `libs.koin.android`, `libs. ## Feature Module Convention -Feature modules use `id("librechat.mobile.feature")` which auto-applies library, compose, -koin, and serialization. They only need to add feature-specific dependencies. +Feature modules use `id("librechat.kmp.feature")` (KMP) or `id("librechat.mobile.feature")` (Android-only) which auto-applies library, compose, +koin, serialization, and Nav 3. They only need to add feature-specific dependencies. Feature modules depend on `:core:*` only, never on other feature modules. diff --git a/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt index 0c1f787..f69a446 100644 --- a/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt @@ -17,7 +17,8 @@ class AndroidFeatureConventionPlugin : Plugin { add("implementation", libs.findLibrary("koin-compose").get()) add("implementation", libs.findLibrary("koin-compose-viewmodel").get()) add("implementation", libs.findLibrary("koin-compose-viewmodel-navigation").get()) - add("implementation", libs.findLibrary("navigation-compose").get()) + add("implementation", libs.findLibrary("navigation3-ui-kmp").get()) + add("implementation", libs.findLibrary("lifecycle-viewmodel-navigation3-kmp").get()) add("implementation", libs.findBundle("lifecycle").get()) } } diff --git a/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt index 16d2efa..6dc95d2 100644 --- a/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt @@ -20,9 +20,10 @@ class KmpFeatureConventionPlugin : Plugin { implementation(libs.findLibrary("koin-compose").get()) implementation(libs.findLibrary("koin-compose-viewmodel").get()) implementation(libs.findLibrary("koin-compose-viewmodel-navigation").get()) - implementation(libs.findLibrary("navigation-compose-kmp").get()) + implementation(libs.findLibrary("navigation3-ui-kmp").get()) implementation(libs.findLibrary("lifecycle-runtime-compose-kmp").get()) implementation(libs.findLibrary("lifecycle-viewmodel-compose-kmp").get()) + implementation(libs.findLibrary("lifecycle-viewmodel-navigation3-kmp").get()) } } } diff --git a/core/ui/CLAUDE.md b/core/ui/CLAUDE.md index 00d2822..bd8eb85 100644 --- a/core/ui/CLAUDE.md +++ b/core/ui/CLAUDE.md @@ -73,7 +73,7 @@ These rules apply to ALL composables across feature modules, not just core:ui. 3. **Mark UI state classes `@Immutable`.** This lets the Compose compiler skip recomposition when the reference hasn't changed. All fields must be `val` with stable types. -4. **Collect state at the narrowest scope.** If only `DrawerContent` uses drawer state, collect inside `DrawerContent` — not in the parent `PhoneLayout` which also owns the NavHost. State changes should only recompose the composable that reads them. +4. **Collect state at the narrowest scope.** If only `DrawerContent` uses drawer state, collect inside `DrawerContent` — not in the parent `PhoneLayout` which also owns the NavDisplay. State changes should only recompose the composable that reads them. 5. **Use `SharingStarted.Eagerly`** for UI state that should be ready before the composable subscribes (avoids empty→populated two-phase render jank on first frame). diff --git a/core/ui/src/androidMain/kotlin/com/garfiec/librechat/core/ui/components/ScreenTransitionWrapper.kt b/core/ui/src/androidMain/kotlin/com/garfiec/librechat/core/ui/components/ScreenTransitionWrapper.kt deleted file mode 100644 index 617fdad..0000000 --- a/core/ui/src/androidMain/kotlin/com/garfiec/librechat/core/ui/components/ScreenTransitionWrapper.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.garfiec.librechat.core.ui.components - -import android.os.Build -import android.view.RoundedCorner -import androidx.compose.animation.EnterExitState -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.Transition -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.unit.dp - -private const val FALLBACK_CORNER_RADIUS_DP = 24f - -/** - * Wraps screen content to apply rounded corners during navigation transitions. - * Pass the [transition] from AnimatedVisibilityScope inside a composable() block. - */ -@Composable -actual fun ScreenTransitionWrapper( - transition: Transition, - modifier: Modifier, - content: @Composable () -> Unit, -) { - val screenCornerRadiusDp = rememberScreenCornerRadiusDp() - - val cornerRadius by transition.animateFloat( - transitionSpec = { tween(300, easing = LinearEasing) }, - label = "screenCorner", - ) { state -> - when (state) { - EnterExitState.PreEnter -> screenCornerRadiusDp - EnterExitState.Visible -> 0f - EnterExitState.PostExit -> screenCornerRadiusDp - } - } - Box( - modifier = modifier - .fillMaxSize() - .clip(RoundedCornerShape(cornerRadius.dp)), - ) { - content() - } -} - -@Composable -private fun rememberScreenCornerRadiusDp(): Float { - val view = LocalView.current - val density = LocalDensity.current - return remember(view, density) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val insets = view.rootWindowInsets - val topLeft = insets?.getRoundedCorner(RoundedCorner.POSITION_TOP_LEFT) - if (topLeft != null && topLeft.radius > 0) { - with(density) { topLeft.radius.toDp().value } - } else { - FALLBACK_CORNER_RADIUS_DP - } - } else { - FALLBACK_CORNER_RADIUS_DP - } - } -} diff --git a/core/ui/src/commonMain/kotlin/com/garfiec/librechat/core/ui/components/ScreenTransitionWrapper.kt b/core/ui/src/commonMain/kotlin/com/garfiec/librechat/core/ui/components/ScreenTransitionWrapper.kt deleted file mode 100644 index 21e5798..0000000 --- a/core/ui/src/commonMain/kotlin/com/garfiec/librechat/core/ui/components/ScreenTransitionWrapper.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.garfiec.librechat.core.ui.components - -import androidx.compose.animation.EnterExitState -import androidx.compose.animation.core.Transition -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier - -/** - * Wraps screen content to apply rounded corners during navigation transitions. - * On Android, reads the device screen corner radius for a polished effect. - * On iOS, renders content directly (no transition animation needed). - */ -@Composable -expect fun ScreenTransitionWrapper( - transition: Transition, - modifier: Modifier = Modifier, - content: @Composable () -> Unit, -) diff --git a/core/ui/src/iosMain/kotlin/com/garfiec/librechat/core/ui/components/ScreenTransitionWrapper.ios.kt b/core/ui/src/iosMain/kotlin/com/garfiec/librechat/core/ui/components/ScreenTransitionWrapper.ios.kt deleted file mode 100644 index 13fd3aa..0000000 --- a/core/ui/src/iosMain/kotlin/com/garfiec/librechat/core/ui/components/ScreenTransitionWrapper.ios.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.garfiec.librechat.core.ui.components - -import androidx.compose.animation.EnterExitState -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.Transition -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer - -@Composable -actual fun ScreenTransitionWrapper( - transition: Transition, - modifier: Modifier, - content: @Composable () -> Unit, -) { - val alpha by transition.animateFloat( - transitionSpec = { tween(300, easing = LinearEasing) }, - label = "screenAlpha", - ) { state -> - when (state) { - EnterExitState.PreEnter -> 0f - EnterExitState.Visible -> 1f - EnterExitState.PostExit -> 0f - } - } - Box( - modifier = modifier - .fillMaxSize() - .graphicsLayer { this.alpha = alpha }, - ) { - content() - } -} diff --git a/feature/agents/CLAUDE.md b/feature/agents/CLAUDE.md index b5965eb..3376aef 100644 --- a/feature/agents/CLAUDE.md +++ b/feature/agents/CLAUDE.md @@ -5,8 +5,9 @@ - **AgentDetailScreen** -- full agent info with "Start Chat" action ## Navigation -- `AGENTS_ROUTE` ("agents") -> marketplace grid -- `AGENT_DETAIL_ROUTE` ("agents/{agentId}") -> detail view +- Sealed interface: `AgentsRoute : NavKey` with typed route classes +- Routes: `AgentMarketplace` (grid), `AgentDetail(agentId: String)` (detail), `AgentEditorCreate`, `AgentEditorEdit(agentId: String)` (all `@Serializable`) +- Feature entries registered via `EntryProviderScope.agentsEntries()` - `onStartChat(agentId)` callback navigates to chat with the selected agent ## Marketplace ViewModel diff --git a/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/di/AgentsModule.kt b/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/di/AgentsModule.kt index a06dd3b..71b226b 100644 --- a/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/di/AgentsModule.kt +++ b/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/di/AgentsModule.kt @@ -14,14 +14,22 @@ val agentsModule = module { includes(agentsPlatformModule) viewModelOf(::AgentMarketplaceViewModel) - viewModelOf(::AgentDetailViewModel) - viewModel { + viewModel { params -> + AgentDetailViewModel( + savedStateHandle = get(), + agentRepository = get(), + serverDataStore = get(), + initialAgentId = params.getOrNull(), + ) + } + viewModel { params -> AgentEditorViewModel( savedStateHandle = get(), agentRepository = get(), configRepository = get(), mcpRepository = get(), contentReader = get(), + initialAgentId = params.getOrNull(), ) } } diff --git a/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/navigation/AgentsNavigation.kt b/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/navigation/AgentsNavigation.kt index 90ce060..32566ff 100644 --- a/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/navigation/AgentsNavigation.kt +++ b/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/navigation/AgentsNavigation.kt @@ -1,60 +1,64 @@ package com.garfiec.librechat.feature.agents.navigation -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavType -import androidx.navigation.compose.composable -import androidx.navigation.navArgument +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey import com.garfiec.librechat.feature.agents.screen.AgentDetailScreen import com.garfiec.librechat.feature.agents.screen.AgentEditorScreen 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" -const val AGENT_DETAIL_ROUTE = "agents/{agentId}" -const val AGENT_EDITOR_CREATE_ROUTE = "agents/editor/create" -const val AGENT_EDITOR_EDIT_ROUTE = "agents/editor/{agentId}" +@Serializable sealed interface AgentsRoute : NavKey -fun NavGraphBuilder.agentsGraph( - onAgentClick: (String) -> Unit, +@Serializable data object AgentMarketplace : AgentsRoute +@Serializable data class AgentDetail(val agentId: String) : AgentsRoute +@Serializable data object AgentEditorCreate : AgentsRoute +@Serializable data class AgentEditorEdit(val agentId: String) : AgentsRoute + +fun EntryProviderScope.agentsEntries( + onNavigate: (NavKey) -> Unit, onBack: () -> Unit, onStartChat: (String) -> Unit, - onCreateAgent: () -> Unit, - onEditAgent: (String) -> Unit, ) { - composable(AGENTS_ROUTE) { + entry { AgentMarketplaceScreen( - onAgentClick = onAgentClick, - onCreateAgent = onCreateAgent, + onAgentClick = { agentId -> onNavigate(AgentDetail(agentId = agentId)) }, + onCreateAgent = { onNavigate(AgentEditorCreate) }, onBack = onBack, ) } - composable( - route = AGENT_DETAIL_ROUTE, - arguments = listOf( - navArgument("agentId") { type = NavType.StringType }, - ), - ) { + entry { key -> AgentDetailScreen( onBack = onBack, onStartChat = onStartChat, - onEdit = onEditAgent, - onDuplicated = onAgentClick, + onEdit = { agentId -> onNavigate(AgentEditorEdit(agentId = agentId)) }, + onDuplicated = { agentId -> onNavigate(AgentDetail(agentId = agentId)) }, + agentId = key.agentId, ) } - composable(AGENT_EDITOR_CREATE_ROUTE) { + entry { AgentEditorScreen( onBack = onBack, - onSaved = { agentId -> onAgentClick(agentId) }, + onSaved = { agentId -> onNavigate(AgentDetail(agentId = agentId)) }, + agentId = null, ) } - composable( - route = AGENT_EDITOR_EDIT_ROUTE, - arguments = listOf( - navArgument("agentId") { type = NavType.StringType }, - ), - ) { + entry { key -> AgentEditorScreen( onBack = onBack, - onSaved = { agentId -> onAgentClick(agentId) }, + onSaved = { agentId -> onNavigate(AgentDetail(agentId = agentId)) }, + agentId = key.agentId, ) } } + +val agentsSerializersModule = SerializersModule { + polymorphic(NavKey::class) { + subclass(AgentMarketplace::class, AgentMarketplace.serializer()) + subclass(AgentDetail::class, AgentDetail.serializer()) + subclass(AgentEditorCreate::class, AgentEditorCreate.serializer()) + subclass(AgentEditorEdit::class, AgentEditorEdit.serializer()) + } +} diff --git a/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/screen/AgentDetailScreen.kt b/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/screen/AgentDetailScreen.kt index 45e0c7c..3e4ab1a 100644 --- a/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/screen/AgentDetailScreen.kt +++ b/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/screen/AgentDetailScreen.kt @@ -56,6 +56,7 @@ import librechat_mobile.feature.agents.generated.resources.* import com.garfiec.librechat.feature.agents.viewmodel.AgentDetailEvent import com.garfiec.librechat.feature.agents.viewmodel.AgentDetailViewModel import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @@ -65,8 +66,9 @@ fun AgentDetailScreen( onStartChat: (String) -> Unit, onEdit: (String) -> Unit, onDuplicated: (String) -> Unit, + agentId: String? = null, modifier: Modifier = Modifier, - viewModel: AgentDetailViewModel = koinViewModel(), + viewModel: AgentDetailViewModel = koinViewModel { parametersOf(agentId) }, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() diff --git a/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/screen/AgentEditorScreen.kt b/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/screen/AgentEditorScreen.kt index 00eb10f..ecc98f4 100644 --- a/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/screen/AgentEditorScreen.kt +++ b/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/screen/AgentEditorScreen.kt @@ -71,16 +71,18 @@ import com.garfiec.librechat.feature.agents.components.ToolSelectDialog import com.garfiec.librechat.feature.agents.viewmodel.AgentEditorEvent import com.garfiec.librechat.feature.agents.viewmodel.AgentEditorViewModel import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf @OptIn(ExperimentalMaterial3Api::class) @Composable fun AgentEditorScreen( onBack: () -> Unit, onSaved: (String) -> Unit, + agentId: String? = null, modifier: Modifier = Modifier, onDeleted: () -> Unit = onBack, onDuplicated: (String) -> Unit = onSaved, - viewModel: AgentEditorViewModel = koinViewModel(), + viewModel: AgentEditorViewModel = koinViewModel { parametersOf(agentId) }, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() var showToolDialog by rememberSaveable { mutableStateOf(false) } diff --git a/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/viewmodel/AgentDetailViewModel.kt b/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/viewmodel/AgentDetailViewModel.kt index 0de20b2..6eaa69d 100644 --- a/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/viewmodel/AgentDetailViewModel.kt +++ b/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/viewmodel/AgentDetailViewModel.kt @@ -36,9 +36,10 @@ class AgentDetailViewModel( savedStateHandle: SavedStateHandle, private val agentRepository: AgentRepository, private val serverDataStore: ServerDataStore, + initialAgentId: String? = null, ) : ViewModel() { - private val agentId: String = checkNotNull(savedStateHandle["agentId"]) + private val agentId: String = checkNotNull(initialAgentId) { "agentId must be provided" } private val _uiState = MutableStateFlow(AgentDetailUiState()) val uiState: StateFlow = _uiState.asStateFlow() diff --git a/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/viewmodel/AgentEditorViewModel.kt b/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/viewmodel/AgentEditorViewModel.kt index b339133..0078e00 100644 --- a/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/viewmodel/AgentEditorViewModel.kt +++ b/feature/agents/src/commonMain/kotlin/com/garfiec/librechat/feature/agents/viewmodel/AgentEditorViewModel.kt @@ -107,9 +107,10 @@ class AgentEditorViewModel( private val configRepository: ConfigRepository, private val mcpRepository: McpRepository, private val contentReader: ContentReader, + initialAgentId: String? = null, ) : ViewModel() { - private val editAgentId: String? = savedStateHandle["agentId"] + private val editAgentId: String? = initialAgentId private val _uiState = MutableStateFlow( AgentEditorUiState( diff --git a/feature/auth/CLAUDE.md b/feature/auth/CLAUDE.md index 556767b..acf4e25 100644 --- a/feature/auth/CLAUDE.md +++ b/feature/auth/CLAUDE.md @@ -8,10 +8,12 @@ - **TwoFactorScreen** -- 6-digit OTP input with backup code fallback ## Navigation -- Graph route: `AUTH_GRAPH_ROUTE` ("auth_graph"), start destination: `SERVER_URL_ROUTE` -- Flow: ServerUrl -> Login -> (2FA if `tempToken` returned) -> `onAuthComplete` +- Sealed interface: `AuthRoute : NavKey` with typed route classes +- Routes: `ServerUrl`, `Login`, `Register`, `ForgotPassword`, `TwoFactor(tempToken)`, `VerifyEmail(email)`, `ResetPassword(userId, token)`, `Terms` (all `@Serializable`) +- Feature entries registered via `EntryProviderScope.authEntries()` +- Flow: `ServerUrl` → `Login` → (2FA if `tempToken` returned) → `onAuthComplete` - Register and ForgotPassword are lateral routes from Login -- `TWO_FACTOR_ROUTE` takes `{tempToken}` nav argument +- `TwoFactor(val tempToken: String)` data class carries the nav argument directly ## OAuth Flow - `OAuthManager` opens Chrome Custom Tabs to `{serverUrl}/api/oauth/{provider}` @@ -37,7 +39,7 @@ ### Terms Screen - `TermsScreen` + `TermsViewModel` — displays server terms, "I Accept" button -- Route: `TERMS_ROUTE = "auth/terms"` in `AuthNavigation.kt` +- Route: `Terms` data object (part of `AuthRoute` sealed interface) in `AuthNavigation.kt` - Loads terms text via `UserRepository`, posts acceptance on confirm - `TermsViewModel.consumeAccepted()` resets navigation trigger - **Gotcha**: Terms check should happen after login if `startupConfig.requireTerms` is true diff --git a/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/di/AuthModule.kt b/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/di/AuthModule.kt index 789f2e3..27d7a6e 100644 --- a/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/di/AuthModule.kt +++ b/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/di/AuthModule.kt @@ -9,6 +9,7 @@ import com.garfiec.librechat.feature.auth.viewmodel.TermsViewModel import com.garfiec.librechat.feature.auth.viewmodel.TwoFactorViewModel import com.garfiec.librechat.feature.auth.viewmodel.VerifyEmailViewModel import org.koin.core.module.Module +import org.koin.core.module.dsl.viewModel import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module @@ -19,9 +20,28 @@ val authModule = module { viewModelOf(::ServerUrlViewModel) viewModelOf(::LoginViewModel) viewModelOf(::RegisterViewModel) - viewModelOf(::VerifyEmailViewModel) + viewModel { params -> + VerifyEmailViewModel( + savedStateHandle = get(), + userRepository = get(), + initialEmail = params.getOrNull(), + ) + } viewModelOf(::ForgotPasswordViewModel) - viewModelOf(::ResetPasswordViewModel) - viewModelOf(::TwoFactorViewModel) + viewModel { params -> + ResetPasswordViewModel( + savedStateHandle = get(), + authRepository = get(), + initialUserId = params.getOrNull(), + initialToken = params.getOrNull(), + ) + } + viewModel { params -> + TwoFactorViewModel( + savedStateHandle = get(), + authRepository = get(), + initialTempToken = params.getOrNull(), + ) + } viewModelOf(::TermsViewModel) } diff --git a/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/navigation/AuthNavigation.kt b/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/navigation/AuthNavigation.kt index 1f95540..1a81623 100644 --- a/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/navigation/AuthNavigation.kt +++ b/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/navigation/AuthNavigation.kt @@ -1,12 +1,7 @@ package com.garfiec.librechat.feature.auth.navigation -import androidx.navigation.NavController -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavType -import androidx.navigation.compose.composable -import androidx.navigation.navArgument -import androidx.navigation.navigation -import com.garfiec.librechat.core.ui.components.ScreenTransitionWrapper +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey import com.garfiec.librechat.feature.auth.screen.ForgotPasswordScreen import com.garfiec.librechat.feature.auth.screen.LoginScreen import com.garfiec.librechat.feature.auth.screen.RegisterScreen @@ -15,123 +10,92 @@ import com.garfiec.librechat.feature.auth.screen.ServerUrlScreen import com.garfiec.librechat.feature.auth.screen.TermsScreen import com.garfiec.librechat.feature.auth.screen.TwoFactorScreen import com.garfiec.librechat.feature.auth.screen.VerifyEmailScreen -const val AUTH_GRAPH_ROUTE = "auth_graph" -const val SERVER_URL_ROUTE = "server_url" -const val LOGIN_ROUTE = "login" -const val REGISTER_ROUTE = "register" -const val FORGOT_PASSWORD_ROUTE = "forgot_password" -const val TWO_FACTOR_ROUTE = "two_factor/{tempToken}" -const val VERIFY_EMAIL_ROUTE = "verify_email/{email}" -const val TERMS_ROUTE = "auth/terms" -const val RESET_PASSWORD_ROUTE = "reset_password/{userId}/{token}" +import kotlinx.serialization.Serializable +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic +import kotlinx.serialization.modules.subclass -fun NavController.navigateToVerifyEmail(email: String) { - navigate("verify_email/${encodeNavArg(email)}") -} +@Serializable sealed interface AuthRoute : NavKey -fun NavController.navigateToResetPassword(userId: String, token: String) { - navigate("reset_password/${encodeNavArg(userId)}/${encodeNavArg(token)}") -} +@Serializable data object ServerUrl : AuthRoute +@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 -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( - navController: NavController, +fun EntryProviderScope.authEntries( + onNavigate: (NavKey) -> Unit, + onBack: () -> Unit, onAuthComplete: () -> Unit, ) { - navigation(startDestination = SERVER_URL_ROUTE, route = AUTH_GRAPH_ROUTE) { - composable(SERVER_URL_ROUTE) { - ScreenTransitionWrapper(transition) { - ServerUrlScreen( - onServerValidated = { navController.navigate(LOGIN_ROUTE) }, - ) - } - } - composable(LOGIN_ROUTE) { - ScreenTransitionWrapper(transition) { - LoginScreen( - onLoginSuccess = onAuthComplete, - onNavigateToRegister = { navController.navigate(REGISTER_ROUTE) }, - onNavigateToForgotPassword = { navController.navigate(FORGOT_PASSWORD_ROUTE) }, - onNavigateToTwoFactor = { tempToken -> - navController.navigate("two_factor/$tempToken") - }, - ) - } - } - composable(REGISTER_ROUTE) { - ScreenTransitionWrapper(transition) { - RegisterScreen( - onRegistered = { navController.popBackStack(LOGIN_ROUTE, inclusive = false) }, - onNavigateToLogin = { navController.popBackStack() }, - ) - } - } - composable(FORGOT_PASSWORD_ROUTE) { - ScreenTransitionWrapper(transition) { - ForgotPasswordScreen( - onBack = { navController.popBackStack() }, - ) - } - } - composable( - route = TWO_FACTOR_ROUTE, - arguments = listOf(navArgument("tempToken") { type = NavType.StringType }), - ) { - ScreenTransitionWrapper(transition) { - TwoFactorScreen( - onVerified = onAuthComplete, - onBack = { navController.popBackStack() }, - ) - } - } - composable( - route = VERIFY_EMAIL_ROUTE, - arguments = listOf(navArgument("email") { type = NavType.StringType }), - ) { - ScreenTransitionWrapper(transition) { - VerifyEmailScreen( - onVerified = { navController.popBackStack(LOGIN_ROUTE, inclusive = false) }, - onBack = { navController.popBackStack() }, - ) - } - } - composable(TERMS_ROUTE) { - ScreenTransitionWrapper(transition) { - TermsScreen( - onAccepted = onAuthComplete, - onBack = { navController.popBackStack() }, - ) - } - } - composable( - route = RESET_PASSWORD_ROUTE, - arguments = listOf( - navArgument("userId") { type = NavType.StringType }, - navArgument("token") { type = NavType.StringType }, - ), - ) { - ScreenTransitionWrapper(transition) { - ResetPasswordScreen( - onResetComplete = { navController.popBackStack(LOGIN_ROUTE, inclusive = false) }, - onBack = { navController.popBackStack() }, - ) - } - } + entry { + ServerUrlScreen( + onServerValidated = { onNavigate(Login) }, + ) + } + entry { + LoginScreen( + onLoginSuccess = onAuthComplete, + onNavigateToRegister = { onNavigate(Register) }, + onNavigateToForgotPassword = { onNavigate(ForgotPassword) }, + onNavigateToTwoFactor = { tempToken -> + onNavigate(TwoFactor(tempToken = tempToken)) + }, + ) + } + entry { + RegisterScreen( + onRegistered = onBack, + onNavigateToLogin = onBack, + ) + } + entry { + ForgotPasswordScreen( + onBack = onBack, + ) + } + entry { key -> + TwoFactorScreen( + onVerified = onAuthComplete, + onBack = onBack, + tempToken = key.tempToken, + ) + } + entry { key -> + VerifyEmailScreen( + onVerified = onBack, + onBack = onBack, + email = key.email, + ) + } + entry { + TermsScreen( + onAccepted = onAuthComplete, + onBack = onBack, + ) + } + entry { key -> + ResetPasswordScreen( + onResetComplete = onBack, + onBack = onBack, + userId = key.userId, + token = key.token, + ) + } +} + +val authSerializersModule = SerializersModule { + polymorphic(NavKey::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()) } } diff --git a/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/screen/ResetPasswordScreen.kt b/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/screen/ResetPasswordScreen.kt index 83c41f6..4cc0ee8 100644 --- a/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/screen/ResetPasswordScreen.kt +++ b/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/screen/ResetPasswordScreen.kt @@ -35,14 +35,17 @@ import librechat_mobile.feature.auth.generated.resources.Res import librechat_mobile.feature.auth.generated.resources.* import com.garfiec.librechat.feature.auth.viewmodel.ResetPasswordViewModel import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf @OptIn(ExperimentalMaterial3Api::class) @Composable fun ResetPasswordScreen( onResetComplete: () -> Unit, onBack: () -> Unit, + userId: String? = null, + token: String? = null, modifier: Modifier = Modifier, - viewModel: ResetPasswordViewModel = koinViewModel(), + viewModel: ResetPasswordViewModel = koinViewModel { parametersOf(userId, token) }, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() diff --git a/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/screen/TwoFactorScreen.kt b/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/screen/TwoFactorScreen.kt index 9419d3a..92c6b68 100644 --- a/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/screen/TwoFactorScreen.kt +++ b/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/screen/TwoFactorScreen.kt @@ -42,14 +42,16 @@ import librechat_mobile.feature.auth.generated.resources.Res import librechat_mobile.feature.auth.generated.resources.* import com.garfiec.librechat.feature.auth.viewmodel.TwoFactorViewModel import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf @OptIn(ExperimentalMaterial3Api::class) @Composable fun TwoFactorScreen( onVerified: () -> Unit, onBack: () -> Unit, + tempToken: String? = null, modifier: Modifier = Modifier, - viewModel: TwoFactorViewModel = koinViewModel(), + viewModel: TwoFactorViewModel = koinViewModel { parametersOf(tempToken) }, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() diff --git a/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/screen/VerifyEmailScreen.kt b/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/screen/VerifyEmailScreen.kt index 280bba6..31af0f3 100644 --- a/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/screen/VerifyEmailScreen.kt +++ b/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/screen/VerifyEmailScreen.kt @@ -34,14 +34,16 @@ import librechat_mobile.feature.auth.generated.resources.Res import librechat_mobile.feature.auth.generated.resources.* import com.garfiec.librechat.feature.auth.viewmodel.VerifyEmailViewModel import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf @OptIn(ExperimentalMaterial3Api::class) @Composable fun VerifyEmailScreen( onVerified: () -> Unit, onBack: () -> Unit, + email: String? = null, modifier: Modifier = Modifier, - viewModel: VerifyEmailViewModel = koinViewModel(), + viewModel: VerifyEmailViewModel = koinViewModel { parametersOf(email) }, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val email = uiState.email diff --git a/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/viewmodel/ResetPasswordViewModel.kt b/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/viewmodel/ResetPasswordViewModel.kt index 93c3e3b..650cbcf 100644 --- a/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/viewmodel/ResetPasswordViewModel.kt +++ b/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/viewmodel/ResetPasswordViewModel.kt @@ -23,10 +23,12 @@ data class ResetPasswordUiState( class ResetPasswordViewModel( savedStateHandle: SavedStateHandle, private val authRepository: AuthRepository, + initialUserId: String? = null, + initialToken: String? = null, ) : ViewModel() { - private val userId: String = savedStateHandle["userId"] ?: "" - private val token: String = savedStateHandle["token"] ?: "" + private val userId: String = initialUserId ?: "" + private val token: String = initialToken ?: "" private val _uiState = MutableStateFlow(ResetPasswordUiState()) val uiState: StateFlow = _uiState.asStateFlow() diff --git a/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/viewmodel/TwoFactorViewModel.kt b/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/viewmodel/TwoFactorViewModel.kt index 6357807..2b429de 100644 --- a/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/viewmodel/TwoFactorViewModel.kt +++ b/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/viewmodel/TwoFactorViewModel.kt @@ -24,9 +24,10 @@ data class TwoFactorUiState( class TwoFactorViewModel( savedStateHandle: SavedStateHandle, private val authRepository: AuthRepository, + initialTempToken: String? = null, ) : ViewModel() { - private val tempToken: String = savedStateHandle["tempToken"] ?: "" + private val tempToken: String = initialTempToken ?: "" private val _uiState = MutableStateFlow(TwoFactorUiState()) val uiState: StateFlow = _uiState.asStateFlow() diff --git a/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/viewmodel/VerifyEmailViewModel.kt b/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/viewmodel/VerifyEmailViewModel.kt index 737f16e..016f001 100644 --- a/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/viewmodel/VerifyEmailViewModel.kt +++ b/feature/auth/src/commonMain/kotlin/com/garfiec/librechat/feature/auth/viewmodel/VerifyEmailViewModel.kt @@ -26,10 +26,11 @@ data class VerifyEmailUiState( class VerifyEmailViewModel( savedStateHandle: SavedStateHandle, private val userRepository: UserRepository, + initialEmail: String? = null, ) : ViewModel() { private val _uiState = MutableStateFlow( - VerifyEmailUiState(email = savedStateHandle["email"] ?: ""), + VerifyEmailUiState(email = initialEmail ?: ""), ) val uiState: StateFlow = _uiState.asStateFlow() diff --git a/feature/chat/CLAUDE.md b/feature/chat/CLAUDE.md index 0df2fe0..647b66c 100644 --- a/feature/chat/CLAUDE.md +++ b/feature/chat/CLAUDE.md @@ -7,10 +7,11 @@ - **Active**: message list + streaming content + input area ## Navigation -- `CHAT_GRAPH_ROUTE` with `NEW_CHAT_ROUTE` (landing) and `CHAT_ROUTE` ("chat/{conversationId}") -- `PROMPTS_LIBRARY_ROUTE` is co-located here for prompts library screen -- When a new conversation starts from the landing page, `pendingNavigationConversationId` triggers navigation to `chat/{id}` after the first stream completes (data persisted to Room), keeping `new_chat` in the back stack -- `navigateToChat()` replaces the current chat entry when switching between chats so back returns to `new_chat` +- Sealed interface: `ChatRoute : NavKey` with typed route classes +- Routes: `NewChat` (landing), `Chat(conversationId: String? = null)`, `PromptsLibrary`, `PromptEditor(groupId: String? = null)` (all `@Serializable`) +- Feature entries registered via `EntryProviderScope.chatEntries()` +- When a new conversation starts from the landing page, `pendingNavigationConversationId` triggers navigation to `Chat(id)` after the first stream completes (data persisted to Room), keeping `NewChat` in the back stack +- `navigateToChat()` replaces the current chat entry on the `NavBackStack` when switching between chats so back returns to `NewChat` ## Message Tree - Messages stored flat, linked by `parentMessageId` (root = `NO_PARENT` UUID) diff --git a/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatPlatformModule.android.kt b/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatPlatformModule.android.kt index 9776696..c93240d 100644 --- a/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatPlatformModule.android.kt +++ b/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatPlatformModule.android.kt @@ -18,9 +18,10 @@ actual val chatPlatformModule: Module = module { ) } - viewModel { + viewModel { params -> ChatViewModel( savedStateHandle = get(), + initialConversationId = params.getOrNull(), agentRepository = get(), chatRepository = get(), messageRepository = get(), diff --git a/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/screen/ChatScreen.kt b/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/screen/ChatScreen.kt index caef207..4852754 100644 --- a/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/screen/ChatScreen.kt +++ b/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/screen/ChatScreen.kt @@ -97,18 +97,20 @@ import com.garfiec.librechat.feature.chat.viewmodel.ChatScreenState import com.garfiec.librechat.feature.chat.viewmodel.ChatViewModel import kotlinx.coroutines.launch import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf @OptIn(ExperimentalMaterial3Api::class) @Composable actual fun ChatScreen( modifier: Modifier, + conversationId: String?, onConversationStarted: ((String) -> Unit)?, onNavigateToConversation: ((String) -> Unit)?, onOpenDrawer: (() -> Unit)?, onNavigateToPromptsLibrary: (() -> Unit)?, onNavigateBack: (() -> Unit)?, ) { - val viewModel: ChatViewModel = koinViewModel() + val viewModel: ChatViewModel = koinViewModel { parametersOf(conversationId) } val uiState by viewModel.uiState.collectAsStateWithLifecycle() val attachedFiles by viewModel.attachedFiles.collectAsStateWithLifecycle() val shareLinkUrl by viewModel.shareLinkUrl.collectAsStateWithLifecycle() @@ -232,9 +234,9 @@ actual fun ChatScreen( } } - // When a new conversation starts, navigate to chat/{conversationId} immediately - // (at StreamEvent.Created) so the new_chat landing page stays clean in the back - // stack. The new ChatViewModel at chat/{id} will resume the active stream. + // When a new conversation starts, navigate to Chat(conversationId) immediately + // (at StreamEvent.Created) so the NewChat landing page stays clean in the back + // stack. The new ChatViewModel at Chat(id) will resume the active stream. // onPendingNavigationHandled() resets this ViewModel to a fresh landing state. LaunchedEffect(uiState.pendingNavigationConversationId) { val pendingId = uiState.pendingNavigationConversationId diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatModule.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatModule.kt index 29c5076..a9b2593 100644 --- a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatModule.kt +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatModule.kt @@ -3,13 +3,20 @@ package com.garfiec.librechat.feature.chat.di import com.garfiec.librechat.feature.chat.prompts.PromptEditorViewModel import com.garfiec.librechat.feature.chat.prompts.PromptsViewModel import org.koin.core.module.Module +import org.koin.core.module.dsl.viewModel import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module val chatModule = module { includes(chatPlatformModule) viewModelOf(::PromptsViewModel) - viewModelOf(::PromptEditorViewModel) + viewModel { params -> + PromptEditorViewModel( + promptRepository = get(), + savedStateHandle = get(), + initialGroupId = params.getOrNull(), + ) + } } expect val chatPlatformModule: Module diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/navigation/ChatNavigation.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/navigation/ChatNavigation.kt index 70e3d4d..2267513 100644 --- a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/navigation/ChatNavigation.kt +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/navigation/ChatNavigation.kt @@ -1,117 +1,69 @@ package com.garfiec.librechat.feature.chat.navigation -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.navigation.NavController -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavType -import androidx.navigation.compose.composable -import androidx.navigation.navArgument -import androidx.navigation.navigation +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey import com.garfiec.librechat.feature.chat.prompts.PromptEditorScreen import com.garfiec.librechat.feature.chat.prompts.PromptsLibraryScreen import com.garfiec.librechat.feature.chat.screen.ChatScreen 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" -const val NEW_CHAT_ROUTE = "new_chat" -const val CHAT_ROUTE = "chat/{conversationId}" -const val PROMPTS_LIBRARY_ROUTE = "prompts_library" -const val PROMPT_EDITOR_ROUTE = "prompt_editor?groupId={groupId}" +@Serializable sealed interface ChatRoute : NavKey -fun NavController.navigateToChat(conversationId: String) { - val currentEntry = currentBackStackEntry - val isCurrentlyInChat = currentEntry?.destination?.route == CHAT_ROUTE - val currentDestId = currentEntry?.destination?.id - navigate("chat/$conversationId") { - if (isCurrentlyInChat && currentDestId != null) { - // Already viewing a chat -- replace it so switching chats - // doesn't stack entries. Back will go to whatever was - // before the first chat (e.g. new_chat, settings, agents). - popUpTo(currentDestId) { inclusive = true } - } - // From non-chat screens just push onto the backstack normally - launchSingleTop = true - } -} +@Serializable data object NewChat : ChatRoute +@Serializable data class Chat(val conversationId: String? = null) : ChatRoute +@Serializable data object PromptsLibrary : ChatRoute +@Serializable data class PromptEditor(val groupId: String? = null) : ChatRoute -fun NavController.navigateToPromptsLibrary() { - navigate(PROMPTS_LIBRARY_ROUTE) -} - -fun NavController.navigateToPromptEditor(groupId: String? = null) { - if (groupId != null) { - navigate("prompt_editor?groupId=$groupId") - } else { - navigate("prompt_editor") - } -} - -fun NavGraphBuilder.chatGraph( - navController: NavController, +fun EntryProviderScope.chatEntries( + onNavigate: (NavKey) -> Unit, + onBack: () -> Unit, + onNavigateToChat: (String) -> Unit, onOpenDrawer: (() -> Unit)? = null, ) { - navigation(startDestination = NEW_CHAT_ROUTE, route = CHAT_GRAPH_ROUTE) { - composable( - route = NEW_CHAT_ROUTE, - enterTransition = { EnterTransition.None }, - exitTransition = { ExitTransition.None }, - popEnterTransition = { null }, - popExitTransition = { null }, - ) { - NewChatScreen( - onConversationStarted = { conversationId -> - navController.navigateToChat(conversationId) - }, - onOpenDrawer = onOpenDrawer, - onNavigateToPromptsLibrary = { navController.navigateToPromptsLibrary() }, - ) - } - composable( - route = CHAT_ROUTE, - arguments = listOf( - navArgument("conversationId") { - type = NavType.StringType - nullable = true - defaultValue = null - }, - ), - enterTransition = { EnterTransition.None }, - exitTransition = { ExitTransition.None }, - popEnterTransition = { null }, - popExitTransition = { null }, - ) { _ -> - ChatScreen( - onOpenDrawer = onOpenDrawer, - onNavigateToPromptsLibrary = { navController.navigateToPromptsLibrary() }, - onNavigateBack = { navController.popBackStack() }, - onNavigateToConversation = { conversationId -> navController.navigateToChat(conversationId) }, - ) - } - composable(PROMPTS_LIBRARY_ROUTE) { - PromptsLibraryScreen( - onNavigateBack = { navController.popBackStack() }, - onUseInChat = { promptText -> - navController.popBackStack() - }, - onNavigateToEditor = { groupId -> - navController.navigateToPromptEditor(groupId) - }, - ) - } - composable( - route = PROMPT_EDITOR_ROUTE, - arguments = listOf( - navArgument("groupId") { - type = NavType.StringType - nullable = true - defaultValue = null - }, - ), - ) { - PromptEditorScreen( - onBack = { navController.popBackStack() }, - ) - } + entry { + NewChatScreen( + onConversationStarted = { conversationId -> + onNavigateToChat(conversationId) + }, + onOpenDrawer = onOpenDrawer, + onNavigateToPromptsLibrary = { onNavigate(PromptsLibrary) }, + ) + } + entry { key -> + ChatScreen( + conversationId = key.conversationId, + onOpenDrawer = onOpenDrawer, + onNavigateToPromptsLibrary = { onNavigate(PromptsLibrary) }, + onNavigateBack = onBack, + onNavigateToConversation = { conversationId -> onNavigateToChat(conversationId) }, + ) + } + entry { + PromptsLibraryScreen( + onNavigateBack = onBack, + onUseInChat = { _ -> onBack() }, + onNavigateToEditor = { groupId -> + onNavigate(PromptEditor(groupId = groupId)) + }, + ) + } + entry { key -> + PromptEditorScreen( + onBack = onBack, + groupId = key.groupId, + ) + } +} + +val chatSerializersModule = SerializersModule { + polymorphic(NavKey::class) { + subclass(NewChat::class, NewChat.serializer()) + subclass(Chat::class, Chat.serializer()) + subclass(PromptsLibrary::class, PromptsLibrary.serializer()) + subclass(PromptEditor::class, PromptEditor.serializer()) } } diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptEditorScreen.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptEditorScreen.kt index 9bb7e4b..d9cae53 100644 --- a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptEditorScreen.kt +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptEditorScreen.kt @@ -38,13 +38,15 @@ import com.garfiec.librechat.core.ui.components.LoadingIndicator import librechat_mobile.feature.chat.generated.resources.Res import librechat_mobile.feature.chat.generated.resources.* import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf @OptIn(ExperimentalMaterial3Api::class) @Composable fun PromptEditorScreen( onBack: () -> Unit, modifier: Modifier = Modifier, - viewModel: PromptEditorViewModel = koinViewModel(), + groupId: String? = null, + viewModel: PromptEditorViewModel = koinViewModel { parametersOf(groupId) }, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptEditorViewModel.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptEditorViewModel.kt index e0bb1e4..84e9c35 100644 --- a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptEditorViewModel.kt +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/prompts/PromptEditorViewModel.kt @@ -40,13 +40,14 @@ data class PromptEditorUiState( class PromptEditorViewModel( private val promptRepository: PromptRepository, savedStateHandle: SavedStateHandle, + initialGroupId: String? = null, ) : ViewModel() { private val _uiState = MutableStateFlow(PromptEditorUiState()) val uiState: StateFlow = _uiState.asStateFlow() init { - val groupId = savedStateHandle.get("groupId") + val groupId = initialGroupId if (groupId != null) { loadGroup(groupId) } diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/screen/PlatformScreens.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/screen/PlatformScreens.kt index e489428..f6939b1 100644 --- a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/screen/PlatformScreens.kt +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/screen/PlatformScreens.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.Modifier @Composable expect fun ChatScreen( modifier: Modifier = Modifier, + conversationId: String? = null, onConversationStarted: ((String) -> Unit)? = null, onNavigateToConversation: ((String) -> Unit)? = null, onOpenDrawer: (() -> Unit)? = null, diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatUiState.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatUiState.kt index 6583aa4..5a87faf 100644 --- a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatUiState.kt +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatUiState.kt @@ -125,7 +125,7 @@ data class ChatUiState( val showDeleteConfirmation: Boolean = false, val duplicatedConversationId: String? = null, /** Set at StreamEvent.Created when a new conversation's conversationId becomes available. - * The UI navigates to chat/{id} and then clears this via [ChatViewModel.onPendingNavigationHandled], + * The UI navigates to Chat(id) and then clears this via [ChatViewModel.onPendingNavigationHandled], * which also resets this ViewModel to a clean landing state. */ val pendingNavigationConversationId: String? = null, // Model comparison state diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatViewModel.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatViewModel.kt index 13e5b1e..979818c 100644 --- a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatViewModel.kt +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/viewmodel/ChatViewModel.kt @@ -58,6 +58,7 @@ import kotlin.uuid.Uuid class ChatViewModel( savedStateHandle: SavedStateHandle, + initialConversationId: String? = null, private val agentRepository: AgentRepository, private val chatRepository: ChatRepository, private val messageRepository: MessageRepository, @@ -163,7 +164,7 @@ class ChatViewModel( private var titleGenerationRequested = false init { - val conversationId = savedStateHandle.get("conversationId") + val conversationId = initialConversationId isNewConversation = conversationId == null if (conversationId != null) { _uiState.value = _uiState.value.copy( @@ -174,7 +175,7 @@ class ChatViewModel( loadConversationModel(conversationId) restoreDraft(conversationId) // Check if there's an active stream for this conversation (e.g. when - // navigating here from new_chat immediately after sending). If so, + // navigating here from NewChat immediately after sending). If so, // resume it so the user sees streaming content on this screen. resumeActiveStreamIfNeeded(conversationId) } else { @@ -255,7 +256,7 @@ class ChatViewModel( } // Restore MCP server and tool selections from DataStore so they - // survive the new_chat -> chat/{id} navigation re-creation. + // survive the NewChat -> Chat(id) navigation re-creation. viewModelScope.launch { val mcpServers = settingsDataStore.selectedMcpServers.first() val tools = settingsDataStore.enabledTools.first() diff --git a/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatPlatformModule.ios.kt b/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatPlatformModule.ios.kt index 7c20aff..fa18e03 100644 --- a/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatPlatformModule.ios.kt +++ b/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/di/ChatPlatformModule.ios.kt @@ -16,9 +16,10 @@ actual val chatPlatformModule: Module = module { ) } - viewModel { + viewModel { params -> ChatViewModel( savedStateHandle = get(), + initialConversationId = params.getOrNull(), agentRepository = get(), chatRepository = get(), messageRepository = get(), diff --git a/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/screen/PlatformScreens.ios.kt b/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/screen/PlatformScreens.ios.kt index d29912f..ff660ea 100644 --- a/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/screen/PlatformScreens.ios.kt +++ b/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/screen/PlatformScreens.ios.kt @@ -81,18 +81,20 @@ import librechat_mobile.feature.chat.generated.resources.Res import librechat_mobile.feature.chat.generated.resources.* import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf @OptIn(ExperimentalMaterial3Api::class) @Composable actual fun ChatScreen( modifier: Modifier, + conversationId: String?, onConversationStarted: ((String) -> Unit)?, onNavigateToConversation: ((String) -> Unit)?, onOpenDrawer: (() -> Unit)?, onNavigateToPromptsLibrary: (() -> Unit)?, onNavigateBack: (() -> Unit)?, ) { - val viewModel: ChatViewModel = koinViewModel() + val viewModel: ChatViewModel = koinViewModel { parametersOf(conversationId) } val uiState by viewModel.uiState.collectAsStateWithLifecycle() val attachedFiles by viewModel.attachedFiles.collectAsStateWithLifecycle() val prefs by viewModel.chatPreferences.collectAsStateWithLifecycle() diff --git a/feature/conversations/CLAUDE.md b/feature/conversations/CLAUDE.md index 48107f0..ae7a833 100644 --- a/feature/conversations/CLAUDE.md +++ b/feature/conversations/CLAUDE.md @@ -1,7 +1,7 @@ # feature:conversations ## Screen -`ConversationListScreen` -- the drawer content showing all conversations. Single route: `CONVERSATIONS_ROUTE`. +`ConversationListScreen` -- the drawer content showing all conversations. Sealed interface: `ConversationsRoute : NavKey` with routes `Conversations` and `ArchivedConversations` (both `@Serializable` data objects). ## Pagination - Cursor-based via `ConversationRepository.loadNextPage(cursor, tags)` diff --git a/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/navigation/ConversationsNavigation.kt b/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/navigation/ConversationsNavigation.kt index 769b56d..d02b101 100644 --- a/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/navigation/ConversationsNavigation.kt +++ b/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/navigation/ConversationsNavigation.kt @@ -1,32 +1,40 @@ package com.garfiec.librechat.feature.conversations.navigation -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import com.garfiec.librechat.core.ui.components.ScreenTransitionWrapper +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey import com.garfiec.librechat.feature.conversations.screen.ArchivedConversationsScreen 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" -const val ARCHIVED_ROUTE = "conversations/archived" +@Serializable sealed interface ConversationsRoute : NavKey -fun NavGraphBuilder.conversationsGraph( +@Serializable data object Conversations : ConversationsRoute +@Serializable data object ArchivedConversations : ConversationsRoute + +fun EntryProviderScope.conversationsEntries( onConversationClick: (String) -> Unit, onNavigateToArchived: () -> Unit = {}, - onNavigateBackFromArchived: () -> Unit = {}, + onBack: () -> Unit = {}, ) { - composable(CONVERSATIONS_ROUTE) { - ScreenTransitionWrapper(transition) { - ConversationListScreen( - onConversationClick = onConversationClick, - onNavigateToArchived = onNavigateToArchived, - ) - } + entry { + ConversationListScreen( + onConversationClick = onConversationClick, + onNavigateToArchived = onNavigateToArchived, + ) } - composable(ARCHIVED_ROUTE) { - ScreenTransitionWrapper(transition) { - ArchivedConversationsScreen( - onNavigateBack = onNavigateBackFromArchived, - ) - } + entry { + ArchivedConversationsScreen( + onNavigateBack = onBack, + ) + } +} + +val conversationsSerializersModule = SerializersModule { + polymorphic(NavKey::class) { + subclass(Conversations::class, Conversations.serializer()) + subclass(ArchivedConversations::class, ArchivedConversations.serializer()) } } diff --git a/feature/files/CLAUDE.md b/feature/files/CLAUDE.md index 07e4113..cfcc2dc 100644 --- a/feature/files/CLAUDE.md +++ b/feature/files/CLAUDE.md @@ -1,7 +1,7 @@ # feature:files ## Screen -`FilesScreen` -- single route `FILES_ROUTE`. Lists uploaded files with upload and delete actions. +`FilesScreen` -- sealed interface `FilesRoute : NavKey` with single route `Files` (`@Serializable` data object). Lists uploaded files with upload and delete actions. ## ViewModel `FilesViewModel` depends on `FileRepository` and application `Context` (for content resolver). diff --git a/feature/files/src/commonMain/kotlin/com/garfiec/librechat/feature/files/navigation/FilesNavigation.kt b/feature/files/src/commonMain/kotlin/com/garfiec/librechat/feature/files/navigation/FilesNavigation.kt index 2bdf8db..d3154a0 100644 --- a/feature/files/src/commonMain/kotlin/com/garfiec/librechat/feature/files/navigation/FilesNavigation.kt +++ b/feature/files/src/commonMain/kotlin/com/garfiec/librechat/feature/files/navigation/FilesNavigation.kt @@ -1,18 +1,27 @@ package com.garfiec.librechat.feature.files.navigation -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import com.garfiec.librechat.core.ui.components.ScreenTransitionWrapper +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey 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 : NavKey -fun NavGraphBuilder.filesGraph( +@Serializable data object Files : FilesRoute + +fun EntryProviderScope.filesEntries( onBack: (() -> Unit)? = null, ) { - composable(FILES_ROUTE) { - ScreenTransitionWrapper(transition) { - FilesScreen(onBack = onBack) - } + entry { + FilesScreen(onBack = onBack) + } +} + +val filesSerializersModule = SerializersModule { + polymorphic(NavKey::class) { + subclass(Files::class, Files.serializer()) } } diff --git a/feature/settings/CLAUDE.md b/feature/settings/CLAUDE.md index 1d5ab38..75e2978 100644 --- a/feature/settings/CLAUDE.md +++ b/feature/settings/CLAUDE.md @@ -1,7 +1,7 @@ # feature:settings ## Screen -`SettingsScreen` -- single route `SETTINGS_ROUTE`. Receives `onLogout` and `onNavigateBack` callbacks. +`SettingsScreen` -- sealed interface `SettingsRoute : NavKey` with primary route `SettingsTabbed` (`@Serializable` data object). Receives `onLogout` and `onNavigateBack` callbacks. ## Sections - **Account**: displays user profile from `UserApi.getUser()` @@ -46,14 +46,14 @@ - `McpServersScreen` — server list with status badges, CRUD, reinitialize, tools sheet (`McpViewModel`) - `PresetManagerScreen` — list/delete presets (`PresetManagerViewModel`) - `CommandsConfigScreen` — enable/disable slash commands -- Routes: `MEMORIES_ROUTE`, `MCP_SERVERS_ROUTE`, `PRESET_MANAGER_ROUTE`, `COMMANDS_CONFIG_ROUTE` +- Routes: `Memories`, `McpServers`, `PresetManager` (all `@Serializable` data objects extending `SettingsRoute`) - **Gotcha**: Memories and MCP navigation routes defined in their own files (`MemoriesNavigation.kt`, `McpNavigation.kt`) but wired through `SettingsNavigation.kt` - **Gotcha**: `McpServerDialog` uses `McpServerType` enum (SSE, STREAMABLE_HTTP) — must match backend expectations - **Gotcha**: Memory keys are immutable after creation (edit dialog disables key field) ### API Keys - `ApiKeysScreen` — list/create/delete API keys, own ViewModel (`ApiKeysViewModel`) -- Route: `API_KEYS_ROUTE` ("settings/api_keys"), accessible from Settings → Security section +- Route: `ApiKeys` (`@Serializable` data object extending `SettingsRoute`), accessible from Settings → Security section - `ApiKeyCreateDialog` is two-phase: name input → show created key value with copy button - **Gotcha**: The key value is only available in the creation response — it cannot be retrieved again from the server. The dialog must show it immediately after creation. diff --git a/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/navigation/McpNavigation.kt b/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/navigation/McpNavigation.kt index 14192d5..2471dfd 100644 --- a/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/navigation/McpNavigation.kt +++ b/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/navigation/McpNavigation.kt @@ -1,17 +1,18 @@ package com.garfiec.librechat.feature.settings.navigation -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey 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( - onNavigateBack: () -> Unit, +fun EntryProviderScope.mcpServersEntry( + onBack: () -> Unit, ) { - composable(MCP_SERVERS_ROUTE) { + entry { McpServersScreen( - onNavigateBack = onNavigateBack, + onNavigateBack = onBack, ) } } diff --git a/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/navigation/MemoriesNavigation.kt b/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/navigation/MemoriesNavigation.kt index e3e3b15..d7b7939 100644 --- a/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/navigation/MemoriesNavigation.kt +++ b/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/navigation/MemoriesNavigation.kt @@ -1,17 +1,18 @@ package com.garfiec.librechat.feature.settings.navigation -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey 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( - onNavigateBack: () -> Unit, +fun EntryProviderScope.memoriesEntry( + onBack: () -> Unit, ) { - composable(MEMORIES_ROUTE) { + entry { MemoriesScreen( - onNavigateBack = onNavigateBack, + onNavigateBack = onBack, ) } } diff --git a/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/navigation/SettingsNavigation.kt b/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/navigation/SettingsNavigation.kt index a987b7d..3db073d 100644 --- a/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/navigation/SettingsNavigation.kt +++ b/feature/settings/src/commonMain/kotlin/com/garfiec/librechat/feature/settings/navigation/SettingsNavigation.kt @@ -2,8 +2,8 @@ package com.garfiec.librechat.feature.settings.navigation import androidx.compose.runtime.LaunchedEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey import com.garfiec.librechat.feature.settings.screen.AccountSettingsScreen import com.garfiec.librechat.feature.settings.screen.ApiKeysScreen import com.garfiec.librechat.feature.settings.screen.ChatSettingsScreen @@ -13,65 +13,65 @@ import com.garfiec.librechat.feature.settings.screen.PresetManagerScreen import com.garfiec.librechat.feature.settings.screen.SharedLinksScreen import com.garfiec.librechat.feature.settings.screen.TabbedSettingsScreen 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 -const val SETTINGS_TABBED_ROUTE = "settings" -const val SETTINGS_GENERAL_ROUTE = "settings/general" -const val SETTINGS_CHAT_ROUTE = "settings/chat" -const val SETTINGS_ACCOUNT_ROUTE = "settings/account" -const val SETTINGS_DATA_ROUTE = "settings/data" -const val SHARED_LINKS_ROUTE = "settings/shared-links" -const val PRESET_MANAGER_ROUTE = "settings/presets" -const val API_KEYS_ROUTE = "settings/api_keys" +@Serializable sealed interface SettingsRoute : NavKey -fun NavGraphBuilder.settingsGraph( +@Serializable data object SettingsTabbed : SettingsRoute +@Serializable data object SettingsGeneral : SettingsRoute +@Serializable data object SettingsChat : SettingsRoute +@Serializable data object SettingsAccount : SettingsRoute +@Serializable data object SettingsData : SettingsRoute +@Serializable data object SharedLinks : SettingsRoute +@Serializable data object PresetManager : SettingsRoute +@Serializable data object ApiKeys : SettingsRoute + +fun EntryProviderScope.settingsEntries( + onNavigate: (NavKey) -> Unit, + onBack: () -> Unit, onLogout: () -> Unit, - onNavigateBack: () -> Unit, onNavigateToArchived: () -> Unit = {}, - onNavigateToSharedLinks: () -> Unit = {}, - onNavigateBackFromSharedLinks: () -> Unit = {}, - onNavigateToPresets: () -> Unit = {}, - onNavigateBackFromPresets: () -> Unit = {}, - onNavigateToApiKeys: () -> Unit = {}, - onNavigateBackFromApiKeys: () -> Unit = {}, ) { - composable(SETTINGS_TABBED_ROUTE) { + entry { TabbedSettingsScreen( - onNavigateBack = onNavigateBack, + onNavigateBack = onBack, onLogout = onLogout, onNavigateToArchived = onNavigateToArchived, - onNavigateToSharedLinks = onNavigateToSharedLinks, - onNavigateToPresets = onNavigateToPresets, - onNavigateToApiKeys = onNavigateToApiKeys, + onNavigateToSharedLinks = { onNavigate(SharedLinks) }, + onNavigateToPresets = { onNavigate(PresetManager) }, + onNavigateToApiKeys = { onNavigate(ApiKeys) }, ) } - - composable(SETTINGS_GENERAL_ROUTE) { + entry { GeneralSettingsScreen( - onNavigateBack = onNavigateBack, + onNavigateBack = onBack, ) } - composable(SETTINGS_CHAT_ROUTE) { + entry { ChatSettingsScreen( - onNavigateBack = onNavigateBack, - onNavigateToPresets = onNavigateToPresets, + onNavigateBack = onBack, + onNavigateToPresets = { onNavigate(PresetManager) }, ) } - composable(SETTINGS_ACCOUNT_ROUTE) { + entry { AccountSettingsScreen( onLogout = onLogout, - onNavigateBack = onNavigateBack, - onNavigateToApiKeys = onNavigateToApiKeys, + onNavigateBack = onBack, + onNavigateToApiKeys = { onNavigate(ApiKeys) }, ) } - composable(SETTINGS_DATA_ROUTE) { + entry { DataSettingsScreen( - onNavigateBack = onNavigateBack, + onNavigateBack = onBack, onNavigateToArchived = onNavigateToArchived, - onNavigateToSharedLinks = onNavigateToSharedLinks, + onNavigateToSharedLinks = { onNavigate(SharedLinks) }, ) } - composable(SHARED_LINKS_ROUTE) { + entry { val viewModel: SettingsViewModel = koinViewModel() val uiState = viewModel.uiState.collectAsStateWithLifecycle() @@ -87,17 +87,32 @@ fun NavGraphBuilder.settingsGraph( onLoadMore = viewModel::loadMoreSharedLinks, onToggleVisibility = viewModel::toggleSharedLinkVisibility, onDelete = viewModel::deleteSharedLink, - onNavigateBack = onNavigateBackFromSharedLinks, + onNavigateBack = onBack, ) } - composable(PRESET_MANAGER_ROUTE) { + entry { PresetManagerScreen( - onNavigateBack = onNavigateBackFromPresets, + onNavigateBack = onBack, ) } - composable(API_KEYS_ROUTE) { + entry { ApiKeysScreen( - onNavigateBack = onNavigateBackFromApiKeys, + onNavigateBack = onBack, ) } } + +val settingsSerializersModule = SerializersModule { + polymorphic(NavKey::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()) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 132e63a..eb7f706 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,10 +7,10 @@ compose-bom = "2025.05.00" compose-multiplatform = "1.11.0-beta01" material3 = "1.3.1" activity-compose = "1.10.1" -navigation-compose = "2.9.2" lifecycle = "2.9.0" lifecycle-kmp = "2.9.0" -navigation-kmp = "2.9.2" +navigation3-kmp = "1.0.0-alpha06" +lifecycle-viewmodel-nav3-kmp = "2.10.0-alpha05" # DI koin = "4.2.0" @@ -82,14 +82,13 @@ compose-foundation = { group = "androidx.compose.foundation", name = "foundation # Activity / Navigation activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } -navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation-compose" } - # Lifecycle lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } lifecycle-runtime-compose-kmp = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle-kmp" } lifecycle-viewmodel-compose-kmp = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle-kmp" } -navigation-compose-kmp = { group = "org.jetbrains.androidx.navigation", name = "navigation-compose", version.ref = "navigation-kmp" } +navigation3-ui-kmp = { group = "org.jetbrains.androidx.navigation3", name = "navigation3-ui", version.ref = "navigation3-kmp" } +lifecycle-viewmodel-navigation3-kmp = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel-navigation3", version.ref = "lifecycle-viewmodel-nav3-kmp" } # Koin koin-bom = { group = "io.insert-koin", name = "koin-bom", version.ref = "koin" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 5228259..55428df 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -42,9 +42,10 @@ kotlin { implementation(libs.kotlinx.serialization.json) implementation(libs.coroutines.core) implementation(libs.kermit) - implementation(libs.navigation.compose.kmp) + implementation(libs.navigation3.ui.kmp) implementation(libs.lifecycle.runtime.compose.kmp) implementation(libs.lifecycle.viewmodel.compose.kmp) + implementation(libs.lifecycle.viewmodel.navigation3.kmp) implementation(libs.koin.compose) implementation(libs.koin.compose.viewmodel) implementation(libs.koin.compose.viewmodel.navigation) diff --git a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/AppModule.kt b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/AppModule.kt index 5743725..2bf2268 100644 --- a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/AppModule.kt +++ b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/AppModule.kt @@ -1,8 +1,11 @@ package com.garfiec.librechat.shared.navigation +import kotlinx.serialization.modules.SerializersModule import org.koin.core.module.dsl.viewModelOf +import org.koin.core.qualifier.named import org.koin.dsl.module val sharedAppModule = module { viewModelOf(::NavHostViewModel) + single(named("navigation")) { navigationSerializersModule } } diff --git a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/LibreChatNavHost.kt b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/LibreChatNavHost.kt index 85a71d0..7415953 100644 --- a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/LibreChatNavHost.kt +++ b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/LibreChatNavHost.kt @@ -1,6 +1,5 @@ package com.garfiec.librechat.shared.navigation -import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.tween @@ -8,7 +7,9 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut -import androidx.compose.animation.slideOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -26,34 +27,36 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.savedstate.read -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.currentBackStackEntryAsState -import androidx.navigation.compose.rememberNavController +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.ui.NavDisplay +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator 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.agentsGraph -import com.garfiec.librechat.feature.auth.navigation.AUTH_GRAPH_ROUTE -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_ROUTE -import com.garfiec.librechat.feature.chat.navigation.NEW_CHAT_ROUTE -import com.garfiec.librechat.feature.chat.navigation.chatGraph -import com.garfiec.librechat.feature.chat.navigation.navigateToChat -import com.garfiec.librechat.feature.conversations.navigation.conversationsGraph -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.PRESET_MANAGER_ROUTE -import com.garfiec.librechat.feature.settings.navigation.SETTINGS_ACCOUNT_ROUTE -import com.garfiec.librechat.feature.settings.navigation.SETTINGS_CHAT_ROUTE -import com.garfiec.librechat.feature.settings.navigation.SETTINGS_DATA_ROUTE -import com.garfiec.librechat.feature.settings.navigation.SETTINGS_GENERAL_ROUTE -import com.garfiec.librechat.feature.settings.navigation.SETTINGS_TABBED_ROUTE -import com.garfiec.librechat.feature.settings.navigation.SHARED_LINKS_ROUTE -import com.garfiec.librechat.feature.settings.navigation.settingsGraph +import com.garfiec.librechat.feature.agents.navigation.AgentMarketplace +import com.garfiec.librechat.feature.agents.navigation.agentsEntries +import com.garfiec.librechat.feature.auth.navigation.AuthRoute +import com.garfiec.librechat.feature.auth.navigation.ServerUrl +import com.garfiec.librechat.feature.auth.navigation.authEntries +import com.garfiec.librechat.feature.chat.navigation.Chat +import com.garfiec.librechat.feature.chat.navigation.NewChat +import com.garfiec.librechat.feature.chat.navigation.chatEntries +import com.garfiec.librechat.feature.conversations.navigation.ArchivedConversations +import com.garfiec.librechat.feature.conversations.navigation.conversationsEntries +import com.garfiec.librechat.feature.files.navigation.Files +import com.garfiec.librechat.feature.files.navigation.filesEntries +import com.garfiec.librechat.feature.settings.navigation.SettingsAccount +import com.garfiec.librechat.feature.settings.navigation.SettingsChat +import com.garfiec.librechat.feature.settings.navigation.SettingsData +import com.garfiec.librechat.feature.settings.navigation.SettingsGeneral +import com.garfiec.librechat.feature.settings.navigation.SettingsRoute +import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed +import com.garfiec.librechat.feature.settings.navigation.memoriesEntry +import com.garfiec.librechat.feature.settings.navigation.mcpServersEntry +import com.garfiec.librechat.feature.settings.navigation.settingsEntries import kotlinx.coroutines.launch import librechat_mobile.shared.generated.resources.Res import librechat_mobile.shared.generated.resources.dismiss @@ -63,12 +66,12 @@ import librechat_mobile.shared.generated.resources.version_mismatch_title import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel -/** Maps a [SettingsCategory] to its corresponding navigation route. */ -fun SettingsCategory.toRoute(): String = when (this) { - SettingsCategory.GENERAL -> SETTINGS_GENERAL_ROUTE - SettingsCategory.CHAT -> SETTINGS_CHAT_ROUTE - 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 } /** @@ -84,43 +87,35 @@ fun LibreChatNavHost( modifier: Modifier = Modifier, navHostViewModel: NavHostViewModel = koinViewModel(), ) { - val navController = rememberNavController() - val navBackStackEntry by navController.currentBackStackEntryAsState() - val currentDestination = navBackStackEntry?.destination - val isLoggedIn by navHostViewModel.isLoggedIn.collectAsStateWithLifecycle() - val isInAuthFlow = currentDestination?.route == AUTH_GRAPH_ROUTE || - currentDestination?.parent?.route == AUTH_GRAPH_ROUTE + // Stable start key — auth redirect handled via LaunchedEffect below. + val backStack = rememberNavBackStack(navigationSavedStateConfig, NewChat) + val navigator = Navigator(backStack) - // Track active conversation from nav back stack. - // Use SavedState.read {} to extract nav args (KMP-compatible, no Bundle.getString). - LaunchedEffect(navBackStackEntry) { - val destRoute = navBackStackEntry?.destination?.route - val conversationId = if (destRoute == CHAT_ROUTE) { - navBackStackEntry?.arguments?.read { getStringOrNull("conversationId") } - } else { - null + // Redirect to auth if not logged in (on first composition only). + LaunchedEffect(Unit) { + if (!navHostViewModel.isLoggedIn.value) { + navigator.navigateToAuth() } + } + + // Track active conversation from nav back stack + LaunchedEffect(navigator.currentRoute) { + val conversationId = (navigator.currentRoute as? Chat)?.conversationId navHostViewModel.setActiveConversation(conversationId) } // Handle session expiry LaunchedEffect(Unit) { navHostViewModel.sessionExpired.collect { - navController.navigate(AUTH_GRAPH_ROUTE) { - popUpTo(0) { inclusive = true } - } + navigator.navigateToAuth() } } - val startDestination = if (isLoggedIn) CHAT_GRAPH_ROUTE else AUTH_GRAPH_ROUTE - PhoneLayout( - navController = navController, + navigator = navigator, navHostViewModel = navHostViewModel, - startDestination = startDestination, - isInAuthFlow = isInAuthFlow, modifier = modifier, ) @@ -172,10 +167,8 @@ private fun VersionMismatchDialog( @Composable private fun PhoneLayout( - navController: androidx.navigation.NavHostController, + navigator: Navigator, navHostViewModel: NavHostViewModel, - startDestination: String, - isInAuthFlow: Boolean, modifier: Modifier = Modifier, ) { val drawerState = rememberDrawerState(DrawerValue.Closed) @@ -192,54 +185,42 @@ private fun PhoneLayout( ModalNavigationDrawer( drawerState = drawerState, - gesturesEnabled = !isInAuthFlow, + gesturesEnabled = !navigator.isInAuthFlow, drawerContent = { ModalDrawerSheet { SidebarScaffold( viewModel = navHostViewModel, onNewChat = { scope.launch { drawerState.close() } - val currentRoute = navController.currentBackStackEntry?.destination?.route - if (currentRoute != NEW_CHAT_ROUTE) { - navController.navigate(NEW_CHAT_ROUTE) { - popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false } - launchSingleTop = true - } + if (navigator.currentRoute !is NewChat) { + navigator.navigateToTopLevel(NewChat) } }, onConversationClick = { conversationId -> scope.launch { drawerState.close() } - navController.navigateToChat(conversationId) + navigator.navigateToChat(conversationId) }, onSettingsClick = { scope.launch { drawerState.close() } - navController.navigate(SETTINGS_TABBED_ROUTE) { - launchSingleTop = true - } + navigator.navigate(SettingsTabbed) }, onSettingsCategorySelected = { category -> - navController.navigate(category.toRoute()) { - launchSingleTop = true - } + navigator.navigate(category.toRoute()) }, onAgentsClick = { scope.launch { drawerState.close() } - navController.navigate(TopLevelDestination.AGENTS.route) { - launchSingleTop = true - } + navigator.navigate(AgentMarketplace) }, onFilesClick = { scope.launch { drawerState.close() } - navController.navigate(TopLevelDestination.FILES.route) { - launchSingleTop = true - } + navigator.navigate(Files) }, ) } }, ) { Column(modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { - if (!isInAuthFlow && banners.isNotEmpty()) { + if (!navigator.isInAuthFlow && banners.isNotEmpty()) { BannerDisplay( banners = banners, dismissedIds = dismissedBannerIds, @@ -247,124 +228,88 @@ private fun PhoneLayout( modifier = Modifier.padding(top = 4.dp), ) } - NavHost( - navController = navController, - startDestination = startDestination, - modifier = Modifier.fillMaxSize(), - enterTransition = { - slideIntoContainer( - towards = AnimatedContentTransitionScope.SlideDirection.Start, - animationSpec = tween(300), + NavDisplay( + backStack = navigator.backStack, + onBack = { navigator.goBack() }, + transitionSpec = { + slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(300)) togetherWith + fadeOut(animationSpec = tween(150)) + }, + popTransitionSpec = { + (fadeIn(initialAlpha = 0.5f, animationSpec = tween(300, easing = LinearEasing)) + + scaleIn(initialScale = 0.92f, animationSpec = tween(300, easing = LinearEasing))) togetherWith + (slideOutHorizontally( + targetOffsetX = { (it * 0.15f).toInt() }, + animationSpec = tween(300, easing = FastOutSlowInEasing), + ) + scaleOut( + targetScale = 0.92f, + animationSpec = tween(300, easing = LinearEasing), + )) + }, + predictivePopTransitionSpec = { + (fadeIn(initialAlpha = 0.5f, animationSpec = tween(300, easing = LinearEasing)) + + scaleIn(initialScale = 0.92f, animationSpec = tween(300, easing = LinearEasing))) togetherWith + (slideOutHorizontally( + targetOffsetX = { (it * 0.15f).toInt() }, + animationSpec = tween(300, easing = FastOutSlowInEasing), + ) + scaleOut( + targetScale = 0.92f, + animationSpec = tween(300, easing = LinearEasing), + )) + }, + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + entryProvider = entryProvider { + authEntries( + onNavigate = { navigator.navigate(it) }, + onBack = { navigator.goBack() }, + onAuthComplete = { + navHostViewModel.onAuthComplete() + navigator.navigateToChat() + }, + ) + chatEntries( + onNavigate = { navigator.navigate(it) }, + onBack = { navigator.goBack() }, + onNavigateToChat = { navigator.navigateToChat(it) }, + onOpenDrawer = { + scope.launch { drawerState.open() } + }, + ) + conversationsEntries( + onConversationClick = { navigator.navigateToChat(it) }, + onNavigateToArchived = { navigator.navigate(ArchivedConversations) }, + onBack = { navigator.goBack() }, + ) + agentsEntries( + onNavigate = { navigator.navigate(it) }, + onBack = { navigator.goBack() }, + onStartChat = { _ -> + navigator.navigateToTopLevel(NewChat) + }, + ) + filesEntries( + onBack = { navigator.goBack() }, + ) + settingsEntries( + onNavigate = { navigator.navigate(it) }, + onBack = { navigator.goBack() }, + onLogout = { + navHostViewModel.logout() + navigator.navigateToAuth() + }, + onNavigateToArchived = { navigator.navigate(ArchivedConversations) }, + ) + memoriesEntry( + onBack = { navigator.goBack() }, + ) + mcpServersEntry( + onBack = { navigator.goBack() }, ) }, - exitTransition = { - fadeOut(animationSpec = tween(150)) - }, - popEnterTransition = { - fadeIn( - initialAlpha = 0.5f, - animationSpec = tween(300, easing = LinearEasing), - ) + scaleIn( - initialScale = 0.92f, - animationSpec = tween(300, easing = LinearEasing), - ) - }, - popExitTransition = { - slideOut( - targetOffset = { IntOffset((it.width * 0.15f).toInt(), 0) }, - animationSpec = tween(300, easing = FastOutSlowInEasing), - ) + scaleOut( - targetScale = 0.92f, - animationSpec = tween(300, easing = LinearEasing), - ) - }, - ) { - authGraph( - navController = navController, - onAuthComplete = { - navHostViewModel.onAuthComplete() - navController.navigate(CHAT_GRAPH_ROUTE) { - popUpTo(AUTH_GRAPH_ROUTE) { inclusive = true } - } - }, - ) - chatGraph( - navController = navController, - onOpenDrawer = { - scope.launch { drawerState.open() } - }, - ) - conversationsGraph( - onConversationClick = { conversationId -> - navController.navigateToChat(conversationId) - }, - onNavigateToArchived = { - navController.navigate("conversations/archived") - }, - onNavigateBackFromArchived = { - navController.popBackStack() - }, - ) - agentsGraph( - onAgentClick = { agentId -> - navController.navigate("agents/$agentId") - }, - onBack = { navController.popBackStack() }, - onStartChat = { agentId -> - navController.navigate(NEW_CHAT_ROUTE) { - popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false } - launchSingleTop = true - } - }, - onCreateAgent = { - navController.navigate(AGENT_EDITOR_CREATE_ROUTE) - }, - onEditAgent = { agentId -> - navController.navigate("agents/editor/$agentId") - }, - ) - filesGraph( - onBack = { navController.popBackStack() }, - ) - settingsGraph( - onLogout = { - navHostViewModel.logout() - navController.navigate(AUTH_GRAPH_ROUTE) { - popUpTo(0) { inclusive = true } - } - }, - onNavigateBack = { navController.popBackStack() }, - onNavigateToArchived = { - navController.navigate("conversations/archived") { - launchSingleTop = true - } - }, - onNavigateToSharedLinks = { - navController.navigate(SHARED_LINKS_ROUTE) { - launchSingleTop = true - } - }, - onNavigateBackFromSharedLinks = { - navController.popBackStack() - }, - onNavigateToPresets = { - navController.navigate(PRESET_MANAGER_ROUTE) { - launchSingleTop = true - } - }, - onNavigateBackFromPresets = { - navController.popBackStack() - }, - onNavigateToApiKeys = { - navController.navigate(API_KEYS_ROUTE) { - launchSingleTop = true - } - }, - onNavigateBackFromApiKeys = { - navController.popBackStack() - }, - ) - } + ) } } } diff --git a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/NavigationSerializers.kt b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/NavigationSerializers.kt new file mode 100644 index 0000000..02143ea --- /dev/null +++ b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/NavigationSerializers.kt @@ -0,0 +1,32 @@ +package com.garfiec.librechat.shared.navigation + +import androidx.savedstate.serialization.SavedStateConfiguration +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. + * Used by Nav 3's SavedStateConfiguration for type-safe state saving. + */ +val navigationSerializersModule: SerializersModule = + authSerializersModule + + chatSerializersModule + + conversationsSerializersModule + + agentsSerializersModule + + filesSerializersModule + + settingsSerializersModule + +/** + * Nav 3 [SavedStateConfiguration] with polymorphic serialization for all route types. + * Required for KMP (iOS) since Nav 3 cannot use reflection-based serialization on non-JVM platforms. + */ +val navigationSavedStateConfig = SavedStateConfiguration { + serializersModule = navigationSerializersModule +} diff --git a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/Navigator.kt b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/Navigator.kt new file mode 100644 index 0000000..17be1aa --- /dev/null +++ b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/Navigator.kt @@ -0,0 +1,60 @@ +package com.garfiec.librechat.shared.navigation + +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import com.garfiec.librechat.feature.auth.navigation.AuthRoute +import com.garfiec.librechat.feature.auth.navigation.ServerUrl +import com.garfiec.librechat.feature.chat.navigation.Chat +import com.garfiec.librechat.feature.chat.navigation.NewChat + +/** + * Encapsulates all back stack mutations for Nav 3 navigation. + * Follows UDF: UI events → Navigator → back stack state → NavDisplay recomposition. + */ +class Navigator(val backStack: NavBackStack) { + + val currentRoute: NavKey? get() = backStack.lastOrNull() + + val isInAuthFlow: Boolean get() = currentRoute is AuthRoute + + /** Navigate forward to a route. */ + fun navigate(route: NavKey) { + backStack.add(route) + } + + /** Pop the top entry. Safe on empty stacks. */ + fun goBack() { + backStack.removeLastOrNull() + } + + /** Navigate to a chat, replacing any current chat on the stack. */ + fun navigateToChat(conversationId: String) { + if (backStack.lastOrNull() is Chat) { + backStack.removeLastOrNull() + } + backStack.add(Chat(conversationId)) + } + + /** Navigate to a top-level destination, popping to root first. */ + fun navigateToTopLevel(route: NavKey) { + while (backStack.size > 1) { + backStack.removeLastOrNull() + } + if (backStack.lastOrNull()?.let { it::class } != route::class) { + backStack.removeLastOrNull() + backStack.add(route) + } + } + + /** Clear back stack and navigate to auth (session expiry / logout). */ + fun navigateToAuth() { + backStack.clear() + backStack.add(ServerUrl) + } + + /** Clear back stack and navigate to chat (auth complete). */ + fun navigateToChat() { + backStack.clear() + backStack.add(NewChat) + } +} diff --git a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/TopLevelDestination.kt b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/TopLevelDestination.kt deleted file mode 100644 index e25dafb..0000000 --- a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/TopLevelDestination.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.garfiec.librechat.shared.navigation - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Chat -import androidx.compose.material.icons.filled.Folder -import androidx.compose.material.icons.filled.Forum -import androidx.compose.material.icons.filled.SmartToy -import androidx.compose.ui.graphics.vector.ImageVector - -/** - * Destinations accessible from the navigation drawer footer. - * The primary navigation is sidebar-first (drawer with conversation list), - * matching the web frontend pattern. - */ -enum class TopLevelDestination( - val route: String, - val icon: ImageVector, - val label: String, -) { - CHAT("chat_graph", Icons.AutoMirrored.Filled.Chat, "Chat"), - CONVERSATIONS("conversations", Icons.Default.Forum, "Conversations"), - AGENTS("agents", Icons.Default.SmartToy, "Agents"), - FILES("files", Icons.Default.Folder, "Files"), -}