Merge pull request #12 from garfiec/feature/nav3-migration

Migrate to Navigation Compose 3 with type-safe routes
This commit is contained in:
Garfie Chiu 2026-04-04 22:32:51 -05:00 committed by GitHub
commit d8bdfa843b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 1112 additions and 1199 deletions

View file

@ -4,18 +4,19 @@ Native mobile client for LibreChat (Android & iOS). Connects to existing LibreCh
## Tech Stack ## Tech Stack
- **UI**: Jetpack Compose + Compose Navigation - **UI**: Jetpack Compose + Navigation Compose 3 (Nav 3)
- **DI**: Koin (KMP-ready) - **DI**: Koin (KMP-ready)
- **Network**: Ktor Client (OkHttp engine) - **Network**: Ktor Client (OkHttp engine)
- **Serialization**: Kotlinx Serialization - **Serialization**: Kotlinx Serialization
- **Local Storage**: Room (cache), DataStore (prefs), EncryptedSharedPreferences (tokens) - **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 ## Module Layout
``` ```
app/ → Single Activity, adaptive navigation (phone/tablet) 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/common/ → Result type, dispatcher DI, coroutine scopes, extensions
core/model/ → @Serializable data classes (pure Kotlin, no Android deps) core/model/ → @Serializable data classes (pure Kotlin, no Android deps)
core/network/ → Ktor client, 16 API services, SSE client, auth interceptor 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 ## Architecture Rules
- Feature modules depend on `:core:*` only, never on each other - Feature modules depend on `:core:*` only, never on each other
- Single Activity with Compose Navigation - Single Activity with Nav 3 (`NavDisplay` + `NavBackStack<NavKey>` + `entryProvider`)
- Unidirectional data flow: UI → ViewModel → Repository → API/Room - Unidirectional data flow: UI → ViewModel → Repository → API/Room
- Room is a read-through cache; server is source of truth - Room is a read-through cache; server is source of truth
- Custom SSE parser over raw ByteReadChannel (not Ktor SSE plugin) - Custom SSE parser over raw ByteReadChannel (not Ktor SSE plugin)

View file

@ -1,14 +1,5 @@
# Known Issues # 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 ## Chat search: sub-message scroll precision

View file

