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
- **UI**: Jetpack Compose + Compose Navigation
- **UI**: Jetpack Compose + Navigation Compose 3 (Nav 3)
- **DI**: Koin (KMP-ready)
- **Network**: Ktor Client (OkHttp engine)
- **Serialization**: Kotlinx Serialization
- **Local Storage**: Room (cache), DataStore (prefs), EncryptedSharedPreferences (tokens)
- **Build**: Gradle 8.11.1, AGP 8.7.3, Kotlin 2.1.0, compileSdk 35, minSdk 26
- **Build**: Gradle 9.4.1, AGP 9.1.0, Kotlin 2.3.20, compileSdk 36, minSdk 26
## Module Layout
```
app/ → Single Activity, adaptive navigation (phone/tablet)
build-logic/ → 7 convention plugins for consistent Gradle config
shared/ → KMP shared framework (iOS entry point, shared navigation)
build-logic/ → 13 convention plugins for consistent Gradle config
core/common/ → Result type, dispatcher DI, coroutine scopes, extensions
core/model/ → @Serializable data classes (pure Kotlin, no Android deps)
core/network/ → Ktor client, 16 API services, SSE client, auth interceptor
@ -34,7 +35,7 @@ Each module has its own `CLAUDE.md` with specific guidance.
## Architecture Rules
- Feature modules depend on `:core:*` only, never on each other
- Single Activity with Compose Navigation
- Single Activity with Nav 3 (`NavDisplay` + `NavBackStack<NavKey>` + `entryProvider`)
- Unidirectional data flow: UI → ViewModel → Repository → API/Room
- Room is a read-through cache; server is source of truth
- Custom SSE parser over raw ByteReadChannel (not Ktor SSE plugin)

View file

@ -1,14 +1,5 @@
# Known Issues
## Tablet back navigation doesn't dismiss sidebar first
**Status**: Known issue, to be fixed later
**Symptoms**: In tablet mode with the sidebar open, pressing back navigates away from the current chat instead of dismissing the sidebar first. The sidebar remains covering the screen.
**Root cause**: The `BackHandler` in `TabletLayout.kt` is present but the system navigation's back handler takes priority. Needs investigation into back handler dispatch ordering relative to the NavHost.
**Workaround**: Tap the hamburger menu button to toggle the sidebar closed.
## Chat search: sub-message scroll precision

View file

@ -4,11 +4,13 @@ Single Activity architecture. `MainActivity` is the sole entry point.
## Navigation
`LibreChatNavHost` is the root composable. It uses adaptive layout based on `WindowSizeClass`:
`LibreChatNavHost` is the root composable using Nav 3's `NavDisplay` with `NavBackStack<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.
- **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
`DrawerContent` is the rich sidebar matching the web's left panel:
@ -22,8 +24,8 @@ Single Activity architecture. `MainActivity` is the sole entry point.
## Top-Level Destinations
Defined in `TopLevelDestination` enum: Chat, Conversations, Agents, Files, Settings.
Conversations are integrated into the drawer body. Agents, Files, and Settings are accessible via drawer footer links. All routes remain registered in the NavHost.
Top-level routes: `NewChat` (Chat), `Conversations`, `AgentMarketplace` (Agents), `Files`, `SettingsTabbed` (Settings).
Conversations are integrated into the drawer body. Agents, Files, and Settings are accessible via drawer footer links. All routes are registered in the `NavDisplay` entry provider.
## Active Conversation Tracking
@ -32,8 +34,8 @@ Conversations are integrated into the drawer body. Agents, Files, and Settings a
## Auth & Session
- `NavHostViewModel` observes auth state via `isLoggedIn` flow
- Start destination is `CHAT_GRAPH_ROUTE` if logged in, `AUTH_GRAPH_ROUTE` otherwise
- `sessionExpired` flow triggers nav to auth graph with full backstack clear
- Start destination is `NewChat` if logged in, `ServerUrl` otherwise
- `sessionExpired` flow triggers nav to auth flow with full back stack clear
- Drawer gestures are hidden during auth flow
## Deep Linking
@ -66,6 +68,6 @@ It applies convention plugins: `librechat.mobile.application`, `librechat.mobile
- **Gotcha**: `displayFrom`/`displayTo` are ISO 8601 strings parsed with `Instant.parse()` — wrap in `runCatching` since the format from the server is not guaranteed
### Preset Navigation
- `PRESET_MANAGER_ROUTE` registered in `SettingsNavigation.kt`
- `PresetManager` route registered in `SettingsNavigation.kt`
- `PresetManagerScreen` accessible from Settings → Chat section
- Navigation wired in both `LibreChatNavHost` and `TabletLayout`
- Navigation wired through `MainNavDisplay` entry provider, accessible from both phone and tablet layouts

View file

@ -2,6 +2,7 @@ plugins {
id("librechat.mobile.application")
id("librechat.mobile.compose")
id("librechat.mobile.koin")
id("librechat.kotlin.serialization")
}
android {
@ -30,7 +31,8 @@ dependencies {
implementation(project(":feature:files"))
implementation(libs.activity.compose)
implementation(libs.navigation.compose)
implementation(libs.navigation3.ui.kmp)
implementation(libs.lifecycle.viewmodel.navigation3.kmp)
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
implementation(libs.koin.compose.viewmodel.navigation)

View file

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

View file

@ -1,8 +1,11 @@
package com.garfiec.librechat.navigation
import kotlinx.serialization.modules.SerializersModule
import org.koin.core.module.dsl.viewModelOf
import org.koin.core.qualifier.named
import org.koin.dsl.module
val appModule = module {
viewModelOf(::NavHostViewModel)
single<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.
* State changes only recompose inside this composable not the parent
* PhoneLayout/TabletLayout, which avoids recomposing the NavHost/main content.
* PhoneLayout/TabletLayout, which avoids recomposing the NavDisplay/main content.
*/
@Composable
fun DrawerContent(

View file

@ -1,7 +1,6 @@
package com.garfiec.librechat.navigation
import android.net.Uri
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
@ -9,7 +8,9 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
@ -29,45 +30,48 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import com.garfiec.librechat.MainActivity
import com.garfiec.librechat.R
import com.garfiec.librechat.core.ui.components.BannerDisplay
import com.garfiec.librechat.feature.agents.navigation.AGENT_EDITOR_CREATE_ROUTE
import com.garfiec.librechat.feature.agents.navigation.agentsGraph
import com.garfiec.librechat.feature.auth.navigation.AUTH_GRAPH_ROUTE
import com.garfiec.librechat.feature.auth.navigation.authGraph
import com.garfiec.librechat.feature.chat.navigation.CHAT_GRAPH_ROUTE
import com.garfiec.librechat.feature.chat.navigation.CHAT_ROUTE
import com.garfiec.librechat.feature.chat.navigation.NEW_CHAT_ROUTE
import com.garfiec.librechat.feature.chat.navigation.chatGraph
import com.garfiec.librechat.feature.chat.navigation.navigateToChat
import com.garfiec.librechat.feature.conversations.navigation.conversationsGraph
import com.garfiec.librechat.feature.files.navigation.filesGraph
import com.garfiec.librechat.feature.settings.navigation.API_KEYS_ROUTE
import com.garfiec.librechat.feature.settings.navigation.PRESET_MANAGER_ROUTE
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_ACCOUNT_ROUTE
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_CHAT_ROUTE
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_DATA_ROUTE
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_GENERAL_ROUTE
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_TABBED_ROUTE
import com.garfiec.librechat.feature.settings.navigation.SHARED_LINKS_ROUTE
import com.garfiec.librechat.feature.settings.navigation.settingsGraph
import com.garfiec.librechat.feature.agents.navigation.AgentMarketplace
import com.garfiec.librechat.feature.agents.navigation.agentsEntries
import com.garfiec.librechat.feature.auth.navigation.AuthRoute
import com.garfiec.librechat.feature.auth.navigation.ServerUrl
import com.garfiec.librechat.feature.auth.navigation.authEntries
import com.garfiec.librechat.feature.chat.navigation.Chat
import com.garfiec.librechat.feature.chat.navigation.NewChat
import com.garfiec.librechat.feature.chat.navigation.chatEntries
import com.garfiec.librechat.feature.conversations.navigation.ArchivedConversations
import com.garfiec.librechat.feature.conversations.navigation.conversationsEntries
import com.garfiec.librechat.feature.files.navigation.Files
import com.garfiec.librechat.feature.files.navigation.filesEntries
import com.garfiec.librechat.feature.settings.navigation.SettingsAccount
import com.garfiec.librechat.feature.settings.navigation.SettingsChat
import com.garfiec.librechat.feature.settings.navigation.SettingsData
import com.garfiec.librechat.feature.settings.navigation.SettingsGeneral
import com.garfiec.librechat.feature.settings.navigation.SettingsRoute
import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed
import com.garfiec.librechat.feature.settings.navigation.memoriesEntry
import com.garfiec.librechat.feature.settings.navigation.mcpServersEntry
import com.garfiec.librechat.feature.settings.navigation.settingsEntries
import kotlinx.coroutines.launch
import org.koin.compose.viewmodel.koinViewModel
import co.touchlab.kermit.Logger
/** Maps a [SettingsCategory] to its corresponding navigation route. */
fun SettingsCategory.toRoute(): String = when (this) {
SettingsCategory.GENERAL -> SETTINGS_GENERAL_ROUTE
SettingsCategory.CHAT -> SETTINGS_CHAT_ROUTE
SettingsCategory.ACCOUNT -> SETTINGS_ACCOUNT_ROUTE
SettingsCategory.DATA -> SETTINGS_DATA_ROUTE
/** Maps a [SettingsCategory] to its corresponding typed navigation route. */
fun SettingsCategory.toRoute(): SettingsRoute = when (this) {
SettingsCategory.GENERAL -> SettingsGeneral
SettingsCategory.CHAT -> SettingsChat
SettingsCategory.ACCOUNT -> SettingsAccount
SettingsCategory.DATA -> SettingsData
}
@Composable
@ -79,47 +83,40 @@ fun LibreChatNavHost(
shareNavigationTrigger: Int = 0,
navHostViewModel: NavHostViewModel = koinViewModel(),
) {
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
val isLoggedIn by navHostViewModel.isLoggedIn.collectAsStateWithLifecycle()
val isInAuthFlow = currentDestination?.route == AUTH_GRAPH_ROUTE ||
currentDestination?.parent?.route == AUTH_GRAPH_ROUTE
// Stable start key — auth redirect handled via LaunchedEffect below.
val backStack = rememberNavBackStack(navigationSavedStateConfig, NewChat)
val navigator = Navigator(backStack)
// Redirect to auth if not logged in (on first composition only).
LaunchedEffect(Unit) {
if (!navHostViewModel.isLoggedIn.value) {
navigator.navigateToAuth()
}
}
// Track active conversation from nav back stack
LaunchedEffect(navBackStackEntry) {
val route = navBackStackEntry?.destination?.route
val conversationId = if (route == CHAT_ROUTE) {
navBackStackEntry?.arguments?.getString("conversationId")
} else {
null
}
LaunchedEffect(navigator.currentRoute) {
val conversationId = (navigator.currentRoute as? Chat)?.conversationId
navHostViewModel.setActiveConversation(conversationId)
}
// Handle session expiry
LaunchedEffect(Unit) {
navHostViewModel.sessionExpired.collect {
navController.navigate(AUTH_GRAPH_ROUTE) {
popUpTo(0) { inclusive = true }
}
navigator.navigateToAuth()
}
}
val startDestination = if (isLoggedIn) CHAT_GRAPH_ROUTE else AUTH_GRAPH_ROUTE
val isTablet = windowSizeClass?.widthSizeClass?.let {
it >= WindowWidthSizeClass.Medium
} ?: false
if (isTablet) {
TabletLayout(
navController = navController,
navigator = navigator,
navHostViewModel = navHostViewModel,
startDestination = startDestination,
isInAuthFlow = isInAuthFlow,
deepLinkUri = deepLinkUri,
onDeepLinkConsumed = onDeepLinkConsumed,
shareNavigationTrigger = shareNavigationTrigger,
@ -127,10 +124,8 @@ fun LibreChatNavHost(
)
} else {
PhoneLayout(
navController = navController,
navigator = navigator,
navHostViewModel = navHostViewModel,
startDestination = startDestination,
isInAuthFlow = isInAuthFlow,
deepLinkUri = deepLinkUri,
onDeepLinkConsumed = onDeepLinkConsumed,
shareNavigationTrigger = shareNavigationTrigger,
@ -191,10 +186,8 @@ private fun VersionMismatchDialog(
@Composable
private fun PhoneLayout(
navController: androidx.navigation.NavHostController,
navigator: Navigator,
navHostViewModel: NavHostViewModel,
startDestination: String,
isInAuthFlow: Boolean,
deepLinkUri: Uri?,
onDeepLinkConsumed: () -> Unit,
shareNavigationTrigger: Int,
@ -202,7 +195,6 @@ private fun PhoneLayout(
) {
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
// Banner state only -- drawer state is collected inside DrawerContent itself
val banners by navHostViewModel.banners.collectAsStateWithLifecycle()
val dismissedBannerIds by navHostViewModel.dismissedBannerIds.collectAsStateWithLifecycle()
@ -212,7 +204,7 @@ private fun PhoneLayout(
if (uri.scheme == "librechat" && uri.host == "conversation") {
uri.lastPathSegment?.let { conversationId ->
if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) {
navController.navigateToChat(conversationId)
navigator.navigateToChat(conversationId)
} else {
Logger.w { "Ignoring deep link with invalid conversation ID: $conversationId" }
}
@ -222,21 +214,14 @@ private fun PhoneLayout(
}
}
// Handle share intent: if already on a chat screen, let the active ChatViewModel
// consume the shared content reactively. Only navigate to new chat when on a
// non-chat screen (e.g. settings, agents, files).
// Handle share intent
LaunchedEffect(shareNavigationTrigger) {
if (shareNavigationTrigger > 0) {
val currentRoute = navController.currentBackStackEntry?.destination?.route
val isOnChatScreen = currentRoute == CHAT_ROUTE || currentRoute == NEW_CHAT_ROUTE
val currentRoute = navigator.currentRoute
val isOnChatScreen = currentRoute is Chat || currentRoute is NewChat
if (!isOnChatScreen) {
navController.navigate(NEW_CHAT_ROUTE) {
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false }
launchSingleTop = true
}
navigator.navigateToTopLevel(NewChat)
}
// If already on a chat screen, SharedIntentConsumer.shareAvailable
// will notify the active ChatViewModel to consume the content.
}
}
@ -249,57 +234,42 @@ private fun PhoneLayout(
ModalNavigationDrawer(
drawerState = drawerState,
gesturesEnabled = !isInAuthFlow,
gesturesEnabled = !navigator.isInAuthFlow,
drawerContent = {
ModalDrawerSheet {
SidebarScaffold(
viewModel = navHostViewModel,
onNewChat = {
scope.launch { drawerState.close() }
// Skip navigation if already on the new chat screen
val currentRoute = navController.currentBackStackEntry?.destination?.route
if (currentRoute != NEW_CHAT_ROUTE) {
navController.navigate(NEW_CHAT_ROUTE) {
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false }
launchSingleTop = true
}
if (navigator.currentRoute !is NewChat) {
navigator.navigateToTopLevel(NewChat)
}
},
onConversationClick = { conversationId ->
scope.launch { drawerState.close() }
navController.navigateToChat(conversationId)
navigator.navigateToChat(conversationId)
},
onSettingsClick = {
scope.launch { drawerState.close() }
navController.navigate(SETTINGS_TABBED_ROUTE) {
launchSingleTop = true
}
navigator.navigate(SettingsTabbed)
},
onSettingsCategorySelected = { category ->
// Navigate to the settings sub-page but keep the drawer open
navController.navigate(category.toRoute()) {
launchSingleTop = true
}
navigator.navigate(category.toRoute())
},
onAgentsClick = {
scope.launch { drawerState.close() }
navController.navigate(TopLevelDestination.AGENTS.route) {
launchSingleTop = true
}
navigator.navigate(AgentMarketplace)
},
onFilesClick = {
scope.launch { drawerState.close() }
navController.navigate(TopLevelDestination.FILES.route) {
launchSingleTop = true
}
navigator.navigate(Files)
},
)
}
},
) {
// No bottom bar -- drawer is the primary navigation
Column(modifier = modifier.fillMaxSize()) {
if (!isInAuthFlow && banners.isNotEmpty()) {
if (!navigator.isInAuthFlow && banners.isNotEmpty()) {
BannerDisplay(
banners = banners,
dismissedIds = dismissedBannerIds,
@ -307,122 +277,100 @@ private fun PhoneLayout(
modifier = Modifier.padding(top = 4.dp),
)
}
NavHost(
navController = navController,
startDestination = startDestination,
MainNavDisplay(
navigator = navigator,
navHostViewModel = navHostViewModel,
onOpenDrawer = { scope.launch { drawerState.open() } },
modifier = Modifier.fillMaxSize(),
enterTransition = {
slideIntoContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Start,
animationSpec = tween(300),
)
},
exitTransition = {
fadeOut(animationSpec = tween(150))
},
popEnterTransition = {
fadeIn(
initialAlpha = 0.5f,
animationSpec = tween(300, easing = LinearEasing),
) + scaleIn(
initialScale = 0.92f,
animationSpec = tween(300, easing = LinearEasing),
)
},
popExitTransition = {
slideOut(
targetOffset = { IntOffset((it.width * 0.15f).toInt(), 0) },
animationSpec = tween(300, easing = FastOutSlowInEasing),
) + scaleOut(
targetScale = 0.92f,
animationSpec = tween(300, easing = LinearEasing),
)
},
) {
authGraph(
navController = navController,
onAuthComplete = {
navHostViewModel.onAuthComplete()
navController.navigate(CHAT_GRAPH_ROUTE) {
popUpTo(AUTH_GRAPH_ROUTE) { inclusive = true }
}
},
)
chatGraph(
navController = navController,
onOpenDrawer = {
scope.launch { drawerState.open() }
},
)
conversationsGraph(
onConversationClick = { conversationId ->
navController.navigateToChat(conversationId)
},
onNavigateToArchived = {
navController.navigate("conversations/archived")
},
onNavigateBackFromArchived = {
navController.popBackStack()
},
)
agentsGraph(
onAgentClick = { agentId ->
navController.navigate("agents/$agentId")
},
onBack = { navController.popBackStack() },
onStartChat = { agentId ->
navController.navigate(NEW_CHAT_ROUTE) {
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false }
launchSingleTop = true
}
},
onCreateAgent = {
navController.navigate(AGENT_EDITOR_CREATE_ROUTE)
},
onEditAgent = { agentId ->
navController.navigate("agents/editor/$agentId")
},
)
filesGraph()
settingsGraph(
onLogout = {
navHostViewModel.logout()
navController.navigate(AUTH_GRAPH_ROUTE) {
popUpTo(0) { inclusive = true }
}
},
onNavigateBack = { navController.popBackStack() },
onNavigateToArchived = {
navController.navigate("conversations/archived") {
launchSingleTop = true
}
},
onNavigateToSharedLinks = {
navController.navigate(SHARED_LINKS_ROUTE) {
launchSingleTop = true
}
},
onNavigateBackFromSharedLinks = {
navController.popBackStack()
},
onNavigateToPresets = {
navController.navigate(PRESET_MANAGER_ROUTE) {
launchSingleTop = true
}
},
onNavigateBackFromPresets = {
navController.popBackStack()
},
onNavigateToApiKeys = {
navController.navigate(API_KEYS_ROUTE) {
launchSingleTop = true
}
},
onNavigateBackFromApiKeys = {
navController.popBackStack()
},
)
}
)
}
}
}
@Composable
internal fun MainNavDisplay(
navigator: Navigator,
navHostViewModel: NavHostViewModel,
onOpenDrawer: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
NavDisplay(
backStack = navigator.backStack,
onBack = { navigator.goBack() },
modifier = modifier,
transitionSpec = {
slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(300)) togetherWith
fadeOut(animationSpec = tween(150))
},
popTransitionSpec = {
(fadeIn(initialAlpha = 0.5f, animationSpec = tween(300, easing = LinearEasing)) +
scaleIn(initialScale = 0.92f, animationSpec = tween(300, easing = LinearEasing))) togetherWith
(slideOutHorizontally(
targetOffsetX = { (it * 0.15f).toInt() },
animationSpec = tween(300, easing = FastOutSlowInEasing),
) + scaleOut(
targetScale = 0.92f,
animationSpec = tween(300, easing = LinearEasing),
))
},
predictivePopTransitionSpec = {
(fadeIn(initialAlpha = 0.5f, animationSpec = tween(300, easing = LinearEasing)) +
scaleIn(initialScale = 0.92f, animationSpec = tween(300, easing = LinearEasing))) togetherWith
(slideOutHorizontally(
targetOffsetX = { (it * 0.15f).toInt() },
animationSpec = tween(300, easing = FastOutSlowInEasing),
) + scaleOut(
targetScale = 0.92f,
animationSpec = tween(300, easing = LinearEasing),
))
},
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
entryProvider = entryProvider {
authEntries(
onNavigate = { navigator.navigate(it) },
onBack = { navigator.goBack() },
onAuthComplete = {
navHostViewModel.onAuthComplete()
navigator.navigateToChat()
},
)
chatEntries(
onNavigate = { navigator.navigate(it) },
onBack = { navigator.goBack() },
onNavigateToChat = { navigator.navigateToChat(it) },
onOpenDrawer = onOpenDrawer,
)
conversationsEntries(
onConversationClick = { navigator.navigateToChat(it) },
onNavigateToArchived = { navigator.navigate(ArchivedConversations) },
onBack = { navigator.goBack() },
)
agentsEntries(
onNavigate = { navigator.navigate(it) },
onBack = { navigator.goBack() },
onStartChat = { _ -> navigator.navigateToTopLevel(NewChat) },
)
filesEntries(
onBack = { navigator.goBack() },
)
settingsEntries(
onNavigate = { navigator.navigate(it) },
onBack = { navigator.goBack() },
onLogout = {
navHostViewModel.logout()
navigator.navigateToAuth()
},
onNavigateToArchived = { navigator.navigate(ArchivedConversations) },
)
memoriesEntry(
onBack = { navigator.goBack() },
)
mcpServersEntry(
onBack = { navigator.goBack() },
)
},
)
}

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 androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideOut
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -34,29 +25,16 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation3.runtime.NavKey
import com.garfiec.librechat.MainActivity
import com.garfiec.librechat.core.ui.components.BannerDisplay
import com.garfiec.librechat.feature.agents.navigation.AGENT_EDITOR_CREATE_ROUTE
import com.garfiec.librechat.feature.agents.navigation.agentsGraph
import com.garfiec.librechat.feature.auth.navigation.AUTH_GRAPH_ROUTE
import com.garfiec.librechat.feature.auth.navigation.authGraph
import com.garfiec.librechat.feature.chat.navigation.CHAT_GRAPH_ROUTE
import com.garfiec.librechat.feature.chat.navigation.CHAT_ROUTE
import com.garfiec.librechat.feature.chat.navigation.NEW_CHAT_ROUTE
import com.garfiec.librechat.feature.chat.navigation.chatGraph
import com.garfiec.librechat.feature.chat.navigation.navigateToChat
import com.garfiec.librechat.feature.conversations.navigation.conversationsGraph
import com.garfiec.librechat.feature.files.navigation.filesGraph
import com.garfiec.librechat.feature.settings.navigation.API_KEYS_ROUTE
import com.garfiec.librechat.feature.settings.navigation.PRESET_MANAGER_ROUTE
import com.garfiec.librechat.feature.settings.navigation.SETTINGS_TABBED_ROUTE
import com.garfiec.librechat.feature.settings.navigation.SHARED_LINKS_ROUTE
import com.garfiec.librechat.feature.settings.navigation.settingsGraph
import com.garfiec.librechat.feature.agents.navigation.AgentMarketplace
import com.garfiec.librechat.feature.chat.navigation.Chat
import com.garfiec.librechat.feature.chat.navigation.NewChat
import com.garfiec.librechat.feature.files.navigation.Files
import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed
import kotlinx.coroutines.launch
import co.touchlab.kermit.Logger
@ -67,10 +45,8 @@ private const val FLING_VELOCITY_THRESHOLD = 800f
@Composable
fun TabletLayout(
navController: NavHostController,
navigator: Navigator,
navHostViewModel: NavHostViewModel,
startDestination: String,
isInAuthFlow: Boolean,
deepLinkUri: Uri?,
onDeepLinkConsumed: () -> Unit,
modifier: Modifier = Modifier,
@ -81,8 +57,6 @@ fun TabletLayout(
val dismissedBannerIds by navHostViewModel.dismissedBannerIds.collectAsStateWithLifecycle()
// Persisted sidebar state from DataStore -- single source of truth in the ViewModel.
// The ViewModel's StateFlow initial value is read synchronously from DataStore,
// so the first composition already has the correct persisted state (no flicker).
val isSidebarOpen by navHostViewModel.tabletSidebarOpen.collectAsStateWithLifecycle()
// Whether swipe gesture is enabled (from settings)
@ -92,7 +66,6 @@ fun TabletLayout(
val sidebarWidthPx = with(density) { SidebarWidth.toPx() }
// Animatable tracks sidebar reveal in pixels: 0 = closed, sidebarWidthPx = open.
// During a drag it follows the finger; on release it animates to 0 or sidebarWidthPx.
val sidebarOffset = remember { Animatable(if (isSidebarOpen) sidebarWidthPx else 0f) }
val scope = rememberCoroutineScope()
@ -107,7 +80,7 @@ fun TabletLayout(
if (uri.scheme == "librechat" && uri.host == "conversation") {
uri.lastPathSegment?.let { conversationId ->
if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) {
navController.navigateToChat(conversationId)
navigator.navigateToChat(conversationId)
} else {
Logger.w { "Ignoring deep link with invalid conversation ID: $conversationId" }
}
@ -117,18 +90,13 @@ fun TabletLayout(
}
}
// Handle share intent: if already on a chat screen, let the active ChatViewModel
// consume the shared content reactively. Only navigate to new chat when on a
// non-chat screen (e.g. settings, agents, files).
// Handle share intent
LaunchedEffect(shareNavigationTrigger) {
if (shareNavigationTrigger > 0) {
val currentRoute = navController.currentBackStackEntry?.destination?.route
val isOnChatScreen = currentRoute == CHAT_ROUTE || currentRoute == NEW_CHAT_ROUTE
val currentRoute = navigator.currentRoute
val isOnChatScreen = currentRoute is Chat || currentRoute is NewChat
if (!isOnChatScreen) {
navController.navigate(NEW_CHAT_ROUTE) {
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false }
launchSingleTop = true
}
navigator.navigateToTopLevel(NewChat)
}
}
}
@ -148,7 +116,7 @@ fun TabletLayout(
}
}
if (!isInAuthFlow) {
if (!navigator.isInAuthFlow) {
val swipeModifier = if (gestureEnabled) {
Modifier.pointerInput(Unit) {
val velocityTracker = VelocityTracker()
@ -159,7 +127,6 @@ fun TabletLayout(
onDragEnd = {
val velocity = velocityTracker.calculateVelocity().x
val currentOffset = sidebarOffset.value
// Decide target: fling velocity wins, otherwise use 50% threshold
val shouldOpen = when {
velocity > FLING_VELOCITY_THRESHOLD -> true
velocity < -FLING_VELOCITY_THRESHOLD -> false
@ -177,7 +144,6 @@ fun TabletLayout(
}
},
onDragCancel = {
// Snap back to the current committed state
val target = if (isSidebarOpen) sidebarWidthPx else 0f
scope.launch {
sidebarOffset.animateTo(
@ -214,37 +180,24 @@ fun TabletLayout(
SidebarScaffold(
viewModel = navHostViewModel,
onNewChat = {
// Skip navigation if already on the new chat screen
val currentRoute = navController.currentBackStackEntry?.destination?.route
if (currentRoute != NEW_CHAT_ROUTE) {
navController.navigate(NEW_CHAT_ROUTE) {
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false }
launchSingleTop = true
}
if (navigator.currentRoute !is NewChat) {
navigator.navigateToTopLevel(NewChat)
}
},
onConversationClick = { conversationId ->
navController.navigateToChat(conversationId)
navigator.navigateToChat(conversationId)
},
onSettingsClick = {
navController.navigate(SETTINGS_TABBED_ROUTE) {
launchSingleTop = true
}
navigator.navigate(SettingsTabbed)
},
onSettingsCategorySelected = { category ->
navController.navigate(category.toRoute()) {
launchSingleTop = true
}
navigator.navigate(category.toRoute())
},
onAgentsClick = {
navController.navigate(TopLevelDestination.AGENTS.route) {
launchSingleTop = true
}
navigator.navigate(AgentMarketplace)
},
onFilesClick = {
navController.navigate(TopLevelDestination.FILES.route) {
launchSingleTop = true
}
navigator.navigate(Files)
},
modifier = Modifier
.width(SidebarWidth)
@ -256,9 +209,8 @@ fun TabletLayout(
}
// Slot 1: Main content -- resizes to fill remaining space
MainContent(
navController = navController,
navigator = navigator,
navHostViewModel = navHostViewModel,
startDestination = startDestination,
isInAuthFlow = false,
banners = banners,
dismissedBannerIds = dismissedBannerIds,
@ -272,29 +224,24 @@ fun TabletLayout(
val sidebarPx = SidebarWidth.roundToPx()
val animatedPx = sidebarOffset.value.toInt()
// Sidebar: always measured at full 320dp width
val sidebarPlaceable = measurables[0].measure(
constraints.copy(minWidth = sidebarPx, maxWidth = sidebarPx),
)
// Main content: resizes to fill screen minus the visible sidebar portion
val mainWidth = (constraints.maxWidth - animatedPx).coerceAtLeast(0)
val mainPlaceable = measurables[1].measure(
constraints.copy(minWidth = mainWidth, maxWidth = mainWidth),
)
layout(constraints.maxWidth, constraints.maxHeight) {
// Sidebar slides: -320px (off-screen) -> 0px (fully visible)
sidebarPlaceable.placeRelative(animatedPx - sidebarPx, 0)
// Main content starts right after the visible sidebar portion
mainPlaceable.placeRelative(animatedPx, 0)
}
}
} else {
// Auth flow -- no sidebar, just main content
MainContent(
navController = navController,
navigator = navigator,
navHostViewModel = navHostViewModel,
startDestination = startDestination,
isInAuthFlow = true,
banners = banners,
dismissedBannerIds = dismissedBannerIds,
@ -306,9 +253,8 @@ fun TabletLayout(
@Composable
private fun MainContent(
navController: NavHostController,
navigator: Navigator,
navHostViewModel: NavHostViewModel,
startDestination: String,
isInAuthFlow: Boolean,
banners: List<com.garfiec.librechat.core.model.Banner>,
dismissedBannerIds: Set<String>,
@ -324,119 +270,11 @@ private fun MainContent(
modifier = Modifier.padding(top = 4.dp),
)
}
NavHost(
navController = navController,
startDestination = startDestination,
MainNavDisplay(
navigator = navigator,
navHostViewModel = navHostViewModel,
onOpenDrawer = onToggleDrawer,
modifier = Modifier.fillMaxSize(),
enterTransition = {
slideIntoContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Start,
animationSpec = tween(300),
)
},
exitTransition = {
fadeOut(animationSpec = tween(150))
},
popEnterTransition = {
fadeIn(
initialAlpha = 0.5f,
animationSpec = tween(300, easing = LinearEasing),
) + scaleIn(
initialScale = 0.92f,
animationSpec = tween(300, easing = LinearEasing),
)
},
popExitTransition = {
slideOut(
targetOffset = { IntOffset((it.width * 0.15f).toInt(), 0) },
animationSpec = tween(300, easing = FastOutSlowInEasing),
) + scaleOut(
targetScale = 0.92f,
animationSpec = tween(300, easing = LinearEasing),
)
},
) {
authGraph(
navController = navController,
onAuthComplete = {
navHostViewModel.onAuthComplete()
navController.navigate(CHAT_GRAPH_ROUTE) {
popUpTo(AUTH_GRAPH_ROUTE) { inclusive = true }
}
},
)
chatGraph(
navController = navController,
onOpenDrawer = onToggleDrawer,
)
conversationsGraph(
onConversationClick = { conversationId ->
navController.navigateToChat(conversationId)
},
onNavigateToArchived = {
navController.navigate("conversations/archived")
},
onNavigateBackFromArchived = {
navController.popBackStack()
},
)
agentsGraph(
onAgentClick = { agentId ->
navController.navigate("agents/$agentId")
},
onBack = { navController.popBackStack() },
onStartChat = { agentId ->
navController.navigate(NEW_CHAT_ROUTE) {
popUpTo(CHAT_GRAPH_ROUTE) { inclusive = false }
launchSingleTop = true
}
},
onCreateAgent = {
navController.navigate(AGENT_EDITOR_CREATE_ROUTE)
},
onEditAgent = { agentId ->
navController.navigate("agents/editor/$agentId")
},
)
filesGraph()
settingsGraph(
onLogout = {
navHostViewModel.logout()
navController.navigate(AUTH_GRAPH_ROUTE) {
popUpTo(0) { inclusive = true }
}
},
onNavigateBack = { navController.popBackStack() },
onNavigateToArchived = {
navController.navigate("conversations/archived") {
launchSingleTop = true
}
},
onNavigateToSharedLinks = {
navController.navigate(SHARED_LINKS_ROUTE) {
launchSingleTop = true
}
},
onNavigateBackFromSharedLinks = {
navController.popBackStack()
},
onNavigateToPresets = {
navController.navigate(PRESET_MANAGER_ROUTE) {
launchSingleTop = true
}
},
onNavigateBackFromPresets = {
navController.popBackStack()
},
onNavigateToApiKeys = {
navController.navigate(API_KEYS_ROUTE) {
launchSingleTop = true
}
},
onNavigateBackFromApiKeys = {
navController.popBackStack()
},
)
}
)
}
}

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 (7 total)
## Convention Plugins (13 total)
### Android
| Plugin ID | Class | What it does |
|-----------|-------|-------------|
| `librechat.mobile.application` | `AndroidApplicationConventionPlugin` | AGP application plugin, compileSdk 35, minSdk 26, JDK 17 |
| `librechat.mobile.application` | `AndroidApplicationConventionPlugin` | AGP application plugin, compileSdk 36, minSdk 26, JDK 17 |
| `librechat.mobile.library` | `AndroidLibraryConventionPlugin` | AGP library plugin, same SDK/JDK config |
| `librechat.mobile.compose` | `AndroidComposeConventionPlugin` | Compose compiler, Compose BOM, Material 3 |
| `librechat.mobile.koin` | `AndroidKoinConventionPlugin` | Koin core + Android dependencies |
| `librechat.mobile.feature` | `AndroidFeatureConventionPlugin` | Auto-applies: library + compose + koin + serialization |
| `librechat.mobile.feature` | `AndroidFeatureConventionPlugin` | Auto-applies: library + compose + koin + serialization + Nav 3 |
| `librechat.mobile.room` | `AndroidRoomConventionPlugin` | Room + KSP, schema export config |
| `librechat.mobile.detekt` | `AndroidDetektConventionPlugin` | Detekt static analysis |
### KMP
| Plugin ID | Class | What it does |
|-----------|-------|-------------|
| `librechat.kmp.library` | `KmpLibraryConventionPlugin` | KMP library setup (Android + iOS targets) |
| `librechat.kmp.compose` | `KmpComposeConventionPlugin` | Compose Multiplatform + Material 3 |
| `librechat.kmp.koin` | `KmpKoinConventionPlugin` | Koin multiplatform dependencies |
| `librechat.kmp.feature` | `KmpFeatureConventionPlugin` | Auto-applies: kmp library + compose + koin + serialization + Nav 3 |
| `librechat.kmp.room` | `KmpRoomConventionPlugin` | Room multiplatform + KSP |
### Shared
| Plugin ID | Class | What it does |
|-----------|-------|-------------|
| `librechat.kotlin.serialization` | `KotlinSerializationConventionPlugin` | Kotlinx Serialization plugin + JSON dependency |
## Critical: apply() Signature
@ -32,6 +47,6 @@ build.gradle.kts files. Use `libs.` accessors (e.g., `libs.koin.android`, `libs.
## Feature Module Convention
Feature modules use `id("librechat.mobile.feature")` which auto-applies library, compose,
koin, and serialization. They only need to add feature-specific dependencies.
Feature modules use `id("librechat.kmp.feature")` (KMP) or `id("librechat.mobile.feature")` (Android-only) which auto-applies library, compose,
koin, serialization, and Nav 3. They only need to add feature-specific dependencies.
Feature modules depend on `:core:*` only, never on other feature modules.