@ -4,11 +4,13 @@ Single Activity architecture. `MainActivity` is the sole entry point.
## Navigation ## 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<NavKey>` 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. - **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. - **Tablet** (600dp+ width): Persistent side panel with full conversation list. No `NavigationRail` or bottom bar.
Feature modules provide entries via `EntryProviderScope<NavKey>` 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 ## Sidebar / Drawer Content
`DrawerContent` is the rich sidebar matching the web's left panel: `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 ## Top-Level Destinations
Defined in `TopLevelDestination` enum: Chat, Conversations, Agents, Files, Settings. 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 remain registered in the NavHost. 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 ## Active Conversation Tracking
@ -32,8 +34,8 @@ Conversations are integrated into the drawer body. Agents, Files, and Settings a
## Auth & Session ## Auth & Session
- `NavHostViewModel` observes auth state via `isLoggedIn` flow - `NavHostViewModel` observes auth state via `isLoggedIn` flow
- Start destination is `CHAT_GRAPH_ROUTE` if logged in, `AUTH_GRAPH_ROUTE` otherwise - Start destination is `NewChat` if logged in, `ServerUrl` otherwise
- `sessionExpired` flow triggers nav to auth graph with full backstack clear - `sessionExpired` flow triggers nav to auth flow with full back stack clear
- Drawer gestures are hidden during auth flow - Drawer gestures are hidden during auth flow
## Deep Linking ## 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 - **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 Navigation
- `PRESET_MANAGER_ROUTE` registered in `SettingsNavigation.kt` - `PresetManager` route registered in `SettingsNavigation.kt`
- `PresetManagerScreen` accessible from Settings → Chat section - `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

View file

@ -2,6 +2,7 @@ plugins {
id("librechat.mobile.application") id("librechat.mobile.application")
id("librechat.mobile.compose") id("librechat.mobile.compose")
id("librechat.mobile.koin") id("librechat.mobile.koin")
id("librechat.kotlin.serialization")
} }
android { android {
@ -30,7 +31,8 @@ dependencies {
implementation(project(":feature:files")) implementation(project(":feature:files"))
implementation(libs.activity.compose) 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)
implementation(libs.koin.compose.viewmodel) implementation(libs.koin.compose.viewmodel)
implementation(libs.koin.compose.viewmodel.navigation) implementation(libs.koin.compose.viewmodel.navigation)

View file

@ -51,8 +51,8 @@ class MainActivity : ComponentActivity() {
/** /**
* Incremented each time a share intent arrives. * Incremented each time a share intent arrives.
* The NavHost observes this and either: * The NavDisplay observes this and either:
* - Navigates to NEW_CHAT_ROUTE if the user is not on a chat screen, or * - 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. * - Lets the active ChatViewModel consume the shared content in-place if already on a chat screen.
*/ */
private var shareNavigationTrigger by mutableStateOf(0) private var shareNavigationTrigger by mutableStateOf(0)

View file

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

View file

@ -81,7 +81,7 @@ private val ActiveIndicatorShape = RoundedCornerShape(2.dp)
/** /**
* Stateful DrawerContent that collects its own state from the ViewModel. * Stateful DrawerContent that collects its own state from the ViewModel.
* State changes only recompose inside this composable not the parent * 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 @Composable
fun DrawerContent( fun DrawerContent(

View file

@ -1,7 +1,6 @@
package com.garfiec.librechat.navigation package com.garfiec.librechat.navigation
import android.net.Uri import android.net.Uri
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
@ -9,7 +8,9 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut 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.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -29,45 +30,48 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.NavHost import androidx.navigation3.runtime.NavKey
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation3.runtime.entryProvider
import androidx.navigation.compose.rememberNavController 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.MainActivity
import com.garfiec.librechat.R import com.garfiec.librechat.R
import com.garfiec.librechat.core.ui.components.BannerDisplay import com.garfiec.librechat.core.ui.components.BannerDisplay
import com.garfiec.librechat.feature.agents.navigation.AGENT_EDITOR_CREATE_ROUTE import com.garfiec.librechat.feature.agents.navigation.AgentMarketplace
import com.garfiec.librechat.feature.agents.navigation.agentsGraph import com.garfiec.librechat.feature.agents.navigation.agentsEntries
import com.garfiec.librechat.feature.auth.navigation.AUTH_GRAPH_ROUTE import com.garfiec.librechat.feature.auth.navigation.AuthRoute
import com.garfiec.librechat.feature.auth.navigation.authGraph import com.garfiec.librechat.feature.auth.navigation.ServerUrl
import com.garfiec.librechat.feature.chat.navigation.CHAT_GRAPH_ROUTE import com.garfiec.librechat.feature.auth.navigation.authEntries
import com.garfiec.librechat.feature.chat.navigation.CHAT_ROUTE import com.garfiec.librechat.feature.chat.navigation.Chat
import com.garfiec.librechat.feature.chat.navigation.NEW_CHAT_ROUTE import com.garfiec.librechat.feature.chat.navigation.NewChat
import com.garfiec.librechat.feature.chat.navigation.chatGraph import com.garfiec.librechat.feature.chat.navigation.chatEntries
import com.garfiec.librechat.feature.chat.navigation.navigateToChat import com.garfiec.librechat.feature.conversations.navigation.ArchivedConversations
import com.garfiec.librechat.feature.conversations.navigation.conversationsGraph import com.garfiec.librechat.feature.conversations.navigation.conversationsEntries
import com.garfiec.librechat.feature.files.navigation.filesGraph import com.garfiec.librechat.feature.files.navigation.Files
import com.garfiec.librechat.feature.settings.navigation.API_KEYS_ROUTE import com.garfiec.librechat.feature.files.navigation.filesEntries
import com.garfiec.librechat.feature.settings.navigation.PRESET_MANAGER_ROUTE import com.garfiec.librechat.feature.settings.navigation.SettingsAccount
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_ACCOUNT_ROUTE import com.garfiec.librechat.feature.settings.navigation.SettingsChat
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_CHAT_ROUTE import com.garfiec.librechat.feature.settings.navigation.SettingsData
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_DATA_ROUTE import com.garfiec.librechat.feature.settings.navigation.SettingsGeneral
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_GENERAL_ROUTE import com.garfiec.librechat.feature.settings.navigation.SettingsRoute
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_TABBED_ROUTE import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed
import com.garfiec.librechat.feature.settings.navigation.SHARED_LINKS_ROUTE import com.garfiec.librechat.feature.settings.navigation.memoriesEntry
import com.garfiec.librechat.feature.settings.navigation.settingsGraph import com.garfiec.librechat.feature.settings.navigation.mcpServersEntry
import com.garfiec.librechat.feature.settings.navigation.settingsEntries
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
/** Maps a [SettingsCategory] to its corresponding navigation route. */ /** Maps a [SettingsCategory] to its corresponding typed navigation route. */
fun SettingsCategory.toRoute(): String = when (this) { fun SettingsCategory.toRoute(): SettingsRoute = when (this) {
SettingsCategory.GENERAL -> SETTINGS_GENERAL_ROUTE SettingsCategory.GENERAL -> SettingsGeneral
SettingsCategory.CHAT -> SETTINGS_CHAT_ROUTE SettingsCategory.CHAT -> SettingsChat
SettingsCategory.ACCOUNT -> SETTINGS_ACCOUNT_ROUTE SettingsCategory.ACCOUNT -> SettingsAccount
SettingsCategory.DATA -> SETTINGS_DATA_ROUTE SettingsCategory.DATA -> SettingsData
} }
@Composable @Composable
@ -79,47 +83,40 @@ fun LibreChatNavHost(
shareNavigationTrigger: Int = 0, shareNavigationTrigger: Int = 0,
navHostViewModel: NavHostViewModel = koinViewModel(), navHostViewModel: NavHostViewModel = koinViewModel(),
) { ) {
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
val isLoggedIn by navHostViewModel.isLoggedIn.collectAsStateWithLifecycle() val isLoggedIn by navHostViewModel.isLoggedIn.collectAsStateWithLifecycle()
val isInAuthFlow = currentDestination?.route == AUTH_GRAPH_ROUTE || // Stable start key — auth redirect handled via LaunchedEffect below.
currentDestination?.parent?.route == AUTH_GRAPH_ROUTE 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 // Track active conversation from nav back stack
LaunchedEffect(navBackStackEntry) { LaunchedEffect(navigator.currentRoute) {
val route = navBackStackEntry?.destination?.route val conversationId = (navigator.currentRoute as? Chat)?.conversationId
val conversationId = if (route == CHAT_ROUTE) {
navBackStackEntry?.arguments?.getString("conversationId")
} else {
null
}
navHostViewModel.setActiveConversation(conversationId) navHostViewModel.setActiveConversation(conversationId)
} }
// Handle session expiry // Handle session expiry
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
navHostViewModel.sessionExpired.collect { navHostViewModel.sessionExpired.collect {
navController.navigate(AUTH_GRAPH_ROUTE) { navigator.navigateToAuth()
popUpTo(0) { inclusive = true }
}
} }
} }
val startDestination = if (isLoggedIn) CHAT_GRAPH_ROUTE else AUTH_GRAPH_ROUTE
val isTablet = windowSizeClass?.widthSizeClass?.let { val isTablet = windowSizeClass?.widthSizeClass?.let {
it >= WindowWidthSizeClass.Medium it >= WindowWidthSizeClass.Medium
} ?: false } ?: false
if (isTablet) { if (isTablet) {
TabletLayout( TabletLayout(
navController = navController, navigator = navigator,
navHostViewModel = navHostViewModel, navHostViewModel = navHostViewModel,
startDestination = startDestination,
isInAuthFlow = isInAuthFlow,
deepLinkUri = deepLinkUri, deepLinkUri = deepLinkUri,
onDeepLinkConsumed = onDeepLinkConsumed, onDeepLinkConsumed = onDeepLinkConsumed,
shareNavigationTrigger = shareNavigationTrigger, shareNavigationTrigger = shareNavigationTrigger,
@ -127,10 +124,8 @@ fun LibreChatNavHost(
) )
} else { } else {
PhoneLayout( PhoneLayout(
navController = navController, navigator = navigator,
navHostViewModel = navHostViewModel, navHostViewModel = navHostViewModel,
startDestination = startDestination,
isInAuthFlow = isInAuthFlow,
deepLinkUri = deepLinkUri, deepLinkUri = deepLinkUri,
onDeepLinkConsumed = onDeepLinkConsumed, onDeepLinkConsumed = onDeepLinkConsumed,
shareNavigationTrigger = shareNavigationTrigger, shareNavigationTrigger = shareNavigationTrigger,
@ -191,10 +186,8 @@ private fun VersionMismatchDialog(
@Composable @Composable
private fun PhoneLayout( private fun PhoneLayout(
navController: androidx.navigation.NavHostController, navigator: Navigator,
navHostViewModel: NavHostViewModel, navHostViewModel: NavHostViewModel,
startDestination: String,
isInAuthFlow: Boolean,
deepLinkUri: Uri?, deepLinkUri: Uri?,
onDeepLinkConsumed: () -> Unit, onDeepLinkConsumed: () -> Unit,
shareNavigationTrigger: Int, shareNavigationTrigger: Int,
@ -202,7 +195,6 @@ private fun PhoneLayout(
) { ) {
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// Banner state only -- drawer state is collected inside DrawerContent itself
val banners by navHostViewModel.banners.collectAsStateWithLifecycle() val banners by navHostViewModel.banners.collectAsStateWithLifecycle()
val dismissedBannerIds by navHostViewModel.dismissedBannerIds.collectAsStateWithLifecycle() val dismissedBannerIds by navHostViewModel.dismissedBannerIds.collectAsStateWithLifecycle()
@ -212,7 +204,7 @@ private fun PhoneLayout(
if (uri.scheme == "librechat" && uri.host == "conversation") { if (uri.scheme == "librechat" && uri.host == "conversation") {
uri.lastPathSegment?.let { conversationId -> uri.lastPathSegment?.let { conversationId ->
if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) { if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) {
navController.navigateToChat(conversationId) navigator.navigateToChat(conversationId)
} else { } else {
Logger.w { "Ignoring deep link with invalid conversation ID: $conversationId" } 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 // Handle share intent
// consume the shared content reactively. Only navigate to new chat when on a
// non-chat screen (e.g. settings, agents, files).
LaunchedEffect(shareNavigationTrigger) { LaunchedEffect(shareNavigationTrigger) {
if (shareNavigationTrigger > 0) { if (shareNavigationTrigger > 0) {
val currentRoute = navController.currentBackStackEntry?.destination?.route val currentRoute = navigator.currentRoute
val isOnChatScreen = currentRoute == CHAT_ROUTE || currentRoute == NEW_CHAT_ROUTE val isOnChatScreen = currentRoute is Chat || currentRoute is NewChat
if (!isOnChatScreen) { if (!isOnChatScreen) {
navController.navigate(NEW_CHAT_ROUTE) { navigator.navigateToTopLevel(NewChat)
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false }
launchSingleTop = true
}
} }
// 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( ModalNavigationDrawer(
drawerState = drawerState, drawerState = drawerState,
gesturesEnabled = !isInAuthFlow, gesturesEnabled = !navigator.isInAuthFlow,
drawerContent = { drawerContent = {
ModalDrawerSheet { ModalDrawerSheet {
SidebarScaffold( SidebarScaffold(
viewModel = navHostViewModel, viewModel = navHostViewModel,
onNewChat = { onNewChat = {
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
// Skip navigation if already on the new chat screen if (navigator.currentRoute !is NewChat) {
val currentRoute = navController.currentBackStackEntry?.destination?.route navigator.navigateToTopLevel(NewChat)
if (currentRoute != NEW_CHAT_ROUTE) {
navController.navigate(NEW_CHAT_ROUTE) {
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false }
launchSingleTop = true
}
} }
}, },
onConversationClick = { conversationId -> onConversationClick = { conversationId ->
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
navController.navigateToChat(conversationId) navigator.navigateToChat(conversationId)
}, },
onSettingsClick = { onSettingsClick = {
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
navController.navigate(SETTINGS_TABBED_ROUTE) { navigator.navigate(SettingsTabbed)
launchSingleTop = true
}
}, },
onSettingsCategorySelected = { category -> onSettingsCategorySelected = { category ->
// Navigate to the settings sub-page but keep the drawer open navigator.navigate(category.toRoute())
navController.navigate(category.toRoute()) {
launchSingleTop = true
}
}, },
onAgentsClick = { onAgentsClick = {
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
navController.navigate(TopLevelDestination.AGENTS.route) { navigator.navigate(AgentMarketplace)
launchSingleTop = true
}
}, },
onFilesClick = { onFilesClick = {
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
navController.navigate(TopLevelDestination.FILES.route) { navigator.navigate(Files)
launchSingleTop = true
}
}, },
) )
} }
}, },
) { ) {
// No bottom bar -- drawer is the primary navigation
Column(modifier = modifier.fillMaxSize()) { Column(modifier = modifier.fillMaxSize()) {
if (!isInAuthFlow && banners.isNotEmpty()) { if (!navigator.isInAuthFlow && banners.isNotEmpty()) {
BannerDisplay( BannerDisplay(
banners = banners, banners = banners,
dismissedIds = dismissedBannerIds, dismissedIds = dismissedBannerIds,
@ -307,122 +277,100 @@ private fun PhoneLayout(
modifier = Modifier.padding(top = 4.dp), modifier = Modifier.padding(top = 4.dp),
) )
} }
NavHost( MainNavDisplay(
navController = navController, navigator = navigator,
startDestination = startDestination, navHostViewModel = navHostViewModel,
onOpenDrawer = { scope.launch { drawerState.open() } },
modifier = Modifier.fillMaxSize(), 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() },
)
},
)
}

View file

@ -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
}

View file

@ -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<NavKey>) {
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)
}
}

View file

@ -2,18 +2,9 @@ package com.garfiec.librechat.navigation
import android.net.Uri import android.net.Uri
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.core.Animatable 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.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.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.input.pointer.util.VelocityTracker
import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController import androidx.navigation3.runtime.NavKey
import androidx.navigation.compose.NavHost
import com.garfiec.librechat.MainActivity import com.garfiec.librechat.MainActivity
import com.garfiec.librechat.core.ui.components.BannerDisplay import com.garfiec.librechat.core.ui.components.BannerDisplay
import com.garfiec.librechat.feature.agents.navigation.AGENT_EDITOR_CREATE_ROUTE import com.garfiec.librechat.feature.agents.navigation.AgentMarketplace
import com.garfiec.librechat.feature.agents.navigation.agentsGraph import com.garfiec.librechat.feature.chat.navigation.Chat
import com.garfiec.librechat.feature.auth.navigation.AUTH_GRAPH_ROUTE import com.garfiec.librechat.feature.chat.navigation.NewChat
import com.garfiec.librechat.feature.auth.navigation.authGraph import com.garfiec.librechat.feature.files.navigation.Files
import com.garfiec.librechat.feature.chat.navigation.CHAT_GRAPH_ROUTE import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed
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 kotlinx.coroutines.launch import kotlinx.coroutines.launch
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
@ -67,10 +45,8 @@ private const val FLING_VELOCITY_THRESHOLD = 800f
@Composable @Composable
fun TabletLayout( fun TabletLayout(
navController: NavHostController, navigator: Navigator,
navHostViewModel: NavHostViewModel, navHostViewModel: NavHostViewModel,
startDestination: String,
isInAuthFlow: Boolean,
deepLinkUri: Uri?, deepLinkUri: Uri?,
onDeepLinkConsumed: () -> Unit, onDeepLinkConsumed: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -81,8 +57,6 @@ fun TabletLayout(
val dismissedBannerIds by navHostViewModel.dismissedBannerIds.collectAsStateWithLifecycle() val dismissedBannerIds by navHostViewModel.dismissedBannerIds.collectAsStateWithLifecycle()
// Persisted sidebar state from DataStore -- single source of truth in the ViewModel. // 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() val isSidebarOpen by navHostViewModel.tabletSidebarOpen.collectAsStateWithLifecycle()
// Whether swipe gesture is enabled (from settings) // Whether swipe gesture is enabled (from settings)
@ -92,7 +66,6 @@ fun TabletLayout(
val sidebarWidthPx = with(density) { SidebarWidth.toPx() } val sidebarWidthPx = with(density) { SidebarWidth.toPx() }
// Animatable tracks sidebar reveal in pixels: 0 = closed, sidebarWidthPx = open. // 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 sidebarOffset = remember { Animatable(if (isSidebarOpen) sidebarWidthPx else 0f) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -107,7 +80,7 @@ fun TabletLayout(
if (uri.scheme == "librechat" && uri.host == "conversation") { if (uri.scheme == "librechat" && uri.host == "conversation") {
uri.lastPathSegment?.let { conversationId -> uri.lastPathSegment?.let { conversationId ->
if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) { if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) {
navController.navigateToChat(conversationId) navigator.navigateToChat(conversationId)
} else { } else {
Logger.w { "Ignoring deep link with invalid conversation ID: $conversationId" } 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 // Handle share intent
// consume the shared content reactively. Only navigate to new chat when on a
// non-chat screen (e.g. settings, agents, files).
LaunchedEffect(shareNavigationTrigger) { LaunchedEffect(shareNavigationTrigger) {
if (shareNavigationTrigger > 0) { if (shareNavigationTrigger > 0) {
val currentRoute = navController.currentBackStackEntry?.destination?.route val currentRoute = navigator.currentRoute
val isOnChatScreen = currentRoute == CHAT_ROUTE || currentRoute == NEW_CHAT_ROUTE val isOnChatScreen = currentRoute is Chat || currentRoute is NewChat
if (!isOnChatScreen) { if (!isOnChatScreen) {
navController.navigate(NEW_CHAT_ROUTE) { navigator.navigateToTopLevel(NewChat)
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false }
launchSingleTop = true
}
} }
} }
} }
@ -148,7 +116,7 @@ fun TabletLayout(
} }
} }
if (!isInAuthFlow) { if (!navigator.isInAuthFlow) {
val swipeModifier = if (gestureEnabled) { val swipeModifier = if (gestureEnabled) {
Modifier.pointerInput(Unit) { Modifier.pointerInput(Unit) {
val velocityTracker = VelocityTracker() val velocityTracker = VelocityTracker()
@ -159,7 +127,6 @@ fun TabletLayout(
onDragEnd = { onDragEnd = {
val velocity = velocityTracker.calculateVelocity().x val velocity = velocityTracker.calculateVelocity().x
val currentOffset = sidebarOffset.value val currentOffset = sidebarOffset.value
// Decide target: fling velocity wins, otherwise use 50% threshold
val shouldOpen = when { val shouldOpen = when {
velocity > FLING_VELOCITY_THRESHOLD -> true velocity > FLING_VELOCITY_THRESHOLD -> true
velocity < -FLING_VELOCITY_THRESHOLD -> false velocity < -FLING_VELOCITY_THRESHOLD -> false
@ -177,7 +144,6 @@ fun TabletLayout(
} }
}, },
onDragCancel = { onDragCancel = {
// Snap back to the current committed state
val target = if (isSidebarOpen) sidebarWidthPx else 0f val target = if (isSidebarOpen) sidebarWidthPx else 0f
scope.launch { scope.launch {
sidebarOffset.animateTo( sidebarOffset.animateTo(
@ -214,37 +180,24 @@ fun TabletLayout(
SidebarScaffold( SidebarScaffold(
viewModel = navHostViewModel, viewModel = navHostViewModel,
onNewChat = { onNewChat = {
// Skip navigation if already on the new chat screen if (navigator.currentRoute !is NewChat) {
val currentRoute = navController.currentBackStackEntry?.destination?.route navigator.navigateToTopLevel(NewChat)
if (currentRoute != NEW_CHAT_ROUTE) {
navController.navigate(NEW_CHAT_ROUTE) {
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false }
launchSingleTop = true
}
} }
}, },
onConversationClick = { conversationId -> onConversationClick = { conversationId ->
navController.navigateToChat(conversationId) navigator.navigateToChat(conversationId)
}, },
onSettingsClick = { onSettingsClick = {
navController.navigate(SETTINGS_TABBED_ROUTE) { navigator.navigate(SettingsTabbed)
launchSingleTop = true
}
}, },
onSettingsCategorySelected = { category -> onSettingsCategorySelected = { category ->
navController.navigate(category.toRoute()) { navigator.navigate(category.toRoute())
launchSingleTop = true
}
}, },
onAgentsClick = { onAgentsClick = {
navController.navigate(TopLevelDestination.AGENTS.route) { navigator.navigate(AgentMarketplace)
launchSingleTop = true
}
}, },
onFilesClick = { onFilesClick = {
navController.navigate(TopLevelDestination.FILES.route) { navigator.navigate(Files)
launchSingleTop = true
}
}, },
modifier = Modifier modifier = Modifier
.width(SidebarWidth) .width(SidebarWidth)
@ -256,9 +209,8 @@ fun TabletLayout(
} }
// Slot 1: Main content -- resizes to fill remaining space // Slot 1: Main content -- resizes to fill remaining space
MainContent( MainContent(
navController = navController, navigator = navigator,
navHostViewModel = navHostViewModel, navHostViewModel = navHostViewModel,
startDestination = startDestination,
isInAuthFlow = false, isInAuthFlow = false,
banners = banners, banners = banners,
dismissedBannerIds = dismissedBannerIds, dismissedBannerIds = dismissedBannerIds,
@ -272,29 +224,24 @@ fun TabletLayout(
val sidebarPx = SidebarWidth.roundToPx() val sidebarPx = SidebarWidth.roundToPx()
val animatedPx = sidebarOffset.value.toInt() val animatedPx = sidebarOffset.value.toInt()
// Sidebar: always measured at full 320dp width
val sidebarPlaceable = measurables[0].measure( val sidebarPlaceable = measurables[0].measure(
constraints.copy(minWidth = sidebarPx, maxWidth = sidebarPx), 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 mainWidth = (constraints.maxWidth - animatedPx).coerceAtLeast(0)
val mainPlaceable = measurables[1].measure( val mainPlaceable = measurables[1].measure(
constraints.copy(minWidth = mainWidth, maxWidth = mainWidth), constraints.copy(minWidth = mainWidth, maxWidth = mainWidth),
) )
layout(constraints.maxWidth, constraints.maxHeight) { layout(constraints.maxWidth, constraints.maxHeight) {
// Sidebar slides: -320px (off-screen) -> 0px (fully visible)
sidebarPlaceable.placeRelative(animatedPx - sidebarPx, 0) sidebarPlaceable.placeRelative(animatedPx - sidebarPx, 0)
// Main content starts right after the visible sidebar portion
mainPlaceable.placeRelative(animatedPx, 0) mainPlaceable.placeRelative(animatedPx, 0)
} }
} }
} else { } else {
// Auth flow -- no sidebar, just main content // Auth flow -- no sidebar, just main content
MainContent( MainContent(
navController = navController, navigator = navigator,
navHostViewModel = navHostViewModel, navHostViewModel = navHostViewModel,
startDestination = startDestination,
isInAuthFlow = true, isInAuthFlow = true,
banners = banners, banners = banners,
dismissedBannerIds = dismissedBannerIds, dismissedBannerIds = dismissedBannerIds,
@ -306,9 +253,8 @@ fun TabletLayout(
@Composable @Composable
private fun MainContent( private fun MainContent(
navController: NavHostController, navigator: Navigator,
navHostViewModel: NavHostViewModel, navHostViewModel: NavHostViewModel,
startDestination: String,
isInAuthFlow: Boolean, isInAuthFlow: Boolean,
banners: List<com.garfiec.librechat.core.model.Banner>, banners: List<com.garfiec.librechat.core.model.Banner>,
dismissedBannerIds: Set<String>, dismissedBannerIds: Set<String>,
@ -324,119 +270,11 @@ private fun MainContent(
modifier = Modifier.padding(top = 4.dp), modifier = Modifier.padding(top = 4.dp),
) )
} }
NavHost( MainNavDisplay(
navController = navController, navigator = navigator,
startDestination = startDestination, navHostViewModel = navHostViewModel,
onOpenDrawer = onToggleDrawer,
modifier = Modifier.fillMaxSize(), 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()
},
)
}
} }
} }

View file

@ -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"),
}

View file

@ -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())
}
}

View file

@ -2,16 +2,31 @@
Convention plugins that apply consistent Gradle configuration across all modules. 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 | | 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.library` | `AndroidLibraryConventionPlugin` | AGP library plugin, same SDK/JDK config |
| `librechat.mobile.compose` | `AndroidComposeConventionPlugin` | Compose compiler, Compose BOM, Material 3 | | `librechat.mobile.compose` | `AndroidComposeConventionPlugin` | Compose compiler, Compose BOM, Material 3 |
| `librechat.mobile.koin` | `AndroidKoinConventionPlugin` | Koin core + Android dependencies | | `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.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 | | `librechat.kotlin.serialization` | `KotlinSerializationConventionPlugin` | Kotlinx Serialization plugin + JSON dependency |
## Critical: apply() Signature ## Critical: apply() Signature
@ -32,6 +47,6 @@ build.gradle.kts files. Use `libs.` accessors (e.g., `libs.koin.android`, `libs.
## Feature Module Convention ## Feature Module Convention
Feature modules use `id("librechat.mobile.feature")` which auto-applies library, compose, Feature modules use `id("librechat.kmp.feature")` (KMP) or `id("librechat.mobile.feature")` (Android-only) which auto-applies library, compose,
koin, and serialization. They only need to add feature-specific dependencies. koin, serialization, and Nav 3. They only need to add feature-specific dependencies.
Feature modules depend on `:core:*` only, never on other feature modules. Feature modules depend on `:core:*` only, never on other feature modules.

View file

@ -17,7 +17,8 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
add("implementation", libs.findLibrary("koin-compose").get()) add("implementation", libs.findLibrary("koin-compose").get())
add("implementation", libs.findLibrary("koin-compose-viewmodel").get()) add("implementation", libs.findLibrary("koin-compose-viewmodel").get())
add("implementation", libs.findLibrary("koin-compose-viewmodel-navigation").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()) add("implementation", libs.findBundle("lifecycle").get())
} }
} }

View file

@ -20,9 +20,10 @@ class KmpFeatureConventionPlugin : Plugin<Project> {
implementation(libs.findLibrary("koin-compose").get()) implementation(libs.findLibrary("koin-compose").get())
implementation(libs.findLibrary("koin-compose-viewmodel").get()) implementation(libs.findLibrary("koin-compose-viewmodel").get())
implementation(libs.findLibrary("koin-compose-viewmodel-navigation").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-runtime-compose-kmp").get())
implementation(libs.findLibrary("lifecycle-viewmodel-compose-kmp").get()) implementation(libs.findLibrary("lifecycle-viewmodel-compose-kmp").get())
implementation(libs.findLibrary("lifecycle-viewmodel-navigation3-kmp").get())
} }
} }
} }

View file

@ -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. 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). 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).

View file

@ -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<EnterExitState>,
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
}
}
}

View file

@ -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<EnterExitState>,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
)

View file

@ -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<EnterExitState>,
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()
}
}

View file

@ -5,8 +5,9 @@
- **AgentDetailScreen** -- full agent info with "Start Chat" action - **AgentDetailScreen** -- full agent info with "Start Chat" action
## Navigation ## Navigation
- `AGENTS_ROUTE` ("agents") -> marketplace grid - Sealed interface: `AgentsRoute : NavKey` with typed route classes
- `AGENT_DETAIL_ROUTE` ("agents/{agentId}") -> detail view - Routes: `AgentMarketplace` (grid), `AgentDetail(agentId: String)` (detail), `AgentEditorCreate`, `AgentEditorEdit(agentId: String)` (all `@Serializable`)
- Feature entries registered via `EntryProviderScope<NavKey>.agentsEntries()`
- `onStartChat(agentId)` callback navigates to chat with the selected agent - `onStartChat(agentId)` callback navigates to chat with the selected agent
## Marketplace ViewModel ## Marketplace ViewModel

View file

@ -14,14 +14,22 @@ val agentsModule = module {
includes(agentsPlatformModule) includes(agentsPlatformModule)
viewModelOf(::AgentMarketplaceViewModel) viewModelOf(::AgentMarketplaceViewModel)
viewModelOf(::AgentDetailViewModel) viewModel { params ->
viewModel { AgentDetailViewModel(
savedStateHandle = get(),
agentRepository = get(),
serverDataStore = get(),
initialAgentId = params.getOrNull(),
)
}
viewModel { params ->
AgentEditorViewModel( AgentEditorViewModel(
savedStateHandle = get(), savedStateHandle = get(),
agentRepository = get(), agentRepository = get(),
configRepository = get(), configRepository = get(),
mcpRepository = get(), mcpRepository = get(),
contentReader = get(), contentReader = get(),
initialAgentId = params.getOrNull(),
) )
} }
} }

View file