View file

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

View file

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

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

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
## Navigation
- `AGENTS_ROUTE` ("agents") -> marketplace grid
- `AGENT_DETAIL_ROUTE` ("agents/{agentId}") -> detail view
- Sealed interface: `AgentsRoute : NavKey` with typed route classes
- Routes: `AgentMarketplace` (grid), `AgentDetail(agentId: String)` (detail), `AgentEditorCreate`, `AgentEditorEdit(agentId: String)` (all `@Serializable`)
- Feature entries registered via `EntryProviderScope<NavKey>.agentsEntries()`
- `onStartChat(agentId)` callback navigates to chat with the selected agent
## Marketplace ViewModel

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,10 +7,11 @@
- **Active**: message list + streaming content + input area
## Navigation
- `CHAT_GRAPH_ROUTE` with `NEW_CHAT_ROUTE` (landing) and `CHAT_ROUTE` ("chat/{conversationId}")
- `PROMPTS_LIBRARY_ROUTE` is co-located here for prompts library screen
- When a new conversation starts from the landing page, `pendingNavigationConversationId` triggers navigation to `chat/{id}` after the first stream completes (data persisted to Room), keeping `new_chat` in the back stack
- `navigateToChat()` replaces the current chat entry when switching between chats so back returns to `new_chat`
- Sealed interface: `ChatRoute : NavKey` with typed route classes
- Routes: `NewChat` (landing), `Chat(conversationId: String? = null)`, `PromptsLibrary`, `PromptEditor(groupId: String? = null)` (all `@Serializable`)
- Feature entries registered via `EntryProviderScope<NavKey>.chatEntries()`
- When a new conversation starts from the landing page, `pendingNavigationConversationId` triggers navigation to `Chat(id)` after the first stream completes (data persisted to Room), keeping `NewChat` in the back stack
- `navigateToChat()` replaces the current chat entry on the `NavBackStack` when switching between chats so back returns to `NewChat`
## Message Tree
- Messages stored flat, linked by `parentMessageId` (root = `NO_PARENT` UUID)