@ -1,60 +1,64 @@
package com.garfiec.librechat.feature.agents.navigation package com.garfiec.librechat.feature.agents.navigation
import androidx.navigation.NavGraphBuilder import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation.NavType import androidx.navigation3.runtime.NavKey
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.garfiec.librechat.feature.agents.screen.AgentDetailScreen import com.garfiec.librechat.feature.agents.screen.AgentDetailScreen
import com.garfiec.librechat.feature.agents.screen.AgentEditorScreen import com.garfiec.librechat.feature.agents.screen.AgentEditorScreen
import com.garfiec.librechat.feature.agents.screen.AgentMarketplaceScreen import com.garfiec.librechat.feature.agents.screen.AgentMarketplaceScreen
import kotlinx.serialization.Serializable
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import kotlinx.serialization.modules.subclass
const val AGENTS_ROUTE = "agents" @Serializable sealed interface AgentsRoute : NavKey
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}"
fun NavGraphBuilder.agentsGraph( @Serializable data object AgentMarketplace : AgentsRoute
onAgentClick: (String) -> Unit, @Serializable data class AgentDetail(val agentId: String) : AgentsRoute
@Serializable data object AgentEditorCreate : AgentsRoute
@Serializable data class AgentEditorEdit(val agentId: String) : AgentsRoute
fun EntryProviderScope<NavKey>.agentsEntries(
onNavigate: (NavKey) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
onStartChat: (String) -> Unit, onStartChat: (String) -> Unit,
onCreateAgent: () -> Unit,
onEditAgent: (String) -> Unit,
) { ) {
composable(AGENTS_ROUTE) { entry<AgentMarketplace> {
AgentMarketplaceScreen( AgentMarketplaceScreen(
onAgentClick = onAgentClick, onAgentClick = { agentId -> onNavigate(AgentDetail(agentId = agentId)) },
onCreateAgent = onCreateAgent, onCreateAgent = { onNavigate(AgentEditorCreate) },
onBack = onBack, onBack = onBack,
) )
} }
composable( entry<AgentDetail> { key ->
route = AGENT_DETAIL_ROUTE,
arguments = listOf(
navArgument("agentId") { type = NavType.StringType },
),
) {
AgentDetailScreen( AgentDetailScreen(
onBack = onBack, onBack = onBack,
onStartChat = onStartChat, onStartChat = onStartChat,
onEdit = onEditAgent, onEdit = { agentId -> onNavigate(AgentEditorEdit(agentId = agentId)) },
onDuplicated = onAgentClick, onDuplicated = { agentId -> onNavigate(AgentDetail(agentId = agentId)) },
agentId = key.agentId,
) )
} }
composable(AGENT_EDITOR_CREATE_ROUTE) { entry<AgentEditorCreate> {
AgentEditorScreen( AgentEditorScreen(
onBack = onBack, onBack = onBack,
onSaved = { agentId -> onAgentClick(agentId) }, onSaved = { agentId -> onNavigate(AgentDetail(agentId = agentId)) },
agentId = null,
) )
} }
composable( entry<AgentEditorEdit> { key ->
route = AGENT_EDITOR_EDIT_ROUTE,
arguments = listOf(
navArgument("agentId") { type = NavType.StringType },
),
) {
AgentEditorScreen( AgentEditorScreen(
onBack = onBack, 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())
}
}

View file

@ -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.AgentDetailEvent
import com.garfiec.librechat.feature.agents.viewmodel.AgentDetailViewModel import com.garfiec.librechat.feature.agents.viewmodel.AgentDetailViewModel
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@ -65,8 +66,9 @@ fun AgentDetailScreen(
onStartChat: (String) -> Unit, onStartChat: (String) -> Unit,
onEdit: (String) -> Unit, onEdit: (String) -> Unit,
onDuplicated: (String) -> Unit, onDuplicated: (String) -> Unit,
agentId: String? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: AgentDetailViewModel = koinViewModel(), viewModel: AgentDetailViewModel = koinViewModel { parametersOf(agentId) },
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()

View file

@ -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.AgentEditorEvent
import com.garfiec.librechat.feature.agents.viewmodel.AgentEditorViewModel import com.garfiec.librechat.feature.agents.viewmodel.AgentEditorViewModel
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AgentEditorScreen( fun AgentEditorScreen(
onBack: () -> Unit, onBack: () -> Unit,
onSaved: (String) -> Unit, onSaved: (String) -> Unit,
agentId: String? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onDeleted: () -> Unit = onBack, onDeleted: () -> Unit = onBack,
onDuplicated: (String) -> Unit = onSaved, onDuplicated: (String) -> Unit = onSaved,
viewModel: AgentEditorViewModel = koinViewModel(), viewModel: AgentEditorViewModel = koinViewModel { parametersOf(agentId) },
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var showToolDialog by rememberSaveable { mutableStateOf(false) } var showToolDialog by rememberSaveable { mutableStateOf(false) }

View file

@ -36,9 +36,10 @@ class AgentDetailViewModel(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val agentRepository: AgentRepository, private val agentRepository: AgentRepository,
private val serverDataStore: ServerDataStore, private val serverDataStore: ServerDataStore,
initialAgentId: String? = null,
) : ViewModel() { ) : ViewModel() {
private val agentId: String = checkNotNull(savedStateHandle["agentId"]) private val agentId: String = checkNotNull(initialAgentId) { "agentId must be provided" }
private val _uiState = MutableStateFlow(AgentDetailUiState()) private val _uiState = MutableStateFlow(AgentDetailUiState())
val uiState: StateFlow<AgentDetailUiState> = _uiState.asStateFlow() val uiState: StateFlow<AgentDetailUiState> = _uiState.asStateFlow()

View file

@ -107,9 +107,10 @@ class AgentEditorViewModel(
private val configRepository: ConfigRepository, private val configRepository: ConfigRepository,
private val mcpRepository: McpRepository, private val mcpRepository: McpRepository,
private val contentReader: ContentReader, private val contentReader: ContentReader,
initialAgentId: String? = null,
) : ViewModel() { ) : ViewModel() {
private val editAgentId: String? = savedStateHandle["agentId"] private val editAgentId: String? = initialAgentId
private val _uiState = MutableStateFlow( private val _uiState = MutableStateFlow(
AgentEditorUiState( AgentEditorUiState(

View file

@ -8,10 +8,12 @@
- **TwoFactorScreen** -- 6-digit OTP input with backup code fallback - **TwoFactorScreen** -- 6-digit OTP input with backup code fallback
## Navigation ## Navigation
- Graph route: `AUTH_GRAPH_ROUTE` ("auth_graph"), start destination: `SERVER_URL_ROUTE` - Sealed interface: `AuthRoute : NavKey` with typed route classes
- Flow: ServerUrl -> Login -> (2FA if `tempToken` returned) -> `onAuthComplete` - Routes: `ServerUrl`, `Login`, `Register`, `ForgotPassword`, `TwoFactor(tempToken)`, `VerifyEmail(email)`, `ResetPassword(userId, token)`, `Terms` (all `@Serializable`)
- Feature entries registered via `EntryProviderScope<NavKey>.authEntries()`
- Flow: `ServerUrl``Login` → (2FA if `tempToken` returned) → `onAuthComplete`
- Register and ForgotPassword are lateral routes from Login - 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 ## OAuth Flow
- `OAuthManager` opens Chrome Custom Tabs to `{serverUrl}/api/oauth/{provider}` - `OAuthManager` opens Chrome Custom Tabs to `{serverUrl}/api/oauth/{provider}`
@ -37,7 +39,7 @@
### Terms Screen ### Terms Screen
- `TermsScreen` + `TermsViewModel` — displays server terms, "I Accept" button - `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 - Loads terms text via `UserRepository`, posts acceptance on confirm
- `TermsViewModel.consumeAccepted()` resets navigation trigger - `TermsViewModel.consumeAccepted()` resets navigation trigger
- **Gotcha**: Terms check should happen after login if `startupConfig.requireTerms` is true - **Gotcha**: Terms check should happen after login if `startupConfig.requireTerms` is true

View file

@ -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.TwoFactorViewModel
import com.garfiec.librechat.feature.auth.viewmodel.VerifyEmailViewModel import com.garfiec.librechat.feature.auth.viewmodel.VerifyEmailViewModel
import org.koin.core.module.Module import org.koin.core.module.Module
import org.koin.core.module.dsl.viewModel
import org.koin.core.module.dsl.viewModelOf import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module import org.koin.dsl.module
@ -19,9 +20,28 @@ val authModule = module {
viewModelOf(::ServerUrlViewModel) viewModelOf(::ServerUrlViewModel)
viewModelOf(::LoginViewModel) viewModelOf(::LoginViewModel)
viewModelOf(::RegisterViewModel) viewModelOf(::RegisterViewModel)
viewModelOf(::VerifyEmailViewModel) viewModel { params ->
VerifyEmailViewModel(
savedStateHandle = get(),
userRepository = get(),
initialEmail = params.getOrNull(),
)
}
viewModelOf(::ForgotPasswordViewModel) viewModelOf(::ForgotPasswordViewModel)
viewModelOf(::ResetPasswordViewModel) viewModel { params ->
viewModelOf(::TwoFactorViewModel) ResetPasswordViewModel(
savedStateHandle = get(),
authRepository = get(),
initialUserId = params.getOrNull(),
initialToken = params.getOrNull(),
)
}
viewModel { params ->
TwoFactorViewModel(
savedStateHandle = get(),
authRepository = get(),
initialTempToken = params.getOrNull(),
)
}
viewModelOf(::TermsViewModel) viewModelOf(::TermsViewModel)
} }

View file

@ -1,12 +1,7 @@
package com.garfiec.librechat.feature.auth.navigation package com.garfiec.librechat.feature.auth.navigation
import androidx.navigation.NavController import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation.NavGraphBuilder import androidx.navigation3.runtime.NavKey
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 com.garfiec.librechat.feature.auth.screen.ForgotPasswordScreen import com.garfiec.librechat.feature.auth.screen.ForgotPasswordScreen
import com.garfiec.librechat.feature.auth.screen.LoginScreen import com.garfiec.librechat.feature.auth.screen.LoginScreen
import com.garfiec.librechat.feature.auth.screen.RegisterScreen 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.TermsScreen
import com.garfiec.librechat.feature.auth.screen.TwoFactorScreen import com.garfiec.librechat.feature.auth.screen.TwoFactorScreen
import com.garfiec.librechat.feature.auth.screen.VerifyEmailScreen import com.garfiec.librechat.feature.auth.screen.VerifyEmailScreen
const val AUTH_GRAPH_ROUTE = "auth_graph" import kotlinx.serialization.Serializable
const val SERVER_URL_ROUTE = "server_url" import kotlinx.serialization.modules.SerializersModule
const val LOGIN_ROUTE = "login" import kotlinx.serialization.modules.polymorphic
const val REGISTER_ROUTE = "register" import kotlinx.serialization.modules.subclass
const val FORGOT_PASSWORD_ROUTE = "forgot_password"
const val TWO_FACTOR_ROUTE = "two_factor/{tempToken}"
const val VERIFY_EMAIL_ROUTE = "verify_email/{email}"
const val TERMS_ROUTE = "auth/terms"
const val RESET_PASSWORD_ROUTE = "reset_password/{userId}/{token}"
fun NavController.navigateToVerifyEmail(email: String) { @Serializable sealed interface AuthRoute : NavKey
navigate("verify_email/${encodeNavArg(email)}")
}
fun NavController.navigateToResetPassword(userId: String, token: String) { @Serializable data object ServerUrl : AuthRoute
navigate("reset_password/${encodeNavArg(userId)}/${encodeNavArg(token)}") @Serializable data object Login : AuthRoute
} @Serializable data object Register : AuthRoute
@Serializable data object ForgotPassword : AuthRoute
@Serializable data class TwoFactor(val tempToken: String) : AuthRoute
@Serializable data class VerifyEmail(val email: String) : AuthRoute
@Serializable data object Terms : AuthRoute
@Serializable data class ResetPassword(val userId: String, val token: String) : AuthRoute
private fun encodeNavArg(value: String): String = buildString { fun EntryProviderScope<NavKey>.authEntries(
for (c in value) { onNavigate: (NavKey) -> Unit,
when { onBack: () -> Unit,
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,
onAuthComplete: () -> Unit, onAuthComplete: () -> Unit,
) { ) {
navigation(startDestination = SERVER_URL_ROUTE, route = AUTH_GRAPH_ROUTE) { entry<ServerUrl> {
composable(SERVER_URL_ROUTE) { ServerUrlScreen(
ScreenTransitionWrapper(transition) { onServerValidated = { onNavigate(Login) },
ServerUrlScreen( )
onServerValidated = { navController.navigate(LOGIN_ROUTE) }, }
) entry<Login> {
} LoginScreen(
} onLoginSuccess = onAuthComplete,
composable(LOGIN_ROUTE) { onNavigateToRegister = { onNavigate(Register) },
ScreenTransitionWrapper(transition) { onNavigateToForgotPassword = { onNavigate(ForgotPassword) },
LoginScreen( onNavigateToTwoFactor = { tempToken ->
onLoginSuccess = onAuthComplete, onNavigate(TwoFactor(tempToken = tempToken))
onNavigateToRegister = { navController.navigate(REGISTER_ROUTE) }, },
onNavigateToForgotPassword = { navController.navigate(FORGOT_PASSWORD_ROUTE) }, )
onNavigateToTwoFactor = { tempToken -> }
navController.navigate("two_factor/$tempToken") entry<Register> {
}, RegisterScreen(
) onRegistered = onBack,
} onNavigateToLogin = onBack,
} )
composable(REGISTER_ROUTE) { }
ScreenTransitionWrapper(transition) { entry<ForgotPassword> {
RegisterScreen( ForgotPasswordScreen(
onRegistered = { navController.popBackStack(LOGIN_ROUTE, inclusive = false) }, onBack = onBack,
onNavigateToLogin = { navController.popBackStack() }, )
) }
} entry<TwoFactor> { key ->
} TwoFactorScreen(
composable(FORGOT_PASSWORD_ROUTE) { onVerified = onAuthComplete,
ScreenTransitionWrapper(transition) { onBack = onBack,
ForgotPasswordScreen( tempToken = key.tempToken,
onBack = { navController.popBackStack() }, )
) }
} entry<VerifyEmail> { key ->
} VerifyEmailScreen(
composable( onVerified = onBack,
route = TWO_FACTOR_ROUTE, onBack = onBack,
arguments = listOf(navArgument("tempToken") { type = NavType.StringType }), email = key.email,
) { )
ScreenTransitionWrapper(transition) { }
TwoFactorScreen( entry<Terms> {
onVerified = onAuthComplete, TermsScreen(
onBack = { navController.popBackStack() }, onAccepted = onAuthComplete,
) onBack = onBack,
} )
} }
composable( entry<ResetPassword> { key ->
route = VERIFY_EMAIL_ROUTE, ResetPasswordScreen(
arguments = listOf(navArgument("email") { type = NavType.StringType }), onResetComplete = onBack,
) { onBack = onBack,
ScreenTransitionWrapper(transition) { userId = key.userId,
VerifyEmailScreen( token = key.token,
onVerified = { navController.popBackStack(LOGIN_ROUTE, inclusive = false) }, )
onBack = { navController.popBackStack() }, }
) }
}
} val authSerializersModule = SerializersModule {
composable(TERMS_ROUTE) { polymorphic(NavKey::class) {
ScreenTransitionWrapper(transition) { subclass(ServerUrl::class, ServerUrl.serializer())
TermsScreen( subclass(Login::class, Login.serializer())
onAccepted = onAuthComplete, subclass(Register::class, Register.serializer())
onBack = { navController.popBackStack() }, subclass(ForgotPassword::class, ForgotPassword.serializer())
) subclass(TwoFactor::class, TwoFactor.serializer())
} subclass(VerifyEmail::class, VerifyEmail.serializer())
} subclass(Terms::class, Terms.serializer())
composable( subclass(ResetPassword::class, ResetPassword.serializer())
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() },
)
}
}
} }
} }

View file

@ -35,14 +35,17 @@ import librechat_mobile.feature.auth.generated.resources.Res
import librechat_mobile.feature.auth.generated.resources.* import librechat_mobile.feature.auth.generated.resources.*
import com.garfiec.librechat.feature.auth.viewmodel.ResetPasswordViewModel import com.garfiec.librechat.feature.auth.viewmodel.ResetPasswordViewModel
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ResetPasswordScreen( fun ResetPasswordScreen(
onResetComplete: () -> Unit, onResetComplete: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
userId: String? = null,
token: String? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: ResetPasswordViewModel = koinViewModel(), viewModel: ResetPasswordViewModel = koinViewModel { parametersOf(userId, token) },
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()

View file

@ -42,14 +42,16 @@ import librechat_mobile.feature.auth.generated.resources.Res
import librechat_mobile.feature.auth.generated.resources.* import librechat_mobile.feature.auth.generated.resources.*
import com.garfiec.librechat.feature.auth.viewmodel.TwoFactorViewModel import com.garfiec.librechat.feature.auth.viewmodel.TwoFactorViewModel
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun TwoFactorScreen( fun TwoFactorScreen(
onVerified: () -> Unit, onVerified: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
tempToken: String? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: TwoFactorViewModel = koinViewModel(), viewModel: TwoFactorViewModel = koinViewModel { parametersOf(tempToken) },
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()

View file

@ -34,14 +34,16 @@ import librechat_mobile.feature.auth.generated.resources.Res
import librechat_mobile.feature.auth.generated.resources.* import librechat_mobile.feature.auth.generated.resources.*
import com.garfiec.librechat.feature.auth.viewmodel.VerifyEmailViewModel import com.garfiec.librechat.feature.auth.viewmodel.VerifyEmailViewModel
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun VerifyEmailScreen( fun VerifyEmailScreen(
onVerified: () -> Unit, onVerified: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
email: String? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: VerifyEmailViewModel = koinViewModel(), viewModel: VerifyEmailViewModel = koinViewModel { parametersOf(email) },
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val email = uiState.email val email = uiState.email

View file

@ -23,10 +23,12 @@ data class ResetPasswordUiState(
class ResetPasswordViewModel( class ResetPasswordViewModel(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
initialUserId: String? = null,
initialToken: String? = null,
) : ViewModel() { ) : ViewModel() {
private val userId: String = savedStateHandle["userId"] ?: "" private val userId: String = initialUserId ?: ""
private val token: String = savedStateHandle["token"] ?: "" private val token: String = initialToken ?: ""
private val _uiState = MutableStateFlow(ResetPasswordUiState()) private val _uiState = MutableStateFlow(ResetPasswordUiState())
val uiState: StateFlow<ResetPasswordUiState> = _uiState.asStateFlow() val uiState: StateFlow<ResetPasswordUiState> = _uiState.asStateFlow()

View file

@ -24,9 +24,10 @@ data class TwoFactorUiState(
class TwoFactorViewModel( class TwoFactorViewModel(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
initialTempToken: String? = null,
) : ViewModel() { ) : ViewModel() {
private val tempToken: String = savedStateHandle["tempToken"] ?: "" private val tempToken: String = initialTempToken ?: ""
private val _uiState = MutableStateFlow(TwoFactorUiState()) private val _uiState = MutableStateFlow(TwoFactorUiState())
val uiState: StateFlow<TwoFactorUiState> = _uiState.asStateFlow() val uiState: StateFlow<TwoFactorUiState> = _uiState.asStateFlow()

View file

@ -26,10 +26,11 @@ data class VerifyEmailUiState(
class VerifyEmailViewModel( class VerifyEmailViewModel(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val userRepository: UserRepository, private val userRepository: UserRepository,
initialEmail: String? = null,
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow( private val _uiState = MutableStateFlow(
VerifyEmailUiState(email = savedStateHandle["email"] ?: ""), VerifyEmailUiState(email = initialEmail ?: ""),
) )
val uiState: StateFlow<VerifyEmailUiState> = _uiState.asStateFlow() val uiState: StateFlow<VerifyEmailUiState> = _uiState.asStateFlow()

View file

@ -7,10 +7,11 @@
- **Active**: message list + streaming content + input area - **Active**: message list + streaming content + input area
## Navigation ## Navigation
- `CHAT_GRAPH_ROUTE` with `NEW_CHAT_ROUTE` (landing) and `CHAT_ROUTE` ("chat/{conversationId}") - Sealed interface: `ChatRoute : NavKey` with typed route classes
- `PROMPTS_LIBRARY_ROUTE` is co-located here for prompts library screen - Routes: `NewChat` (landing), `Chat(conversationId: String? = null)`, `PromptsLibrary`, `PromptEditor(groupId: String? = null)` (all `@Serializable`)
- 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 - Feature entries registered via `EntryProviderScope<NavKey>.chatEntries()`
- `navigateToChat()` replaces the current chat entry when switching between chats so back returns to `new_chat` - 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 ## Message Tree
- Messages stored flat, linked by `parentMessageId` (root = `NO_PARENT` UUID) - Messages stored flat, linked by `parentMessageId` (root = `NO_PARENT` UUID)

View file

@ -18,9 +18,10 @@ actual val chatPlatformModule: Module = module {
) )
} }
viewModel { viewModel { params ->
ChatViewModel( ChatViewModel(
savedStateHandle = get(), savedStateHandle = get(),
initialConversationId = params.getOrNull(),
agentRepository = get(), agentRepository = get(),
chatRepository = get(), chatRepository = get(),
messageRepository = get(), messageRepository = get(),

View file

@ -97,18 +97,20 @@ import com.garfiec.librechat.feature.chat.viewmodel.ChatScreenState
import com.garfiec.librechat.feature.chat.viewmodel.ChatViewModel import com.garfiec.librechat.feature.chat.viewmodel.ChatViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
actual fun ChatScreen( actual fun ChatScreen(
modifier: Modifier, modifier: Modifier,
conversationId: String?,
onConversationStarted: ((String) -> Unit)?, onConversationStarted: ((String) -> Unit)?,
onNavigateToConversation: ((String) -> Unit)?, onNavigateToConversation: ((String) -> Unit)?,
onOpenDrawer: (() -> Unit)?, onOpenDrawer: (() -> Unit)?,
onNavigateToPromptsLibrary: (() -> Unit)?, onNavigateToPromptsLibrary: (() -> Unit)?,
onNavigateBack: (() -> Unit)?, onNavigateBack: (() -> Unit)?,
) { ) {
val viewModel: ChatViewModel = koinViewModel() val viewModel: ChatViewModel = koinViewModel { parametersOf(conversationId) }
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val attachedFiles by viewModel.attachedFiles.collectAsStateWithLifecycle() val attachedFiles by viewModel.attachedFiles.collectAsStateWithLifecycle()
val shareLinkUrl by viewModel.shareLinkUrl.collectAsStateWithLifecycle() val shareLinkUrl by viewModel.shareLinkUrl.collectAsStateWithLifecycle()
@ -232,9 +234,9 @@ actual fun ChatScreen(
} }
} }
// When a new conversation starts, navigate to chat/{conversationId} immediately // When a new conversation starts, navigate to Chat(conversationId) immediately
// (at StreamEvent.Created) so the new_chat landing page stays clean in the back // (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. // stack. The new ChatViewModel at Chat(id) will resume the active stream.
// onPendingNavigationHandled() resets this ViewModel to a fresh landing state. // onPendingNavigationHandled() resets this ViewModel to a fresh landing state.
LaunchedEffect(uiState.pendingNavigationConversationId) { LaunchedEffect(uiState.pendingNavigationConversationId) {
val pendingId = uiState.pendingNavigationConversationId val pendingId = uiState.pendingNavigationConversationId

View file

@ -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.PromptEditorViewModel
import com.garfiec.librechat.feature.chat.prompts.PromptsViewModel import com.garfiec.librechat.feature.chat.prompts.PromptsViewModel
import org.koin.core.module.Module import org.koin.core.module.Module
import org.koin.core.module.dsl.viewModel
import org.koin.core.module.dsl.viewModelOf import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module import org.koin.dsl.module
val chatModule = module { val chatModule = module {
includes(chatPlatformModule) includes(chatPlatformModule)
viewModelOf(::PromptsViewModel) viewModelOf(::PromptsViewModel)
viewModelOf(::PromptEditorViewModel) viewModel { params ->
PromptEditorViewModel(
promptRepository = get(),
savedStateHandle = get(),
initialGroupId = params.getOrNull(),
)
}
} }
expect val chatPlatformModule: Module expect val chatPlatformModule: Module

View file

@ -1,117 +1,69 @@
package com.garfiec.librechat.feature.chat.navigation package com.garfiec.librechat.feature.chat.navigation
import androidx.compose.animation.EnterTransition import androidx.navigation3.runtime.EntryProviderScope
import androidx.compose.animation.ExitTransition import androidx.navigation3.runtime.NavKey
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.feature.chat.prompts.PromptEditorScreen import com.garfiec.librechat.feature.chat.prompts.PromptEditorScreen
import com.garfiec.librechat.feature.chat.prompts.PromptsLibraryScreen import com.garfiec.librechat.feature.chat.prompts.PromptsLibraryScreen
import com.garfiec.librechat.feature.chat.screen.ChatScreen import com.garfiec.librechat.feature.chat.screen.ChatScreen
import com.garfiec.librechat.feature.chat.screen.NewChatScreen import com.garfiec.librechat.feature.chat.screen.NewChatScreen
import kotlinx.serialization.Serializable
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import kotlinx.serialization.modules.subclass
const val CHAT_GRAPH_ROUTE = "chat_graph" @Serializable sealed interface ChatRoute : NavKey
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}"
fun NavController.navigateToChat(conversationId: String) { @Serializable data object NewChat : ChatRoute
val currentEntry = currentBackStackEntry @Serializable data class Chat(val conversationId: String? = null) : ChatRoute
val isCurrentlyInChat = currentEntry?.destination?.route == CHAT_ROUTE @Serializable data object PromptsLibrary : ChatRoute
val currentDestId = currentEntry?.destination?.id @Serializable data class PromptEditor(val groupId: String? = null) : ChatRoute
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
}
}
fun NavController.navigateToPromptsLibrary() { fun EntryProviderScope<NavKey>.chatEntries(
navigate(PROMPTS_LIBRARY_ROUTE) onNavigate: (NavKey) -> Unit,
} onBack: () -> Unit,
onNavigateToChat: (String) -> Unit,
fun NavController.navigateToPromptEditor(groupId: String? = null) {
if (groupId != null) {
navigate("prompt_editor?groupId=$groupId")
} else {
navigate("prompt_editor")
}
}
fun NavGraphBuilder.chatGraph(
navController: NavController,
onOpenDrawer: (() -> Unit)? = null, onOpenDrawer: (() -> Unit)? = null,
) { ) {
navigation(startDestination = NEW_CHAT_ROUTE, route = CHAT_GRAPH_ROUTE) { entry<NewChat> {
composable( NewChatScreen(
route = NEW_CHAT_ROUTE, onConversationStarted = { conversationId ->
enterTransition = { EnterTransition.None }, onNavigateToChat(conversationId)
exitTransition = { ExitTransition.None }, },
popEnterTransition = { null }, onOpenDrawer = onOpenDrawer,
popExitTransition = { null }, onNavigateToPromptsLibrary = { onNavigate(PromptsLibrary) },
) { )
NewChatScreen( }
onConversationStarted = { conversationId -> entry<Chat> { key ->
navController.navigateToChat(conversationId) ChatScreen(
}, conversationId = key.conversationId,
onOpenDrawer = onOpenDrawer, onOpenDrawer = onOpenDrawer,
onNavigateToPromptsLibrary = { navController.navigateToPromptsLibrary() }, onNavigateToPromptsLibrary = { onNavigate(PromptsLibrary) },
) onNavigateBack = onBack,
} onNavigateToConversation = { conversationId -> onNavigateToChat(conversationId) },
composable( )
route = CHAT_ROUTE, }
arguments = listOf( entry<PromptsLibrary> {
navArgument("conversationId") { PromptsLibraryScreen(
type = NavType.StringType onNavigateBack = onBack,
nullable = true onUseInChat = { _ -> onBack() },
defaultValue = null onNavigateToEditor = { groupId ->
}, onNavigate(PromptEditor(groupId = groupId))
), },
enterTransition = { EnterTransition.None }, )
exitTransition = { ExitTransition.None }, }
popEnterTransition = { null }, entry<PromptEditor> { key ->
popExitTransition = { null }, PromptEditorScreen(
) { _ -> onBack = onBack,
ChatScreen( groupId = key.groupId,
onOpenDrawer = onOpenDrawer, )
onNavigateToPromptsLibrary = { navController.navigateToPromptsLibrary() }, }
onNavigateBack = { navController.popBackStack() }, }
onNavigateToConversation = { conversationId -> navController.navigateToChat(conversationId) },
) val chatSerializersModule = SerializersModule {
} polymorphic(NavKey::class) {
composable(PROMPTS_LIBRARY_ROUTE) { subclass(NewChat::class, NewChat.serializer())
PromptsLibraryScreen( subclass(Chat::class, Chat.serializer())
onNavigateBack = { navController.popBackStack() }, subclass(PromptsLibrary::class, PromptsLibrary.serializer())
onUseInChat = { promptText -> subclass(PromptEditor::class, PromptEditor.serializer())
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() },
)
}
} }
} }

View file

@ -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.Res
import librechat_mobile.feature.chat.generated.resources.* import librechat_mobile.feature.chat.generated.resources.*
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PromptEditorScreen( fun PromptEditorScreen(
onBack: () -> Unit, onBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: PromptEditorViewModel = koinViewModel(), groupId: String? = null,
viewModel: PromptEditorViewModel = koinViewModel { parametersOf(groupId) },
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }

View file

@ -40,13 +40,14 @@ data class PromptEditorUiState(
class PromptEditorViewModel( class PromptEditorViewModel(
private val promptRepository: PromptRepository, private val promptRepository: PromptRepository,
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
initialGroupId: String? = null,
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(PromptEditorUiState()) private val _uiState = MutableStateFlow(PromptEditorUiState())
val uiState: StateFlow<PromptEditorUiState> = _uiState.asStateFlow() val uiState: StateFlow<PromptEditorUiState> = _uiState.asStateFlow()
init { init {
val groupId = savedStateHandle.get<String>("groupId") val groupId = initialGroupId
if (groupId != null) { if (groupId != null) {
loadGroup(groupId) loadGroup(groupId)
} }

View file

@ -7,6 +7,7 @@ import androidx.compose.ui.Modifier
@Composable @Composable
expect fun ChatScreen( expect fun ChatScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
conversationId: String? = null,
onConversationStarted: ((String) -> Unit)? = null, onConversationStarted: ((String) -> Unit)? = null,
onNavigateToConversation: ((String) -> Unit)? = null, onNavigateToConversation: ((String) -> Unit)? = null,
onOpenDrawer: (() -> Unit)? = null, onOpenDrawer: (() -> Unit)? = null,

View file

@ -125,7 +125,7 @@ data class ChatUiState(
val showDeleteConfirmation: Boolean = false, val showDeleteConfirmation: Boolean = false,
val duplicatedConversationId: String? = null, val duplicatedConversationId: String? = null,
/** Set at StreamEvent.Created when a new conversation's conversationId becomes available. /** 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. */ * which also resets this ViewModel to a clean landing state. */
val pendingNavigationConversationId: String? = null, val pendingNavigationConversationId: String? = null,
// Model comparison state // Model comparison state

View file

@ -58,6 +58,7 @@ import kotlin.uuid.Uuid
class ChatViewModel( class ChatViewModel(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
initialConversationId: String? = null,
private val agentRepository: AgentRepository, private val agentRepository: AgentRepository,
private val chatRepository: ChatRepository, private val chatRepository: ChatRepository,
private val messageRepository: MessageRepository, private val messageRepository: MessageRepository,
@ -163,7 +164,7 @@ class ChatViewModel(
private var titleGenerationRequested = false private var titleGenerationRequested = false
init { init {
val conversationId = savedStateHandle.get<String>("conversationId") val conversationId = initialConversationId
isNewConversation = conversationId == null isNewConversation = conversationId == null
if (conversationId != null) { if (conversationId != null) {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
@ -174,7 +175,7 @@ class ChatViewModel(
loadConversationModel(conversationId) loadConversationModel(conversationId)
restoreDraft(conversationId) restoreDraft(conversationId)
// Check if there's an active stream for this conversation (e.g. when // 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. // resume it so the user sees streaming content on this screen.
resumeActiveStreamIfNeeded(conversationId) resumeActiveStreamIfNeeded(conversationId)
} else { } else {
@ -255,7 +256,7 @@ class ChatViewModel(
} }
// Restore MCP server and tool selections from DataStore so they // 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 { viewModelScope.launch {
val mcpServers = settingsDataStore.selectedMcpServers.first() val mcpServers = settingsDataStore.selectedMcpServers.first()
val tools = settingsDataStore.enabledTools.first() val tools = settingsDataStore.enabledTools.first()

View file

@ -16,9 +16,10 @@ actual val chatPlatformModule: Module = module {
) )
} }
viewModel { viewModel { params ->
ChatViewModel( ChatViewModel(
savedStateHandle = get(), savedStateHandle = get(),
initialConversationId = params.getOrNull(),
agentRepository = get(), agentRepository = get(),
chatRepository = get(), chatRepository = get(),
messageRepository = get(), messageRepository = get(),

View file

@ -81,18 +81,20 @@ import librechat_mobile.feature.chat.generated.resources.Res
import librechat_mobile.feature.chat.generated.resources.* import librechat_mobile.feature.chat.generated.resources.*
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
actual fun ChatScreen( actual fun ChatScreen(
modifier: Modifier, modifier: Modifier,
conversationId: String?,
onConversationStarted: ((String) -> Unit)?, onConversationStarted: ((String) -> Unit)?,
onNavigateToConversation: ((String) -> Unit)?, onNavigateToConversation: ((String) -> Unit)?,
onOpenDrawer: (() -> Unit)?, onOpenDrawer: (() -> Unit)?,
onNavigateToPromptsLibrary: (() -> Unit)?, onNavigateToPromptsLibrary: (() -> Unit)?,
onNavigateBack: (() -> Unit)?, onNavigateBack: (() -> Unit)?,
) { ) {
val viewModel: ChatViewModel = koinViewModel() val viewModel: ChatViewModel = koinViewModel { parametersOf(conversationId) }
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val attachedFiles by viewModel.attachedFiles.collectAsStateWithLifecycle() val attachedFiles by viewModel.attachedFiles.collectAsStateWithLifecycle()
val prefs by viewModel.chatPreferences.collectAsStateWithLifecycle() val prefs by viewModel.chatPreferences.collectAsStateWithLifecycle()

View file

@ -1,7 +1,7 @@
# feature:conversations # feature:conversations
## Screen ## 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 ## Pagination
- Cursor-based via `ConversationRepository.loadNextPage(cursor, tags)` - Cursor-based via `ConversationRepository.loadNextPage(cursor, tags)`

View file

@ -1,32 +1,40 @@
package com.garfiec.librechat.feature.conversations.navigation package com.garfiec.librechat.feature.conversations.navigation
import androidx.navigation.NavGraphBuilder import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation.compose.composable import androidx.navigation3.runtime.NavKey
import com.garfiec.librechat.core.ui.components.ScreenTransitionWrapper
import com.garfiec.librechat.feature.conversations.screen.ArchivedConversationsScreen import com.garfiec.librechat.feature.conversations.screen.ArchivedConversationsScreen
import com.garfiec.librechat.feature.conversations.screen.ConversationListScreen import com.garfiec.librechat.feature.conversations.screen.ConversationListScreen
import kotlinx.serialization.Serializable
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import kotlinx.serialization.modules.subclass
const val CONVERSATIONS_ROUTE = "conversations" @Serializable sealed interface ConversationsRoute : NavKey
const val ARCHIVED_ROUTE = "conversations/archived"
fun NavGraphBuilder.conversationsGraph( @Serializable data object Conversations : ConversationsRoute
@Serializable data object ArchivedConversations : ConversationsRoute
fun EntryProviderScope<NavKey>.conversationsEntries(
onConversationClick: (String) -> Unit, onConversationClick: (String) -> Unit,
onNavigateToArchived: () -> Unit = {}, onNavigateToArchived: () -> Unit = {},
onNavigateBackFromArchived: () -> Unit = {}, onBack: () -> Unit = {},
) { ) {
composable(CONVERSATIONS_ROUTE) { entry<Conversations> {
ScreenTransitionWrapper(transition) { ConversationListScreen(
ConversationListScreen( onConversationClick = onConversationClick,
onConversationClick = onConversationClick, onNavigateToArchived = onNavigateToArchived,
onNavigateToArchived = onNavigateToArchived, )
)
}
} }
composable(ARCHIVED_ROUTE) { entry<ArchivedConversations> {
ScreenTransitionWrapper(transition) { ArchivedConversationsScreen(
ArchivedConversationsScreen( onNavigateBack = onBack,
onNavigateBack = onNavigateBackFromArchived, )
) }
} }
val conversationsSerializersModule = SerializersModule {
polymorphic(NavKey::class) {
subclass(Conversations::class, Conversations.serializer())
subclass(ArchivedConversations::class, ArchivedConversations.serializer())
} }
} }

View file

@ -1,7 +1,7 @@
# feature:files # feature:files
## Screen ## 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 ## ViewModel
`FilesViewModel` depends on `FileRepository` and application `Context` (for content resolver). `FilesViewModel` depends on `FileRepository` and application `Context` (for content resolver).

View file

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

View file

@ -1,7 +1,7 @@
# feature:settings # feature:settings
## Screen ## 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 ## Sections
- **Account**: displays user profile from `UserApi.getUser()` - **Account**: displays user profile from `UserApi.getUser()`
@ -46,14 +46,14 @@
- `McpServersScreen` — server list with status badges, CRUD, reinitialize, tools sheet (`McpViewModel`) - `McpServersScreen` — server list with status badges, CRUD, reinitialize, tools sheet (`McpViewModel`)
- `PresetManagerScreen` — list/delete presets (`PresetManagerViewModel`) - `PresetManagerScreen` — list/delete presets (`PresetManagerViewModel`)
- `CommandsConfigScreen` — enable/disable slash commands - `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**: 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**: `McpServerDialog` uses `McpServerType` enum (SSE, STREAMABLE_HTTP) — must match backend expectations
- **Gotcha**: Memory keys are immutable after creation (edit dialog disables key field) - **Gotcha**: Memory keys are immutable after creation (edit dialog disables key field)
### API Keys ### API Keys
- `ApiKeysScreen` — list/create/delete API keys, own ViewModel (`ApiKeysViewModel`) - `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 - `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. - **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.

View file

@ -1,17 +1,18 @@
package com.garfiec.librechat.feature.settings.navigation package com.garfiec.librechat.feature.settings.navigation
import androidx.navigation.NavGraphBuilder import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation.compose.composable import androidx.navigation3.runtime.NavKey
import com.garfiec.librechat.feature.settings.screen.McpServersScreen import com.garfiec.librechat.feature.settings.screen.McpServersScreen
import kotlinx.serialization.Serializable
const val MCP_SERVERS_ROUTE = "settings/mcp" @Serializable data object McpServers : SettingsRoute
fun NavGraphBuilder.mcpServersScreen( fun EntryProviderScope<NavKey>.mcpServersEntry(
onNavigateBack: () -> Unit, onBack: () -> Unit,
) { ) {
composable(MCP_SERVERS_ROUTE) { entry<McpServers> {
McpServersScreen( McpServersScreen(
onNavigateBack = onNavigateBack, onNavigateBack = onBack,
) )
} }
} }

View file

@ -1,17 +1,18 @@
package com.garfiec.librechat.feature.settings.navigation package com.garfiec.librechat.feature.settings.navigation
import androidx.navigation.NavGraphBuilder import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation.compose.composable import androidx.navigation3.runtime.NavKey
import com.garfiec.librechat.feature.settings.screen.MemoriesScreen import com.garfiec.librechat.feature.settings.screen.MemoriesScreen
import kotlinx.serialization.Serializable
const val MEMORIES_ROUTE = "settings/memories" @Serializable data object Memories : SettingsRoute
fun NavGraphBuilder.memoriesScreen( fun EntryProviderScope<NavKey>.memoriesEntry(
onNavigateBack: () -> Unit, onBack: () -> Unit,
) { ) {
composable(MEMORIES_ROUTE) { entry<Memories> {
MemoriesScreen( MemoriesScreen(
onNavigateBack = onNavigateBack, onNavigateBack = onBack,
) )
} }
} }

View file

@ -2,8 +2,8 @@ package com.garfiec.librechat.feature.settings.navigation
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavGraphBuilder import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation.compose.composable import androidx.navigation3.runtime.NavKey
import com.garfiec.librechat.feature.settings.screen.AccountSettingsScreen import com.garfiec.librechat.feature.settings.screen.AccountSettingsScreen
import com.garfiec.librechat.feature.settings.screen.ApiKeysScreen import com.garfiec.librechat.feature.settings.screen.ApiKeysScreen
import com.garfiec.librechat.feature.settings.screen.ChatSettingsScreen 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.SharedLinksScreen
import com.garfiec.librechat.feature.settings.screen.TabbedSettingsScreen import com.garfiec.librechat.feature.settings.screen.TabbedSettingsScreen
import com.garfiec.librechat.feature.settings.viewmodel.SettingsViewModel import com.garfiec.librechat.feature.settings.viewmodel.SettingsViewModel
import kotlinx.serialization.Serializable
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import kotlinx.serialization.modules.subclass
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
const val SETTINGS_TABBED_ROUTE = "settings" @Serializable sealed interface SettingsRoute : NavKey
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"
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<NavKey>.settingsEntries(
onNavigate: (NavKey) -> Unit,
onBack: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
onNavigateBack: () -> Unit,
onNavigateToArchived: () -> Unit = {}, onNavigateToArchived: () -> Unit = {},
onNavigateToSharedLinks: () -> Unit = {},
onNavigateBackFromSharedLinks: () -> Unit = {},
onNavigateToPresets: () -> Unit = {},
onNavigateBackFromPresets: () -> Unit = {},
onNavigateToApiKeys: () -> Unit = {},
onNavigateBackFromApiKeys: () -> Unit = {},
) { ) {
composable(SETTINGS_TABBED_ROUTE) { entry<SettingsTabbed> {
TabbedSettingsScreen( TabbedSettingsScreen(
onNavigateBack = onNavigateBack, onNavigateBack = onBack,
onLogout = onLogout, onLogout = onLogout,
onNavigateToArchived = onNavigateToArchived, onNavigateToArchived = onNavigateToArchived,
onNavigateToSharedLinks = onNavigateToSharedLinks, onNavigateToSharedLinks = { onNavigate(SharedLinks) },
onNavigateToPresets = onNavigateToPresets, onNavigateToPresets = { onNavigate(PresetManager) },
onNavigateToApiKeys = onNavigateToApiKeys, onNavigateToApiKeys = { onNavigate(ApiKeys) },
) )
} }
entry<SettingsGeneral> {
composable(SETTINGS_GENERAL_ROUTE) {
GeneralSettingsScreen( GeneralSettingsScreen(
onNavigateBack = onNavigateBack, onNavigateBack = onBack,
) )
} }
composable(SETTINGS_CHAT_ROUTE) { entry<SettingsChat> {
ChatSettingsScreen( ChatSettingsScreen(
onNavigateBack = onNavigateBack, onNavigateBack = onBack,
onNavigateToPresets = onNavigateToPresets, onNavigateToPresets = { onNavigate(PresetManager) },
) )
} }
composable(SETTINGS_ACCOUNT_ROUTE) { entry<SettingsAccount> {
AccountSettingsScreen( AccountSettingsScreen(
onLogout = onLogout, onLogout = onLogout,
onNavigateBack = onNavigateBack, onNavigateBack = onBack,
onNavigateToApiKeys = onNavigateToApiKeys, onNavigateToApiKeys = { onNavigate(ApiKeys) },
) )
} }
composable(SETTINGS_DATA_ROUTE) { entry<SettingsData> {
DataSettingsScreen( DataSettingsScreen(
onNavigateBack = onNavigateBack, onNavigateBack = onBack,
onNavigateToArchived = onNavigateToArchived, onNavigateToArchived = onNavigateToArchived,
onNavigateToSharedLinks = onNavigateToSharedLinks, onNavigateToSharedLinks = { onNavigate(SharedLinks) },
) )
} }
composable(SHARED_LINKS_ROUTE) { entry<SharedLinks> {
val viewModel: SettingsViewModel = koinViewModel() val viewModel: SettingsViewModel = koinViewModel()
val uiState = viewModel.uiState.collectAsStateWithLifecycle() val uiState = viewModel.uiState.collectAsStateWithLifecycle()
@ -87,17 +87,32 @@ fun NavGraphBuilder.settingsGraph(
onLoadMore = viewModel::loadMoreSharedLinks, onLoadMore = viewModel::loadMoreSharedLinks,
onToggleVisibility = viewModel::toggleSharedLinkVisibility, onToggleVisibility = viewModel::toggleSharedLinkVisibility,
onDelete = viewModel::deleteSharedLink, onDelete = viewModel::deleteSharedLink,
onNavigateBack = onNavigateBackFromSharedLinks, onNavigateBack = onBack,
) )
} }
composable(PRESET_MANAGER_ROUTE) { entry<PresetManager> {
PresetManagerScreen( PresetManagerScreen(
onNavigateBack = onNavigateBackFromPresets, onNavigateBack = onBack,
) )
} }
composable(API_KEYS_ROUTE) { entry<ApiKeys> {
ApiKeysScreen( 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())
}
}

View file

@ -7,10 +7,10 @@ compose-bom = "2025.05.00"
compose-multiplatform = "1.11.0-beta01" compose-multiplatform = "1.11.0-beta01"
material3 = "1.3.1" material3 = "1.3.1"
activity-compose = "1.10.1" activity-compose = "1.10.1"
navigation-compose = "2.9.2"
lifecycle = "2.9.0" lifecycle = "2.9.0"
lifecycle-kmp = "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 # DI
koin = "4.2.0" koin = "4.2.0"
@ -82,14 +82,13 @@ compose-foundation = { group = "androidx.compose.foundation", name = "foundation
# Activity / Navigation # Activity / Navigation
activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } 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
lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "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-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-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" } 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
koin-bom = { group = "io.insert-koin", name = "koin-bom", version.ref = "koin" } koin-bom = { group = "io.insert-koin", name = "koin-bom", version.ref = "koin" }

View file

@ -42,9 +42,10 @@ kotlin {
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
implementation(libs.coroutines.core) implementation(libs.coroutines.core)
implementation(libs.kermit) implementation(libs.kermit)
implementation(libs.navigation.compose.kmp) implementation(libs.navigation3.ui.kmp)
implementation(libs.lifecycle.runtime.compose.kmp) implementation(libs.lifecycle.runtime.compose.kmp)
implementation(libs.lifecycle.viewmodel.compose.kmp) implementation(libs.lifecycle.viewmodel.compose.kmp)
implementation(libs.lifecycle.viewmodel.navigation3.kmp)
implementation(libs.koin.compose) implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel) implementation(libs.koin.compose.viewmodel)
implementation(libs.koin.compose.viewmodel.navigation) implementation(libs.koin.compose.viewmodel.navigation)

View file

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

View file

@ -1,6 +1,5 @@
package com.garfiec.librechat.shared.navigation package com.garfiec.librechat.shared.navigation
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
@ -8,7 +7,9 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut 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.background
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@ -26,34 +27,36 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.savedstate.read import androidx.navigation3.runtime.NavKey
import androidx.navigation.compose.NavHost import androidx.navigation3.runtime.entryProvider
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation.compose.rememberNavController 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.core.ui.components.BannerDisplay
import com.garfiec.librechat.feature.agents.navigation.AGENT_EDITOR_CREATE_ROUTE import com.garfiec.librechat.feature.agents.navigation.AgentMarketplace
import com.garfiec.librechat.feature.agents.navigation.agentsGraph import com.garfiec.librechat.feature.agents.navigation.agentsEntries
import com.garfiec.librechat.feature.auth.navigation.AUTH_GRAPH_ROUTE import com.garfiec.librechat.feature.auth.navigation.AuthRoute
import com.garfiec.librechat.feature.auth.navigation.authGraph import com.garfiec.librechat.feature.auth.navigation.ServerUrl
import com.garfiec.librechat.feature.chat.navigation.CHAT_GRAPH_ROUTE import com.garfiec.librechat.feature.auth.navigation.authEntries
import com.garfiec.librechat.feature.chat.navigation.CHAT_ROUTE import com.garfiec.librechat.feature.chat.navigation.Chat
import com.garfiec.librechat.feature.chat.navigation.NEW_CHAT_ROUTE import com.garfiec.librechat.feature.chat.navigation.NewChat
import com.garfiec.librechat.feature.chat.navigation.chatGraph import com.garfiec.librechat.feature.chat.navigation.chatEntries
import com.garfiec.librechat.feature.chat.navigation.navigateToChat import com.garfiec.librechat.feature.conversations.navigation.ArchivedConversations
import com.garfiec.librechat.feature.conversations.navigation.conversationsGraph import com.garfiec.librechat.feature.conversations.navigation.conversationsEntries
import com.garfiec.librechat.feature.files.navigation.filesGraph import com.garfiec.librechat.feature.files.navigation.Files
import com.garfiec.librechat.feature.settings.navigation.API_KEYS_ROUTE import com.garfiec.librechat.feature.files.navigation.filesEntries
import com.garfiec.librechat.feature.settings.navigation.PRESET_MANAGER_ROUTE import com.garfiec.librechat.feature.settings.navigation.SettingsAccount
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_ACCOUNT_ROUTE import com.garfiec.librechat.feature.settings.navigation.SettingsChat
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_CHAT_ROUTE import com.garfiec.librechat.feature.settings.navigation.SettingsData
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_DATA_ROUTE import com.garfiec.librechat.feature.settings.navigation.SettingsGeneral
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_GENERAL_ROUTE import com.garfiec.librechat.feature.settings.navigation.SettingsRoute
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_TABBED_ROUTE import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed
import com.garfiec.librechat.feature.settings.navigation.SHARED_LINKS_ROUTE import com.garfiec.librechat.feature.settings.navigation.memoriesEntry
import com.garfiec.librechat.feature.settings.navigation.settingsGraph import com.garfiec.librechat.feature.settings.navigation.mcpServersEntry
import com.garfiec.librechat.feature.settings.navigation.settingsEntries
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import librechat_mobile.shared.generated.resources.Res import librechat_mobile.shared.generated.resources.Res
import librechat_mobile.shared.generated.resources.dismiss 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.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
/** Maps a [SettingsCategory] to its corresponding navigation route. */ /** Maps a [SettingsCategory] to its corresponding typed navigation route. */
fun SettingsCategory.toRoute(): String = when (this) { fun SettingsCategory.toRoute(): SettingsRoute = when (this) {
SettingsCategory.GENERAL -> SETTINGS_GENERAL_ROUTE SettingsCategory.GENERAL -> SettingsGeneral
SettingsCategory.CHAT -> SETTINGS_CHAT_ROUTE SettingsCategory.CHAT -> SettingsChat
SettingsCategory.ACCOUNT -> SETTINGS_ACCOUNT_ROUTE SettingsCategory.ACCOUNT -> SettingsAccount
SettingsCategory.DATA -> SETTINGS_DATA_ROUTE SettingsCategory.DATA -> SettingsData
} }
/** /**
@ -84,43 +87,35 @@ fun LibreChatNavHost(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
navHostViewModel: NavHostViewModel = koinViewModel(), navHostViewModel: NavHostViewModel = koinViewModel(),
) { ) {
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
val isLoggedIn by navHostViewModel.isLoggedIn.collectAsStateWithLifecycle() val isLoggedIn by navHostViewModel.isLoggedIn.collectAsStateWithLifecycle()
val isInAuthFlow = currentDestination?.route == AUTH_GRAPH_ROUTE || // Stable start key — auth redirect handled via LaunchedEffect below.
currentDestination?.parent?.route == AUTH_GRAPH_ROUTE val backStack = rememberNavBackStack(navigationSavedStateConfig, NewChat)
val navigator = Navigator(backStack)
// Track active conversation from nav back stack. // Redirect to auth if not logged in (on first composition only).
// Use SavedState.read {} to extract nav args (KMP-compatible, no Bundle.getString). LaunchedEffect(Unit) {
LaunchedEffect(navBackStackEntry) { if (!navHostViewModel.isLoggedIn.value) {
val destRoute = navBackStackEntry?.destination?.route navigator.navigateToAuth()
val conversationId = if (destRoute == CHAT_ROUTE) {
navBackStackEntry?.arguments?.read { getStringOrNull("conversationId") }
} else {
null
} }
}
// Track active conversation from nav back stack
LaunchedEffect(navigator.currentRoute) {
val conversationId = (navigator.currentRoute as? Chat)?.conversationId
navHostViewModel.setActiveConversation(conversationId) navHostViewModel.setActiveConversation(conversationId)
} }
// Handle session expiry // Handle session expiry
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
navHostViewModel.sessionExpired.collect { navHostViewModel.sessionExpired.collect {
navController.navigate(AUTH_GRAPH_ROUTE) { navigator.navigateToAuth()
popUpTo(0) { inclusive = true }
}
} }
} }
val startDestination = if (isLoggedIn) CHAT_GRAPH_ROUTE else AUTH_GRAPH_ROUTE
PhoneLayout( PhoneLayout(
navController = navController, navigator = navigator,
navHostViewModel = navHostViewModel, navHostViewModel = navHostViewModel,
startDestination = startDestination,
isInAuthFlow = isInAuthFlow,
modifier = modifier, modifier = modifier,
) )
@ -172,10 +167,8 @@ private fun VersionMismatchDialog(
@Composable @Composable
private fun PhoneLayout( private fun PhoneLayout(
navController: androidx.navigation.NavHostController, navigator: Navigator,
navHostViewModel: NavHostViewModel, navHostViewModel: NavHostViewModel,
startDestination: String,
isInAuthFlow: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
@ -192,54 +185,42 @@ private fun PhoneLayout(
ModalNavigationDrawer( ModalNavigationDrawer(
drawerState = drawerState, drawerState = drawerState,
gesturesEnabled = !isInAuthFlow, gesturesEnabled = !navigator.isInAuthFlow,
drawerContent = { drawerContent = {
ModalDrawerSheet { ModalDrawerSheet {
SidebarScaffold( SidebarScaffold(
viewModel = navHostViewModel, viewModel = navHostViewModel,
onNewChat = { onNewChat = {
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
val currentRoute = navController.currentBackStackEntry?.destination?.route if (navigator.currentRoute !is NewChat) {
if (currentRoute != NEW_CHAT_ROUTE) { navigator.navigateToTopLevel(NewChat)
navController.navigate(NEW_CHAT_ROUTE) {
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false }
launchSingleTop = true
}
} }
}, },
onConversationClick = { conversationId -> onConversationClick = { conversationId ->
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
navController.navigateToChat(conversationId) navigator.navigateToChat(conversationId)
}, },
onSettingsClick = { onSettingsClick = {
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
navController.navigate(SETTINGS_TABBED_ROUTE) { navigator.navigate(SettingsTabbed)
launchSingleTop = true
}
}, },
onSettingsCategorySelected = { category -> onSettingsCategorySelected = { category ->
navController.navigate(category.toRoute()) { navigator.navigate(category.toRoute())
launchSingleTop = true
}
}, },
onAgentsClick = { onAgentsClick = {
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
navController.navigate(TopLevelDestination.AGENTS.route) { navigator.navigate(AgentMarketplace)
launchSingleTop = true
}
}, },
onFilesClick = { onFilesClick = {
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
navController.navigate(TopLevelDestination.FILES.route) { navigator.navigate(Files)
launchSingleTop = true
}
}, },
) )
} }
}, },
) { ) {
Column(modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { Column(modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
if (!isInAuthFlow && banners.isNotEmpty()) { if (!navigator.isInAuthFlow && banners.isNotEmpty()) {
BannerDisplay( BannerDisplay(
banners = banners, banners = banners,
dismissedIds = dismissedBannerIds, dismissedIds = dismissedBannerIds,
@ -247,124 +228,88 @@ private fun PhoneLayout(
modifier = Modifier.padding(top = 4.dp), modifier = Modifier.padding(top = 4.dp),
) )
} }
NavHost( NavDisplay(
navController = navController, backStack = navigator.backStack,
startDestination = startDestination, onBack = { navigator.goBack() },
modifier = Modifier.fillMaxSize(), transitionSpec = {
enterTransition = { slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(300)) togetherWith
slideIntoContainer( fadeOut(animationSpec = tween(150))
towards = AnimatedContentTransitionScope.SlideDirection.Start, },
animationSpec = tween(300), 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()
},
)
}
} }
} }
} }

View file

@ -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
}

View file

@ -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<NavKey>) {
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)
}
}

View file

@ -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"),
}