View file

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

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

View file

@ -1,117 +1,69 @@
package com.garfiec.librechat.feature.chat.navigation
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import androidx.navigation.navigation
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import com.garfiec.librechat.feature.chat.prompts.PromptEditorScreen
import com.garfiec.librechat.feature.chat.prompts.PromptsLibraryScreen
import com.garfiec.librechat.feature.chat.screen.ChatScreen
import com.garfiec.librechat.feature.chat.screen.NewChatScreen
import kotlinx.serialization.Serializable
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import kotlinx.serialization.modules.subclass
const val CHAT_GRAPH_ROUTE = "chat_graph"
const val NEW_CHAT_ROUTE = "new_chat"
const val CHAT_ROUTE = "chat/{conversationId}"
const val PROMPTS_LIBRARY_ROUTE = "prompts_library"
const val PROMPT_EDITOR_ROUTE = "prompt_editor?groupId={groupId}"
@Serializable sealed interface ChatRoute : NavKey
fun NavController.navigateToChat(conversationId: String) {
val currentEntry = currentBackStackEntry
val isCurrentlyInChat = currentEntry?.destination?.route == CHAT_ROUTE
val currentDestId = currentEntry?.destination?.id
navigate("chat/$conversationId") {
if (isCurrentlyInChat && currentDestId != null) {
// Already viewing a chat -- replace it so switching chats
// doesn't stack entries. Back will go to whatever was
// before the first chat (e.g. new_chat, settings, agents).
popUpTo(currentDestId) { inclusive = true }
}
// From non-chat screens just push onto the backstack normally
launchSingleTop = true
}
}
@Serializable data object NewChat : ChatRoute
@Serializable data class Chat(val conversationId: String? = null) : ChatRoute
@Serializable data object PromptsLibrary : ChatRoute
@Serializable data class PromptEditor(val groupId: String? = null) : ChatRoute
fun NavController.navigateToPromptsLibrary() {
navigate(PROMPTS_LIBRARY_ROUTE)
}
fun NavController.navigateToPromptEditor(groupId: String? = null) {
if (groupId != null) {
navigate("prompt_editor?groupId=$groupId")
} else {
navigate("prompt_editor")
}
}
fun NavGraphBuilder.chatGraph(
navController: NavController,
fun EntryProviderScope<NavKey>.chatEntries(
onNavigate: (NavKey) -> Unit,
onBack: () -> Unit,
onNavigateToChat: (String) -> Unit,
onOpenDrawer: (() -> Unit)? = null,
) {
navigation(startDestination = NEW_CHAT_ROUTE, route = CHAT_GRAPH_ROUTE) {
composable(
route = NEW_CHAT_ROUTE,
enterTransition = { EnterTransition.None },
exitTransition = { ExitTransition.None },
popEnterTransition = { null },
popExitTransition = { null },
) {
NewChatScreen(
onConversationStarted = { conversationId ->
navController.navigateToChat(conversationId)
},
onOpenDrawer = onOpenDrawer,
onNavigateToPromptsLibrary = { navController.navigateToPromptsLibrary() },
)
}
composable(
route = CHAT_ROUTE,
arguments = listOf(
navArgument("conversationId") {
type = NavType.StringType
nullable = true
defaultValue = null
},
),
enterTransition = { EnterTransition.None },
exitTransition = { ExitTransition.None },
popEnterTransition = { null },
popExitTransition = { null },
) { _ ->
ChatScreen(
onOpenDrawer = onOpenDrawer,
onNavigateToPromptsLibrary = { navController.navigateToPromptsLibrary() },
onNavigateBack = { navController.popBackStack() },
onNavigateToConversation = { conversationId -> navController.navigateToChat(conversationId) },
)
}
composable(PROMPTS_LIBRARY_ROUTE) {
PromptsLibraryScreen(
onNavigateBack = { navController.popBackStack() },
onUseInChat = { promptText ->
navController.popBackStack()
},
onNavigateToEditor = { groupId ->
navController.navigateToPromptEditor(groupId)
},
)
}
composable(
route = PROMPT_EDITOR_ROUTE,
arguments = listOf(
navArgument("groupId") {
type = NavType.StringType
nullable = true
defaultValue = null
},
),
) {
PromptEditorScreen(
onBack = { navController.popBackStack() },
)
}
entry<NewChat> {
NewChatScreen(
onConversationStarted = { conversationId ->
onNavigateToChat(conversationId)
},
onOpenDrawer = onOpenDrawer,
onNavigateToPromptsLibrary = { onNavigate(PromptsLibrary) },
)
}
entry<Chat> { key ->
ChatScreen(
conversationId = key.conversationId,
onOpenDrawer = onOpenDrawer,
onNavigateToPromptsLibrary = { onNavigate(PromptsLibrary) },
onNavigateBack = onBack,
onNavigateToConversation = { conversationId -> onNavigateToChat(conversationId) },
)
}
entry<PromptsLibrary> {
PromptsLibraryScreen(
onNavigateBack = onBack,
onUseInChat = { _ -> onBack() },
onNavigateToEditor = { groupId ->
onNavigate(PromptEditor(groupId = groupId))
},
)
}
entry<PromptEditor> { key ->
PromptEditorScreen(
onBack = onBack,
groupId = key.groupId,
)
}
}
val chatSerializersModule = SerializersModule {
polymorphic(NavKey::class) {
subclass(NewChat::class, NewChat.serializer())
subclass(Chat::class, Chat.serializer())
subclass(PromptsLibrary::class, PromptsLibrary.serializer())
subclass(PromptEditor::class, PromptEditor.serializer())
}
}

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

View file

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

View file

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

View file

@ -125,7 +125,7 @@ data class ChatUiState(
val showDeleteConfirmation: Boolean = false,
val duplicatedConversationId: String? = null,
/** Set at StreamEvent.Created when a new conversation's conversationId becomes available.
* The UI navigates to chat/{id} and then clears this via [ChatViewModel.onPendingNavigationHandled],
* The UI navigates to Chat(id) and then clears this via [ChatViewModel.onPendingNavigationHandled],
* which also resets this ViewModel to a clean landing state. */
val pendingNavigationConversationId: String? = null,
// Model comparison state

View file

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

View file

@ -16,9 +16,10 @@ actual val chatPlatformModule: Module = module {
)
}
viewModel {
viewModel { params ->
ChatViewModel(
savedStateHandle = get(),
initialConversationId = params.getOrNull(),
agentRepository = get(),
chatRepository = 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 org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@OptIn(ExperimentalMaterial3Api::class)
@Composable
actual fun ChatScreen(
modifier: Modifier,
conversationId: String?,
onConversationStarted: ((String) -> Unit)?,
onNavigateToConversation: ((String) -> Unit)?,
onOpenDrawer: (() -> Unit)?,
onNavigateToPromptsLibrary: (() -> Unit)?,
onNavigateBack: (() -> Unit)?,
) {
val viewModel: ChatViewModel = koinViewModel()
val viewModel: ChatViewModel = koinViewModel { parametersOf(conversationId) }
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val attachedFiles by viewModel.attachedFiles.collectAsStateWithLifecycle()
val prefs by viewModel.chatPreferences.collectAsStateWithLifecycle()

View file

@ -1,7 +1,7 @@
# feature:conversations
## Screen
`ConversationListScreen` -- the drawer content showing all conversations. Single route: `CONVERSATIONS_ROUTE`.
`ConversationListScreen` -- the drawer content showing all conversations. Sealed interface: `ConversationsRoute : NavKey` with routes `Conversations` and `ArchivedConversations` (both `@Serializable` data objects).
## Pagination
- Cursor-based via `ConversationRepository.loadNextPage(cursor, tags)`

View file

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

View file

@ -1,7 +1,7 @@
# feature:files
## Screen
`FilesScreen` -- single route `FILES_ROUTE`. Lists uploaded files with upload and delete actions.
`FilesScreen` -- sealed interface `FilesRoute : NavKey` with single route `Files` (`@Serializable` data object). Lists uploaded files with upload and delete actions.
## ViewModel
`FilesViewModel` depends on `FileRepository` and application `Context` (for content resolver).

View file

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

View file

@ -1,7 +1,7 @@
# feature:settings
## Screen
`SettingsScreen` -- single route `SETTINGS_ROUTE`. Receives `onLogout` and `onNavigateBack` callbacks.
`SettingsScreen` -- sealed interface `SettingsRoute : NavKey` with primary route `SettingsTabbed` (`@Serializable` data object). Receives `onLogout` and `onNavigateBack` callbacks.
## Sections
- **Account**: displays user profile from `UserApi.getUser()`
@ -46,14 +46,14 @@
- `McpServersScreen` — server list with status badges, CRUD, reinitialize, tools sheet (`McpViewModel`)
- `PresetManagerScreen` — list/delete presets (`PresetManagerViewModel`)
- `CommandsConfigScreen` — enable/disable slash commands
- Routes: `MEMORIES_ROUTE`, `MCP_SERVERS_ROUTE`, `PRESET_MANAGER_ROUTE`, `COMMANDS_CONFIG_ROUTE`
- Routes: `Memories`, `McpServers`, `PresetManager` (all `@Serializable` data objects extending `SettingsRoute`)
- **Gotcha**: Memories and MCP navigation routes defined in their own files (`MemoriesNavigation.kt`, `McpNavigation.kt`) but wired through `SettingsNavigation.kt`
- **Gotcha**: `McpServerDialog` uses `McpServerType` enum (SSE, STREAMABLE_HTTP) — must match backend expectations
- **Gotcha**: Memory keys are immutable after creation (edit dialog disables key field)
### API Keys
- `ApiKeysScreen` — list/create/delete API keys, own ViewModel (`ApiKeysViewModel`)
- Route: `API_KEYS_ROUTE` ("settings/api_keys"), accessible from Settings → Security section
- Route: `ApiKeys` (`@Serializable` data object extending `SettingsRoute`), accessible from Settings → Security section
- `ApiKeyCreateDialog` is two-phase: name input → show created key value with copy button
- **Gotcha**: The key value is only available in the creation response — it cannot be retrieved again from the server. The dialog must show it immediately after creation.

View file

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

View file

@ -1,17 +1,18 @@
package com.garfiec.librechat.feature.settings.navigation
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import com.garfiec.librechat.feature.settings.screen.MemoriesScreen
import kotlinx.serialization.Serializable
const val MEMORIES_ROUTE = "settings/memories"
@Serializable data object Memories : SettingsRoute
fun NavGraphBuilder.memoriesScreen(
onNavigateBack: () -> Unit,
fun EntryProviderScope<NavKey>.memoriesEntry(
onBack: () -> Unit,
) {
composable(MEMORIES_ROUTE) {
entry<Memories> {
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.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import com.garfiec.librechat.feature.settings.screen.AccountSettingsScreen
import com.garfiec.librechat.feature.settings.screen.ApiKeysScreen
import com.garfiec.librechat.feature.settings.screen.ChatSettingsScreen
@ -13,65 +13,65 @@ import com.garfiec.librechat.feature.settings.screen.PresetManagerScreen
import com.garfiec.librechat.feature.settings.screen.SharedLinksScreen
import com.garfiec.librechat.feature.settings.screen.TabbedSettingsScreen
import com.garfiec.librechat.feature.settings.viewmodel.SettingsViewModel
import kotlinx.serialization.Serializable
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import kotlinx.serialization.modules.subclass
import org.koin.compose.viewmodel.koinViewModel
const val SETTINGS_TABBED_ROUTE = "settings"
const val SETTINGS_GENERAL_ROUTE = "settings/general"
const val SETTINGS_CHAT_ROUTE = "settings/chat"
const val SETTINGS_ACCOUNT_ROUTE = "settings/account"
const val SETTINGS_DATA_ROUTE = "settings/data"
const val SHARED_LINKS_ROUTE = "settings/shared-links"
const val PRESET_MANAGER_ROUTE = "settings/presets"
const val API_KEYS_ROUTE = "settings/api_keys"
@Serializable sealed interface SettingsRoute : NavKey
fun NavGraphBuilder.settingsGraph(
@Serializable data object SettingsTabbed : SettingsRoute
@Serializable data object SettingsGeneral : SettingsRoute
@Serializable data object SettingsChat : SettingsRoute
@Serializable data object SettingsAccount : SettingsRoute
@Serializable data object SettingsData : SettingsRoute
@Serializable data object SharedLinks : SettingsRoute
@Serializable data object PresetManager : SettingsRoute
@Serializable data object ApiKeys : SettingsRoute
fun EntryProviderScope<NavKey>.settingsEntries(
onNavigate: (NavKey) -> Unit,
onBack: () -> Unit,
onLogout: () -> Unit,
onNavigateBack: () -> Unit,
onNavigateToArchived: () -> Unit = {},
onNavigateToSharedLinks: () -> Unit = {},
onNavigateBackFromSharedLinks: () -> Unit = {},
onNavigateToPresets: () -> Unit = {},
onNavigateBackFromPresets: () -> Unit = {},
onNavigateToApiKeys: () -> Unit = {},
onNavigateBackFromApiKeys: () -> Unit = {},
) {
composable(SETTINGS_TABBED_ROUTE) {
entry<SettingsTabbed> {
TabbedSettingsScreen(
onNavigateBack = onNavigateBack,
onNavigateBack = onBack,
onLogout = onLogout,
onNavigateToArchived = onNavigateToArchived,
onNavigateToSharedLinks = onNavigateToSharedLinks,
onNavigateToPresets = onNavigateToPresets,
onNavigateToApiKeys = onNavigateToApiKeys,
onNavigateToSharedLinks = { onNavigate(SharedLinks) },
onNavigateToPresets = { onNavigate(PresetManager) },
onNavigateToApiKeys = { onNavigate(ApiKeys) },
)
}
composable(SETTINGS_GENERAL_ROUTE) {
entry<SettingsGeneral> {
GeneralSettingsScreen(
onNavigateBack = onNavigateBack,
onNavigateBack = onBack,
)
}
composable(SETTINGS_CHAT_ROUTE) {
entry<SettingsChat> {
ChatSettingsScreen(
onNavigateBack = onNavigateBack,
onNavigateToPresets = onNavigateToPresets,
onNavigateBack = onBack,
onNavigateToPresets = { onNavigate(PresetManager) },
)
}
composable(SETTINGS_ACCOUNT_ROUTE) {
entry<SettingsAccount> {
AccountSettingsScreen(
onLogout = onLogout,
onNavigateBack = onNavigateBack,
onNavigateToApiKeys = onNavigateToApiKeys,
onNavigateBack = onBack,
onNavigateToApiKeys = { onNavigate(ApiKeys) },
)
}
composable(SETTINGS_DATA_ROUTE) {
entry<SettingsData> {
DataSettingsScreen(
onNavigateBack = onNavigateBack,
onNavigateBack = onBack,
onNavigateToArchived = onNavigateToArchived,
onNavigateToSharedLinks = onNavigateToSharedLinks,
onNavigateToSharedLinks = { onNavigate(SharedLinks) },
)
}
composable(SHARED_LINKS_ROUTE) {
entry<SharedLinks> {
val viewModel: SettingsViewModel = koinViewModel()
val uiState = viewModel.uiState.collectAsStateWithLifecycle()
@ -87,17 +87,32 @@ fun NavGraphBuilder.settingsGraph(
onLoadMore = viewModel::loadMoreSharedLinks,
onToggleVisibility = viewModel::toggleSharedLinkVisibility,
onDelete = viewModel::deleteSharedLink,
onNavigateBack = onNavigateBackFromSharedLinks,
onNavigateBack = onBack,
)
}
composable(PRESET_MANAGER_ROUTE) {
entry<PresetManager> {
PresetManagerScreen(
onNavigateBack = onNavigateBackFromPresets,
onNavigateBack = onBack,
)
}
composable(API_KEYS_ROUTE) {
entry<ApiKeys> {
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"
material3 = "1.3.1"
activity-compose = "1.10.1"
navigation-compose = "2.9.2"
lifecycle = "2.9.0"
lifecycle-kmp = "2.9.0"
navigation-kmp = "2.9.2"
navigation3-kmp = "1.0.0-alpha06"
lifecycle-viewmodel-nav3-kmp = "2.10.0-alpha05"
# DI
koin = "4.2.0"
@ -82,14 +82,13 @@ compose-foundation = { group = "androidx.compose.foundation", name = "foundation
# Activity / Navigation
activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" }
navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation-compose" }
# Lifecycle
lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
lifecycle-runtime-compose-kmp = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle-kmp" }
lifecycle-viewmodel-compose-kmp = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle-kmp" }
navigation-compose-kmp = { group = "org.jetbrains.androidx.navigation", name = "navigation-compose", version.ref = "navigation-kmp" }
navigation3-ui-kmp = { group = "org.jetbrains.androidx.navigation3", name = "navigation3-ui", version.ref = "navigation3-kmp" }
lifecycle-viewmodel-navigation3-kmp = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel-navigation3", version.ref = "lifecycle-viewmodel-nav3-kmp" }
# Koin
koin-bom = { group = "io.insert-koin", name = "koin-bom", version.ref = "koin" }

View file

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

View file

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

View file

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

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