refactor: migrate from Navigation Compose 2 to Navigation Compose 3

This commit is contained in:
Garfie 2026-04-03 20:44:38 -05:00
parent 2756efa812
commit 40ee348b12
62 changed files with 1747 additions and 1127 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

841
NAV3_RESEARCH.md Normal file
View file

@ -0,0 +1,841 @@
# Navigation Compose 3: Research Report
> Compiled 2026-04-03. Sources: Android Developer docs, JetBrains Kotlin docs, nav3-recipes repo, Android Developers Blog, community blog posts, and release notes.
---
## Table of Contents
1. [Overview](#1-overview)
2. [Core Concepts & API](#2-core-concepts--api)
3. [Best Practices](#3-best-practices)
4. [Migration Guide (Nav 2 → Nav 3)](#4-migration-guide-nav-2--nav-3)
5. [Gotchas, Footguns & Complications](#5-gotchas-footguns--complications)
6. [KMP / Compose Multiplatform Considerations](#6-kmp--compose-multiplatform-considerations)
7. [Appendix: Version & Dependency Reference](#7-appendix-version--dependency-reference)
---
## 1. Overview
### What is Navigation 3?
Navigation 3 (Nav3) is a **ground-up rewrite** of Android's Jetpack Navigation, designed specifically for Jetpack Compose. Released as stable **1.0.0 on November 19, 2025**, it replaces the controller-based paradigm of Nav 2 with a **state-based, declarative approach** where the developer owns the back stack directly.
- **Stable release**: 1.0.1 (Feb 11, 2026)
- **Latest**: 1.1.0-rc01 (Mar 25, 2026)
- **Min SDK**: 23
- **Compile SDK**: 36+
### How It Differs from Nav 2
| Aspect | Nav 2 | Nav 3 |
|--------|-------|-------|
| **State ownership** | Library-owned (`NavController`) | Developer-owned (`SnapshotStateList`) |
| **Back stack model** | Hidden internal structure | Transparent mutable list |
| **Navigation API** | `navController.navigate()` | `backStack.add(key)` |
| **Host component** | `NavHost` | `NavDisplay` |
| **Route definition** | `composable<T> { }` in `NavHost` | `entry<T> { }` in `entryProvider` |
| **Layout flexibility** | Single pane only | Multi-pane via Scenes API |
| **Nested graphs** | `navigation<T> { }` | Not needed — flat entry list |
| **Design philosophy** | Opinionated framework | Flexible building blocks |
| **Compose alignment** | Bolted-on Compose support | Compose-first from scratch |
### Founding Principles
1. **You Own the Back Stack** — Navigation state is a `SnapshotStateList<T>` you control directly. Push = `add()`, pop = `removeLastOrNull()`.
2. **Get Out of Your Way** — The library provides open, extensible building blocks rather than a black box.
3. **Pick Your Building Blocks** — Composable components you combine for your specific needs, with recipes for complex scenarios.
### Data Flow
```
User Action → modify backStack (add/remove) → NavDisplay observes change → entryProvider resolves key → NavEntry.content rendered
```
This follows **Unidirectional Data Flow (UDF)**, fully aligned with Compose's declarative model.
---
## 2. Core Concepts & API
### 2.1 Keys (Routes)
Routes in Nav 3 are simple Kotlin data types that implement `NavKey`:
```kotlin
@Serializable
data object Home : NavKey
@Serializable
data class ProductDetail(val id: String) : NavKey
@Serializable
data object Settings : NavKey
```
Keys must be:
- `@Serializable` (for state persistence)
- Implement `NavKey` (marker interface for `rememberNavBackStack`)
### 2.2 Back Stack
The back stack is a `SnapshotStateList<T>` — you own it completely:
```kotlin
// Simple (no persistence across process death)
val backStack = remember { mutableStateListOf<Any>(Home) }
// Persistent (survives config changes + process death)
val backStack = rememberNavBackStack(Home)
```
Navigation is just list manipulation:
```kotlin
// Navigate forward
backStack.add(ProductDetail(id = "123"))
// Navigate back
backStack.removeLastOrNull()
```
### 2.3 NavEntry
`NavEntry` wraps a composable function with its key and optional metadata:
```kotlin
NavEntry(
key = ProductDetail("123"),
metadata = mapOf("type" to "detail")
) {
ProductDetailScreen()
}
```
### 2.4 Entry Provider
Resolves keys to `NavEntry` objects. Two approaches:
**Lambda function:**
```kotlin
entryProvider = { key ->
when (key) {
is Home -> NavEntry(key) { HomeScreen() }
is ProductDetail -> NavEntry(key) { ProductDetailScreen(key.id) }
else -> NavEntry(Unit) { Text("Unknown") }
}
}
```
**DSL (recommended):**
```kotlin
entryProvider = entryProvider {
entry<Home> { HomeScreen() }
entry<ProductDetail> { key -> ProductDetailScreen(key.id) }
}
```
### 2.5 NavDisplay
The primary UI component that observes the back stack and renders content:
```kotlin
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
sceneStrategies = listOf(DialogSceneStrategy()),
entryProvider = entryProvider {
entry<Home> { HomeScreen() }
entry<ProductDetail> { key -> ProductDetailScreen(key.id) }
}
)
```
### 2.6 NavEntry Decorators
Decorators wrap each `NavEntry` with additional logic (state holders, ViewModel stores, logging):
| Decorator | Purpose |
|-----------|---------|
| `rememberSaveableStateHolderNavEntryDecorator()` | Persists `rememberSaveable` state across config changes and process death. **Should be first in the list.** |
| `rememberViewModelStoreNavEntryDecorator()` | Scopes `ViewModel`s to individual `NavEntry` lifecycle. Requires `androidx.lifecycle:lifecycle-viewmodel-navigation3` dependency. |
| `rememberSharedViewModelStoreNavEntryDecorator()` | Enables ViewModel sharing between parent/child entries. |
Custom decorator example:
```kotlin
class LoggingDecorator<T : Any> : NavEntryDecorator<T>(
decorate = { entry ->
Log.d("Nav", "Entered ${entry.contentKey}")
entry.Content()
},
onPop = { key -> Log.d("Nav", "Popped $key") }
)
```
### 2.7 Scenes & SceneStrategy
**Scenes** render one or more `NavEntry` instances. **SceneStrategies** determine which Scene to use based on the current entries.
**Built-in strategies:**
- `SinglePaneSceneStrategy` — Default, shows topmost entry
- `DialogSceneStrategy` — Renders entries with dialog metadata as overlays
- `ListDetailSceneStrategy` (from `material3-adaptive-navigation3`) — Adaptive list-detail layout
**Custom strategy example (list-detail):**
```kotlin
class ListDetailSceneStrategy<T : Any>(
val windowSizeClass: WindowSizeClass
) : SceneStrategy<T> {
override fun SceneStrategyScope<T>.calculateScene(
entries: List<NavEntry<T>>
): Scene<T>? {
if (!windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND))
return null
val detailEntry = entries.lastOrNull()?.takeIf { it.metadata.contains(DetailKey) } ?: return null
val listEntry = entries.findLast { it.metadata.contains(ListKey) } ?: return null
return ListDetailScene(
key = listEntry.contentKey,
previousEntries = entries.dropLast(1),
listEntry = listEntry,
detailEntry = detailEntry
)
}
}
```
### 2.8 Metadata
Arbitrary `Map<String, Any>` attached to `NavEntry` or `Scene` for communication between components:
```kotlin
// Type-safe metadata DSL (1.1.0-beta01+)
entry<Home>(
metadata = metadata {
put(NavDisplay.TransitionKey) { fadeIn() togetherWith fadeOut() }
put(ListDetailSceneStrategy.ListKey, true)
}
) { HomeScreen() }
```
### 2.9 Animations
NavDisplay supports built-in transition animations:
```kotlin
NavDisplay(
backStack = backStack,
// Global defaults
transitionSpec = {
slideInHorizontally(initialOffsetX = { it }) togetherWith
slideOutHorizontally(targetOffsetX = { -it })
},
popTransitionSpec = {
slideInHorizontally(initialOffsetX = { -it }) togetherWith
slideOutHorizontally(targetOffsetX = { it })
},
predictivePopTransitionSpec = {
slideInHorizontally(initialOffsetX = { -it }) togetherWith
slideOutHorizontally(targetOffsetX = { it })
},
entryProvider = entryProvider {
// Per-entry override via metadata
entry<ScreenC>(
metadata = metadata {
put(NavDisplay.TransitionKey) {
slideInVertically(initialOffsetY = { it }) togetherWith
ExitTransition.KeepUntilTransitionsFinished
}
}
) { ScreenC() }
}
)
```
Shared element transitions:
```kotlin
SharedTransitionLayout {
NavDisplay(
sharedTransitionScope = this,
// ...
)
}
```
### 2.10 Deep Linking
Nav 3 does **not** have built-in deep link support like Nav 2. Instead, you parse intents manually:
1. Define `DeepLinkPattern` objects mapping URIs to `NavKey` classes
2. Parse incoming `Intent.data` into a `DeepLinkRequest`
3. Match against patterns to produce a `NavKey`
4. Build a synthetic back stack for proper Up navigation
```kotlin
override fun onCreate(savedInstanceState: Bundle?) {
val uri: Uri? = intent.data
val key: NavKey = uri?.let { parseDeepLink(it) } ?: HomeKey
setContent {
val backStack = rememberNavBackStack(key)
NavDisplay(backStack = backStack, ...)
}
}
```
Recipes available: [Basic deep links](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/deeplinks), [Advanced deep links](https://github.com/android/nav3-recipes/tree/main/advanceddeeplinkapp).
---
## 3. Best Practices
### 3.1 Architecture Pattern
**Recommended: Navigator + NavigationState pattern** (from official migration guide):
```kotlin
// NavigationState — holds all navigation state
class NavigationState(
val startRoute: NavKey,
topLevelRoute: MutableState<NavKey>,
val backStacks: Map<NavKey, NavBackStack<NavKey>>
) {
var topLevelRoute: NavKey by topLevelRoute
}
// Navigator — handles navigation events
class Navigator(val state: NavigationState) {
fun navigate(route: NavKey) {
if (route in state.backStacks.keys) {
state.topLevelRoute = route
} else {
state.backStacks[state.topLevelRoute]?.add(route)
}
}
fun goBack() { /* ... */ }
}
```
### 3.2 Multi-Module Navigation
Nav 3 is designed for modular navigation. Each feature module exposes:
- **`api` module**: Navigation keys (routes)
- **`impl` module**: Entry builders (content)
```kotlin
// feature/chat/impl
fun EntryProviderScope<NavKey>.chatEntryBuilder() {
entry<ChatRoute> { key -> ChatScreen(key.conversationId) }
entry<ChatSettings> { ChatSettingsScreen() }
}
// app module
NavDisplay(
entryProvider = entryProvider {
chatEntryBuilder()
authEntryBuilder()
settingsEntryBuilder()
}
)
```
**With Koin DI** (from nav3-recipes):
```kotlin
// Each feature module provides its entry builder
val chatNavigationModule = module {
factory<EntryProviderScope<NavKey>.() -> Unit>(named("chat")) {
{ chatEntryBuilder(get()) }
}
}
// App module collects all builders
val entryBuilders: List<EntryProviderScope<NavKey>.() -> Unit> = getAll()
NavDisplay(
entryProvider = entryProvider {
entryBuilders.forEach { builder -> this.builder() }
}
)
```
### 3.3 ViewModel Scoping
ViewModels are scoped to `NavEntry` lifecycle via decorators:
```kotlin
NavDisplay(
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(), // Always first
rememberViewModelStoreNavEntryDecorator()
),
// ...
)
```
With this setup:
- `viewModel()` calls inside NavEntry content are scoped to that entry
- ViewModels survive config changes
- ViewModels are cleared when the entry is popped
**With Koin:**
```kotlin
entry<ChatRoute> { key ->
val viewModel = koinViewModel<ChatViewModel>()
ChatScreen(viewModel)
}
```
### 3.4 Multiple Back Stacks (Bottom Navigation)
For apps with bottom navigation / top-level routes:
```kotlin
val topLevelRoutes = setOf(Home, Search, Profile)
// Each top-level route gets its own persistent back stack
val backStacks = topLevelRoutes.associateWith { rememberNavBackStack(it) }
// Switch between stacks (state is retained)
var currentTopLevel by remember { mutableStateOf(Home) }
```
Recipe: [Multiple back stacks](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/multiplestacks)
### 3.5 Adaptive / Multi-Pane Layouts
Use Material 3 Adaptive for responsive list-detail:
```kotlin
val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>()
NavDisplay(
backStack = backStack,
sceneStrategies = listOf(listDetailStrategy),
entryProvider = entryProvider {
entry<ConversationList>(
metadata = ListDetailSceneStrategy.listPane(
detailPlaceholder = { Text("Select a conversation") }
)
) { ConversationListScreen() }
entry<ConversationDetail>(
metadata = ListDetailSceneStrategy.detailPane()
) { key -> ConversationDetailScreen(key.id) }
}
)
```
### 3.6 Testing Navigation
**Nav 3 has no dedicated testing library like Nav 2's `navigation-testing`.** However, since navigation state is just a list, testing is simpler:
```kotlin
@Test
fun testNavigation() {
val backStack = mutableStateListOf<NavKey>(Home)
// Simulate navigation
backStack.add(ProductDetail("123"))
assertEquals(ProductDetail("123"), backStack.last())
// Simulate back
backStack.removeLastOrNull()
assertEquals(Home, backStack.last())
}
```
For UI testing, you can use standard Compose testing:
```kotlin
@Test
fun testNavigationUI() {
composeTestRule.setContent {
val backStack = remember { mutableStateListOf<NavKey>(Home) }
NavDisplay(
backStack = backStack,
entryProvider = entryProvider { /* ... */ }
)
}
// Assert on rendered content
}
```
The `SceneStrategyScope` has a no-arg public constructor specifically for testing (added in 1.0.0-beta01).
---
## 4. Migration Guide (Nav 2 → Nav 3)
### 4.1 Prerequisites
- `compileSdk` 36+
- `minSdk` 23+
- All destinations must be composable functions
- Routes must implement `NavKey`
### 4.2 Can Nav 2 and Nav 3 Coexist?
**Yes.** The official migration guide mentions **atomic migration** as the recommended approach, but community sources confirm **incremental migration** is possible:
- Nav 2 and Nav 3 can operate simultaneously during migration
- Features can be migrated one at a time
- Business logic and ViewModels don't need rewriting
However, the official migration guide assumes atomic migration. For incremental, you'd maintain a Nav 2 `NavHost` for unmigrated features and a Nav 3 `NavDisplay` for migrated ones, coordinating between them.
### 4.3 Step-by-Step Migration
#### Step 1: Add Dependencies
```toml
[versions]
nav3Core = "1.0.1"
lifecycleViewmodelNav3 = "2.11.0-alpha03"
[libraries]
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" }
androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" }
```
#### Step 2: Update Routes to Implement NavKey
```kotlin
// Before
@Serializable data object Home
@Serializable data class Chat(val conversationId: String)
// After
@Serializable data object Home : NavKey
@Serializable data class Chat(val conversationId: String) : NavKey
```
#### Step 3: Create NavigationState & Navigator
See [Section 3.1](#31-architecture-pattern) for the full pattern.
#### Step 4: Replace NavController Calls
| Nav 2 | Nav 3 |
|-------|-------|
| `navController.navigate(route)` | `navigator.navigate(route)` or `backStack.add(route)` |
| `navController.popBackStack()` | `navigator.goBack()` or `backStack.removeLastOrNull()` |
| `navController.currentDestination` | `backStack.last()` |
| `currentBackStackEntryAsState()` | Direct state observation of `backStack` |
| `destination.isRouteInHierarchy()` | `key == navigationState.topLevelRoute` |
#### Step 5: Convert Destinations
| Nav 2 | Nav 3 |
|-------|-------|
| `composable<T> { }` | `entry<T> { }` |
| `dialog<T> { }` | `entry<T>(metadata = DialogSceneStrategy.dialog()) { }` |
| `navigation<T>(startDestination = ...) { }` | **Delete** — replace references with first child route |
| `NavGraphBuilder` extension | `EntryProviderScope<NavKey>` extension |
| `entry.toRoute<T>()` | `key` parameter: `entry<T> { key -> ... }` |
**Before:**
```kotlin
NavHost(navController = navController, startDestination = Home) {
composable<Home> { HomeScreen() }
composable<Chat> { entry ->
val route = entry.toRoute<Chat>()
ChatScreen(route.conversationId)
}
dialog<ConfirmDialog> { ConfirmDialogScreen() }
}
```
**After:**
```kotlin
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
sceneStrategies = listOf(DialogSceneStrategy()),
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
entry<Home> { HomeScreen() }
entry<Chat> { key -> ChatScreen(key.conversationId) }
entry<ConfirmDialog>(
metadata = DialogSceneStrategy.dialog()
) { ConfirmDialogScreen() }
}
)
```
#### Step 6: Remove Nav 2 Dependencies
Remove all Nav 2 imports, `NavController`, `NavHost`, `NavGraphBuilder`, and gradle dependencies.
### 4.4 Key Differences to Watch
1. **No NavController** — State is a mutable list, not a controller object
2. **No nested graphs** — Delete `navigation<T>` blocks entirely
3. **No built-in deep links** — Must implement manually (see recipes)
4. **Route args via key param**`entry<T> { key -> ... }` instead of `toRoute<T>()`
5. **Dialogs via metadata**`DialogSceneStrategy.dialog()` metadata instead of `dialog<T>`
6. **Use `rememberSerializable`, not `rememberSaveable`** — For top-level route persistence
7. **Decorators are explicit** — Must add state/ViewModel decorators manually
---
## 5. Gotchas, Footguns & Complications
### 5.1 Known Issues (from release notes)
| Issue | Status | Versions |
|-------|--------|----------|
| z-order bug during animation interruptions ([b/459419800](https://issuetracker.google.com/issues/459419800)) | Fixed in 1.1.0-rc01 | |
| Lifecycle not capped at STARTED for entries under overlays ([b/483966071](https://issuetracker.google.com/issues/483966071)) | Fixed in 1.1.0-rc01 | |
| Crash with SharedTransitionLayout + OverlayScene ([b/478664101](https://issuetracker.google.com/issues/478664101)) | Fixed in 1.1.0-alpha04 | |
| IllegalStateException in Android Studio Previews ([b/477149762](https://issuetracker.google.com/issues/477149762)) | Fixed in 1.0.1 / NavigationEvent 1.0.2 | |
| Infinite scene recalculation ([b/418153031](https://issuetracker.google.com/issues/418153031)) | Fixed in 1.0.0-alpha07 | |
| Screen flicker when swapping backStack with animations ([b/450967248](https://issuetracker.google.com/issues/450967248)) | Fixed in 1.0.0-beta01 | |
### 5.2 Footguns
1. **Decorator order matters**`rememberSaveableStateHolderNavEntryDecorator()` must be **first** in the decorators list. Otherwise, `rememberSaveable` won't work inside NavEntry content.
2. **Animations in adaptive layouts** — When using `ListDetailSceneStrategy` in two-pane mode (>=600dp), transition animations between top-level destinations may not work. The screen "jumps" without animation.
3. **No built-in deep link handling** — Unlike Nav 2's `deepLinks { }` DSL, you must parse intents and build synthetic back stacks yourself. See the recipes repo for patterns.
4. **`rememberNavBackStack` requires NavKey** — If you want persistence across process death, all keys must implement `NavKey` and be `@Serializable`. Simple `data object` without `NavKey` will work but won't survive process death.
5. **compileSdk 36 required** — This is a hard requirement. If your project is on an older compileSdk, you must upgrade.
6. **No nested navigation (>1 level)** — The migration guide explicitly states: only one level of nesting is supported. Shared destinations (screens moving between different back stacks) are also unsupported.
7. **`calculateScene` is no longer `@Composable` (since 1.0.0-alpha11)** — Custom SceneStrategies cannot call composable functions in `calculateScene`. Use `remember` in a factory function instead.
8. **NavBackStack serialization is Android-only** — The built-in `NavBackStackSerializer` is restricted to Android ([b/420443609](https://issuetracker.google.com/issues/420443609)). For KMP, you need polymorphic serialization (see Section 6).
9. **API churn in 1.1.x** — The 1.1 branch is still in RC. APIs like `SceneStrategy` receiving `List<SceneStrategy>` instead of chained strategies changed in alpha05. Pin to 1.0.1 for stability.
### 5.3 Things Harder in Nav 3
- **Deep linking**: Significantly more manual work required
- **Bottom sheet destinations**: No built-in support; recipe available but requires custom SceneStrategy
- **Fragment/View interop**: Must use Views-in-Compose wrappers; Nav 3 is Compose-only
- **Shared destinations between back stacks**: Not supported
- **Testing**: No dedicated testing artifact; testing is easier conceptually (just a list) but lacks the convenience APIs of `TestNavHostController`
### 5.4 Things Easier in Nav 3
- **Back stack inspection**: It's just a list — fully observable and debuggable
- **Multi-pane/adaptive layouts**: First-class support via Scenes
- **Custom navigation logic**: No fighting the NavController
- **State management**: Aligns perfectly with Compose state
- **ViewModel scoping**: Explicit, clear, and configurable via decorators
---
## 6. KMP / Compose Multiplatform Considerations
### 6.1 Is Nav 3 Available for Compose Multiplatform?
**Yes.** JetBrains added Nav 3 support in **Compose Multiplatform 1.10.0** (January 2026) for all platforms: Android, iOS, desktop, and web.
### 6.2 Dependencies (KMP)
```toml
[versions]
multiplatform-nav3-ui = "1.0.0-alpha05"
[libraries]
jetbrains-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "multiplatform-nav3-ui" }
```
Nav 3 ships as two artifacts:
- `navigation3-common` — Core (transitive dependency)
- `navigation3-ui` — NavDisplay and UI components (separate CMP implementation)
Additional KMP libraries:
```toml
# Material 3 Adaptive
jetbrains-material3-adaptiveNavigation3 = { module = "org.jetbrains.compose.material3.adaptive:adaptive-navigation3", version.ref = "compose-multiplatform-adaptive" }
# ViewModel support
jetbrains-lifecycle-viewmodelNavigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "compose-multiplatform-lifecycle" }
```
### 6.3 Critical KMP Requirement: Polymorphic Serialization
**On Android**, Nav 3 uses reflection-based serialization for `NavKey` types. **This does NOT work on non-JVM platforms (iOS, web).** You must use polymorphic serialization.
`rememberNavBackStack()` has two overloads:
1. **Android-only**: `rememberNavBackStack(startKey)` — uses reflection
2. **Multiplatform**: `rememberNavBackStack(config, startKey)` — requires `SavedStateConfiguration`
**For KMP, you MUST use the second overload:**
```kotlin
@Serializable
data object Home : NavKey
@Serializable
data class Chat(val id: String) : NavKey
// Required for non-JVM platforms
private val config = SavedStateConfiguration {
serializersModule = SerializersModule {
polymorphic(NavKey::class) {
subclass(Home::class, Home.serializer())
subclass(Chat::class, Chat.serializer())
}
}
}
@Composable
fun App() {
val backStack = rememberNavBackStack(config, Home)
NavDisplay(backStack = backStack, ...)
}
```
### 6.4 Multi-Module KMP Pattern
For our multi-module setup, use sealed interfaces per feature module:
```kotlin
// feature/auth/api
@Serializable
sealed interface AuthRoute : NavKey
@Serializable data object Login : AuthRoute
@Serializable data object Register : AuthRoute
// feature/chat/api
@Serializable
sealed interface ChatRoute : NavKey
@Serializable data class Conversation(val id: String) : ChatRoute
// app module — aggregate all feature routes
private val config = SavedStateConfiguration {
serializersModule = SerializersModule {
polymorphic(NavKey::class) {
subclassesOfSealed<AuthRoute>()
subclassesOfSealed<ChatRoute>()
}
}
}
```
Alternatively, with Koin DI, each module can expose a `SerializersModule` and the app module combines them:
```kotlin
// Each feature module
val chatSerializerModule = SerializersModule {
polymorphic(NavKey::class) {
subclass(Conversation::class, Conversation.serializer())
}
}
// App module
val combinedConfig = SavedStateConfiguration {
serializersModule = chatSerializerModule + authSerializerModule + settingsSerializerModule
}
```
### 6.5 Impact on Our Current Setup
We currently use `org.jetbrains.androidx.navigation:navigation-compose:2.9.2`. For Nav 3:
| Current (Nav 2 KMP) | Nav 3 KMP |
|---------------------|-----------|
| `org.jetbrains.androidx.navigation:navigation-compose:2.9.2` | `org.jetbrains.androidx.navigation3:navigation3-ui:1.0.0-alpha05` |
| `NavHost` | `NavDisplay` |
| `NavController` | `SnapshotStateList` + custom Navigator |
| Reflection-based routing | **Must use** `SavedStateConfiguration` with polymorphic serialization |
| Direct route access | Key-based entry access |
**Key considerations:**
- The JetBrains Nav 3 artifact is at **1.0.0-alpha05** (not yet stable for KMP)
- Android-side Nav 3 is stable at 1.0.1
- We must implement polymorphic serialization for all routes
- Our Koin-based DI can provide serializer modules per feature
- Browser history navigation (for web target) is expected in Nav 3 KMP 1.1.0
### 6.6 CMP Nav 3 Recipes
JetBrains maintains a Compose Multiplatform fork of the nav3-recipes:
- [github.com/terrakok/nav3-recipes](https://github.com/terrakok/nav3-recipes)
---
## 7. Appendix: Version & Dependency Reference
### AndroidX Dependencies (Android-only)
```toml
[versions]
nav3Core = "1.0.1" # Stable
# nav3Core = "1.1.0-rc01" # Latest RC
lifecycleViewmodelNav3 = "2.11.0-alpha03"
material3AdaptiveNav3 = "1.3.0-alpha09"
kotlinxSerializationCore = "1.9.0"
[libraries]
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" }
androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" }
androidx-material3-adaptive-navigation3 = { module = "androidx.compose.material3.adaptive:adaptive-navigation3", version.ref = "material3AdaptiveNav3" }
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerializationCore" }
```
### Compose Multiplatform Dependencies (KMP)
```toml
[versions]
multiplatform-nav3-ui = "1.0.0-alpha05"
compose-multiplatform-adaptive = "1.3.0-alpha02"
compose-multiplatform-lifecycle = "2.10.0-alpha05"
[libraries]
jetbrains-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "multiplatform-nav3-ui" }
jetbrains-material3-adaptiveNavigation3 = { module = "org.jetbrains.compose.material3.adaptive:adaptive-navigation3", version.ref = "compose-multiplatform-adaptive" }
jetbrains-lifecycle-viewmodelNavigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "compose-multiplatform-lifecycle" }
```
### Nav 3 Recipes Repository
- Android: [github.com/android/nav3-recipes](https://github.com/android/nav3-recipes) (1.2k stars)
- CMP: [github.com/terrakok/nav3-recipes](https://github.com/terrakok/nav3-recipes)
Available recipes:
- Basic, Saveable back stack, Entry provider DSL
- Deep links (basic + advanced)
- Dialog, BottomSheet, List-Detail, Two-pane, Supporting Pane
- Animations
- Navigation UI, Multiple back stacks
- Conditional navigation
- Modularized navigation (Hilt + Koin)
- ViewModel patterns (basic, Hilt, Koin, shared)
- Retain values for back stack composables
- Returning results (events + state)
- Fragment/View interop
### Sources
- [Navigation 3 Overview](https://developer.android.com/guide/navigation/navigation-3)
- [Navigation 3 Basics](https://developer.android.com/guide/navigation/navigation-3/basics)
- [Navigation 3 Migration Guide](https://developer.android.com/guide/navigation/navigation-3/migration-guide)
- [Navigation 3 Save State](https://developer.android.com/guide/navigation/navigation-3/save-state)
- [Navigation 3 Metadata](https://developer.android.com/guide/navigation/navigation-3/metadata)
- [Navigation 3 Modularize](https://developer.android.com/guide/navigation/navigation-3/modularize)
- [Navigation 3 Scenes](https://developer.android.com/guide/navigation/navigation-3/scenes)
- [Navigation 3 Animations](https://developer.android.com/guide/navigation/navigation-3/animate-destinations)
- [Navigation 3 Entry Decorators](https://developer.android.com/guide/navigation/navigation-3/naventrydecorators)
- [Navigation 3 Get Started](https://developer.android.com/guide/navigation/navigation-3/get-started)
- [Navigation 3 Release Notes](https://developer.android.com/jetpack/androidx/releases/navigation3)
- [Navigation 3 in Compose Multiplatform](https://kotlinlang.org/docs/multiplatform/compose-navigation-3.html)
- [Compose Multiplatform 1.10.0 Blog Post](https://blog.jetbrains.com/kotlin/2026/01/compose-multiplatform-1-10-0/)
- [Jetpack Navigation 3 is Stable (Blog)](https://android-developers.googleblog.com/2025/11/jetpack-navigation-3-is-stable.html)
- [Announcing Jetpack Navigation 3 (Blog)](https://android-developers.googleblog.com/2025/05/announcing-jetpack-navigation-3-for-compose.html)
- [nav3-recipes GitHub](https://github.com/android/nav3-recipes)
- [Deep Link Guide](https://github.com/android/nav3-recipes/blob/main/docs/deeplink-guide.md)
- [Shared ViewModel Recipe](https://developer.android.com/guide/navigation/navigation-3/recipes/sharedviewmodel)

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

@ -31,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

@ -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,55 +30,42 @@ 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.NavDestination
import androidx.navigation.compose.NavHost
import androidx.navigation.toRoute
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import kotlin.reflect.KClass
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.AgentDetail
import com.garfiec.librechat.feature.agents.navigation.AgentEditorCreate
import com.garfiec.librechat.feature.agents.navigation.AgentEditorEdit
import com.garfiec.librechat.feature.agents.navigation.AgentMarketplace
import com.garfiec.librechat.feature.agents.navigation.agentsGraph
import com.garfiec.librechat.feature.agents.navigation.agentsEntries
import com.garfiec.librechat.feature.auth.navigation.AuthRoute
import com.garfiec.librechat.feature.auth.navigation.ServerUrl
import com.garfiec.librechat.feature.auth.navigation.authGraph
import com.garfiec.librechat.feature.auth.navigation.authEntries
import com.garfiec.librechat.feature.chat.navigation.Chat
import com.garfiec.librechat.feature.chat.navigation.ChatRoute
import com.garfiec.librechat.feature.chat.navigation.NewChat
import com.garfiec.librechat.feature.chat.navigation.chatGraph
import com.garfiec.librechat.feature.chat.navigation.navigateToChat
import com.garfiec.librechat.feature.chat.navigation.chatEntries
import com.garfiec.librechat.feature.conversations.navigation.ArchivedConversations
import com.garfiec.librechat.feature.conversations.navigation.conversationsGraph
import com.garfiec.librechat.feature.conversations.navigation.conversationsEntries
import com.garfiec.librechat.feature.files.navigation.Files
import com.garfiec.librechat.feature.files.navigation.filesGraph
import com.garfiec.librechat.feature.settings.navigation.ApiKeys
import com.garfiec.librechat.feature.settings.navigation.PresetManager
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.SharedLinks
import com.garfiec.librechat.feature.settings.navigation.settingsGraph
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
/** Checks if this destination's route matches the given typed route class. */
private fun NavDestination?.isRoute(routeClass: KClass<*>): Boolean {
val qualifiedName = routeClass.qualifiedName ?: return false
return this?.route?.startsWith(qualifiedName) == true
}
/** Maps a [SettingsCategory] to its corresponding typed navigation route. */
fun SettingsCategory.toRoute(): SettingsRoute = when (this) {
SettingsCategory.GENERAL -> SettingsGeneral
@ -95,46 +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.isRoute(AuthRoute::class) ||
currentDestination?.parent.isRoute(AuthRoute::class)
// 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 conversationId = if (navBackStackEntry?.destination.isRoute(Chat::class)) {
navBackStackEntry?.toRoute<Chat>()?.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(ServerUrl) {
popUpTo(0) { inclusive = true }
}
navigator.navigateToAuth()
}
}
val startDestination: Any = if (isLoggedIn) ChatRoute::class else AuthRoute::class
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,
@ -142,10 +124,8 @@ fun LibreChatNavHost(
)
} else {
PhoneLayout(
navController = navController,
navigator = navigator,
navHostViewModel = navHostViewModel,
startDestination = startDestination,
isInAuthFlow = isInAuthFlow,
deepLinkUri = deepLinkUri,
onDeepLinkConsumed = onDeepLinkConsumed,
shareNavigationTrigger = shareNavigationTrigger,
@ -206,10 +186,8 @@ private fun VersionMismatchDialog(
@Composable
private fun PhoneLayout(
navController: androidx.navigation.NavHostController,
navigator: Navigator,
navHostViewModel: NavHostViewModel,
startDestination: Any,
isInAuthFlow: Boolean,
deepLinkUri: Uri?,
onDeepLinkConsumed: () -> Unit,
shareNavigationTrigger: Int,
@ -217,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()
@ -227,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" }
}
@ -237,22 +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 currentDest = navController.currentBackStackEntry?.destination
val isOnChatScreen = currentDest.isRoute(Chat::class) ||
currentDest.isRoute(NewChat::class)
val currentRoute = navigator.currentRoute
val isOnChatScreen = currentRoute is Chat || currentRoute is NewChat
if (!isOnChatScreen) {
navController.navigate(NewChat) {
popUpTo<ChatRoute> { inclusive = false }
launchSingleTop = true
}
navigator.navigateToTopLevel(NewChat)
}
// If already on a chat screen, SharedIntentConsumer.shareAvailable
// will notify the active ChatViewModel to consume the content.
}
}
@ -265,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 currentDest = navController.currentBackStackEntry?.destination
if (!currentDest.isRoute(NewChat::class)) {
navController.navigate(NewChat) {
popUpTo<ChatRoute> { 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(SettingsTabbed) {
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(AgentMarketplace) {
launchSingleTop = true
}
navigator.navigate(AgentMarketplace)
},
onFilesClick = {
scope.launch { drawerState.close() }
navController.navigate(Files) {
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,
@ -323,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(NewChat) {
popUpTo<AuthRoute> { inclusive = true }
}
},
)
chatGraph(
navController = navController,
onOpenDrawer = {
scope.launch { drawerState.open() }
},
)
conversationsGraph(
onConversationClick = { conversationId ->
navController.navigateToChat(conversationId)
},
onNavigateToArchived = {
navController.navigate(ArchivedConversations)
},
onNavigateBackFromArchived = {
navController.popBackStack()
},
)
agentsGraph(
onAgentClick = { agentId ->
navController.navigate(AgentDetail(agentId = agentId))
},
onBack = { navController.popBackStack() },
onStartChat = { agentId ->
navController.navigate(NewChat) {
popUpTo<ChatRoute> { inclusive = false }
launchSingleTop = true
}
},
onCreateAgent = {
navController.navigate(AgentEditorCreate)
},
onEditAgent = { agentId ->
navController.navigate(AgentEditorEdit(agentId = agentId))
},
)
filesGraph()
settingsGraph(
onLogout = {
navHostViewModel.logout()
navController.navigate(ServerUrl) {
popUpTo(0) { inclusive = true }
}
},
onNavigateBack = { navController.popBackStack() },
onNavigateToArchived = {
navController.navigate(ArchivedConversations) {
launchSingleTop = true
}
},
onNavigateToSharedLinks = {
navController.navigate(SharedLinks) {
launchSingleTop = true
}
},
onNavigateBackFromSharedLinks = {
navController.popBackStack()
},
onNavigateToPresets = {
navController.navigate(PresetManager) {
launchSingleTop = true
}
},
onNavigateBackFromPresets = {
navController.popBackStack()
},
onNavigateToApiKeys = {
navController.navigate(ApiKeys) {
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

@ -1,5 +1,6 @@
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
@ -12,7 +13,7 @@ import kotlinx.serialization.modules.plus
/**
* Combined [SerializersModule] for all navigation route types.
* Registers polymorphic serializers for each feature module's sealed route hierarchy.
* This will be used by Nav 3's SavedStateConfiguration for type-safe state saving.
* Used by Nav 3's SavedStateConfiguration for type-safe state saving.
*/
val navigationSerializersModule: SerializersModule =
authSerializersModule +
@ -21,3 +22,11 @@ val navigationSerializersModule: SerializersModule =
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,46 +25,19 @@ 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.NavDestination
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import kotlin.reflect.KClass
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.AgentDetail
import com.garfiec.librechat.feature.agents.navigation.AgentEditorCreate
import com.garfiec.librechat.feature.agents.navigation.AgentEditorEdit
import com.garfiec.librechat.feature.agents.navigation.AgentMarketplace
import com.garfiec.librechat.feature.agents.navigation.agentsGraph
import com.garfiec.librechat.feature.auth.navigation.AuthRoute
import com.garfiec.librechat.feature.auth.navigation.ServerUrl
import com.garfiec.librechat.feature.auth.navigation.authGraph
import com.garfiec.librechat.feature.chat.navigation.Chat
import com.garfiec.librechat.feature.chat.navigation.ChatRoute
import com.garfiec.librechat.feature.chat.navigation.NewChat
import com.garfiec.librechat.feature.chat.navigation.chatGraph
import com.garfiec.librechat.feature.chat.navigation.navigateToChat
import com.garfiec.librechat.feature.conversations.navigation.ArchivedConversations
import com.garfiec.librechat.feature.conversations.navigation.conversationsGraph
import com.garfiec.librechat.feature.files.navigation.Files
import com.garfiec.librechat.feature.files.navigation.filesGraph
import com.garfiec.librechat.feature.settings.navigation.ApiKeys
import com.garfiec.librechat.feature.settings.navigation.PresetManager
import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed
import com.garfiec.librechat.feature.settings.navigation.SharedLinks
import com.garfiec.librechat.feature.settings.navigation.settingsGraph
import kotlinx.coroutines.launch
import co.touchlab.kermit.Logger
/** Checks if this destination's route matches the given typed route class. */
private fun NavDestination?.isRoute(routeClass: KClass<*>): Boolean {
val qualifiedName = routeClass.qualifiedName ?: return false
return this?.route?.startsWith(qualifiedName) == true
}
private val SidebarWidth = 320.dp
/** Velocity (px/s) above which a fling commits the gesture regardless of position. */
@ -81,10 +45,8 @@ private const val FLING_VELOCITY_THRESHOLD = 800f
@Composable
fun TabletLayout(
navController: NavHostController,
navigator: Navigator,
navHostViewModel: NavHostViewModel,
startDestination: Any,
isInAuthFlow: Boolean,
deepLinkUri: Uri?,
onDeepLinkConsumed: () -> Unit,
modifier: Modifier = Modifier,
@ -95,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)
@ -106,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()
@ -121,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" }
}
@ -131,19 +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 currentDest = navController.currentBackStackEntry?.destination
val isOnChatScreen = currentDest.isRoute(Chat::class) ||
currentDest.isRoute(NewChat::class)
val currentRoute = navigator.currentRoute
val isOnChatScreen = currentRoute is Chat || currentRoute is NewChat
if (!isOnChatScreen) {
navController.navigate(NewChat) {
popUpTo<ChatRoute> { inclusive = false }
launchSingleTop = true
}
navigator.navigateToTopLevel(NewChat)
}
}
}
@ -163,7 +116,7 @@ fun TabletLayout(
}
}
if (!isInAuthFlow) {
if (!navigator.isInAuthFlow) {
val swipeModifier = if (gestureEnabled) {
Modifier.pointerInput(Unit) {
val velocityTracker = VelocityTracker()
@ -174,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
@ -192,7 +144,6 @@ fun TabletLayout(
}
},
onDragCancel = {
// Snap back to the current committed state
val target = if (isSidebarOpen) sidebarWidthPx else 0f
scope.launch {
sidebarOffset.animateTo(
@ -229,37 +180,24 @@ fun TabletLayout(
SidebarScaffold(
viewModel = navHostViewModel,
onNewChat = {
// Skip navigation if already on the new chat screen
val currentDest = navController.currentBackStackEntry?.destination
if (!currentDest.isRoute(NewChat::class)) {
navController.navigate(NewChat) {
popUpTo<ChatRoute> { inclusive = false }
launchSingleTop = true
}
if (navigator.currentRoute !is NewChat) {
navigator.navigateToTopLevel(NewChat)
}
},
onConversationClick = { conversationId ->
navController.navigateToChat(conversationId)
navigator.navigateToChat(conversationId)
},
onSettingsClick = {
navController.navigate(SettingsTabbed) {
launchSingleTop = true
}
navigator.navigate(SettingsTabbed)
},
onSettingsCategorySelected = { category ->
navController.navigate(category.toRoute()) {
launchSingleTop = true
}
navigator.navigate(category.toRoute())
},
onAgentsClick = {
navController.navigate(AgentMarketplace) {
launchSingleTop = true
}
navigator.navigate(AgentMarketplace)
},
onFilesClick = {
navController.navigate(Files) {
launchSingleTop = true
}
navigator.navigate(Files)
},
modifier = Modifier
.width(SidebarWidth)
@ -271,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,
@ -287,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,
@ -321,9 +253,8 @@ fun TabletLayout(
@Composable
private fun MainContent(
navController: NavHostController,
navigator: Navigator,
navHostViewModel: NavHostViewModel,
startDestination: Any,
isInAuthFlow: Boolean,
banners: List<com.garfiec.librechat.core.model.Banner>,
dismissedBannerIds: Set<String>,
@ -339,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(NewChat) {
popUpTo<AuthRoute> { inclusive = true }
}
},
)
chatGraph(
navController = navController,
onOpenDrawer = onToggleDrawer,
)
conversationsGraph(
onConversationClick = { conversationId ->
navController.navigateToChat(conversationId)
},
onNavigateToArchived = {
navController.navigate(ArchivedConversations)
},
onNavigateBackFromArchived = {
navController.popBackStack()
},
)
agentsGraph(
onAgentClick = { agentId ->
navController.navigate(AgentDetail(agentId = agentId))
},
onBack = { navController.popBackStack() },
onStartChat = { agentId ->
navController.navigate(NewChat) {
popUpTo<ChatRoute> { inclusive = false }
launchSingleTop = true
}
},
onCreateAgent = {
navController.navigate(AgentEditorCreate)
},
onEditAgent = { agentId ->
navController.navigate(AgentEditorEdit(agentId = agentId))
},
)
filesGraph()
settingsGraph(
onLogout = {
navHostViewModel.logout()
navController.navigate(ServerUrl) {
popUpTo(0) { inclusive = true }
}
},
onNavigateBack = { navController.popBackStack() },
onNavigateToArchived = {
navController.navigate(ArchivedConversations) {
launchSingleTop = true
}
},
onNavigateToSharedLinks = {
navController.navigate(SharedLinks) {
launchSingleTop = true
}
},
onNavigateBackFromSharedLinks = {
navController.popBackStack()
},
onNavigateToPresets = {
navController.navigate(PresetManager) {
launchSingleTop = true
}
},
onNavigateBackFromPresets = {
navController.popBackStack()
},
onNavigateToApiKeys = {
navController.navigate(ApiKeys) {
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,7 +1,7 @@
package com.garfiec.librechat.feature.agents.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.agents.screen.AgentDetailScreen
import com.garfiec.librechat.feature.agents.screen.AgentEditorScreen
import com.garfiec.librechat.feature.agents.screen.AgentMarketplaceScreen
@ -10,51 +10,52 @@ import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import kotlinx.serialization.modules.subclass
@Serializable sealed interface AgentsRoute
@Serializable sealed interface AgentsRoute : NavKey
@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 NavGraphBuilder.agentsGraph(
onAgentClick: (String) -> Unit,
fun EntryProviderScope<NavKey>.agentsEntries(
onNavigate: (NavKey) -> Unit,
onBack: () -> Unit,
onStartChat: (String) -> Unit,
onCreateAgent: () -> Unit,
onEditAgent: (String) -> Unit,
) {
composable<AgentMarketplace> {
entry<AgentMarketplace> {
AgentMarketplaceScreen(
onAgentClick = onAgentClick,
onCreateAgent = onCreateAgent,
onAgentClick = { agentId -> onNavigate(AgentDetail(agentId = agentId)) },
onCreateAgent = { onNavigate(AgentEditorCreate) },
onBack = onBack,
)
}
composable<AgentDetail> {
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<AgentEditorCreate> {
entry<AgentEditorCreate> {
AgentEditorScreen(
onBack = onBack,
onSaved = { agentId -> onAgentClick(agentId) },
onSaved = { agentId -> onNavigate(AgentDetail(agentId = agentId)) },
agentId = null,
)
}
composable<AgentEditorEdit> {
entry<AgentEditorEdit> { key ->
AgentEditorScreen(
onBack = onBack,
onSaved = { agentId -> onAgentClick(agentId) },
onSaved = { agentId -> onNavigate(AgentDetail(agentId = agentId)) },
agentId = key.agentId,
)
}
}
val agentsSerializersModule = SerializersModule {
polymorphic(AgentsRoute::class) {
polymorphic(NavKey::class) {
subclass(AgentMarketplace::class, AgentMarketplace.serializer())
subclass(AgentDetail::class, AgentDetail.serializer())
subclass(AgentEditorCreate::class, AgentEditorCreate.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,10 +1,7 @@
package com.garfiec.librechat.feature.auth.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
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
@ -18,7 +15,7 @@ import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import kotlinx.serialization.modules.subclass
@Serializable sealed interface AuthRoute
@Serializable sealed interface AuthRoute : NavKey
@Serializable data object ServerUrl : AuthRoute
@Serializable data object Login : AuthRoute
@ -29,90 +26,69 @@ import kotlinx.serialization.modules.subclass
@Serializable data object Terms : AuthRoute
@Serializable data class ResetPassword(val userId: String, val token: String) : AuthRoute
fun NavController.navigateToVerifyEmail(email: String) {
navigate(VerifyEmail(email = email))
}
fun NavController.navigateToResetPassword(userId: String, token: String) {
navigate(ResetPassword(userId = userId, token = token))
}
fun NavGraphBuilder.authGraph(
navController: NavController,
fun EntryProviderScope<NavKey>.authEntries(
onNavigate: (NavKey) -> Unit,
onBack: () -> Unit,
onAuthComplete: () -> Unit,
) {
navigation<AuthRoute>(startDestination = ServerUrl::class) {
composable<ServerUrl> {
ScreenTransitionWrapper(transition) {
ServerUrlScreen(
onServerValidated = { navController.navigate(Login) },
)
}
}
composable<Login> {
ScreenTransitionWrapper(transition) {
LoginScreen(
onLoginSuccess = onAuthComplete,
onNavigateToRegister = { navController.navigate(Register) },
onNavigateToForgotPassword = { navController.navigate(ForgotPassword) },
onNavigateToTwoFactor = { tempToken ->
navController.navigate(TwoFactor(tempToken = tempToken))
},
)
}
}
composable<Register> {
ScreenTransitionWrapper(transition) {
RegisterScreen(
onRegistered = { navController.popBackStack<Login>(inclusive = false) },
onNavigateToLogin = { navController.popBackStack() },
)
}
}
composable<ForgotPassword> {
ScreenTransitionWrapper(transition) {
ForgotPasswordScreen(
onBack = { navController.popBackStack() },
)
}
}
composable<TwoFactor> {
ScreenTransitionWrapper(transition) {
TwoFactorScreen(
onVerified = onAuthComplete,
onBack = { navController.popBackStack() },
)
}
}
composable<VerifyEmail> {
ScreenTransitionWrapper(transition) {
VerifyEmailScreen(
onVerified = { navController.popBackStack<Login>(inclusive = false) },
onBack = { navController.popBackStack() },
)
}
}
composable<Terms> {
ScreenTransitionWrapper(transition) {
TermsScreen(
onAccepted = onAuthComplete,
onBack = { navController.popBackStack() },
)
}
}
composable<ResetPassword> {
ScreenTransitionWrapper(transition) {
ResetPasswordScreen(
onResetComplete = { navController.popBackStack<Login>(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(AuthRoute::class) {
polymorphic(NavKey::class) {
subclass(ServerUrl::class, ServerUrl.serializer())
subclass(Login::class, Login.serializer())
subclass(Register::class, Register.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,13 +1,7 @@
package com.garfiec.librechat.feature.chat.navigation
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.navigation
import kotlin.reflect.KClass
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
@ -17,96 +11,56 @@ import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import kotlinx.serialization.modules.subclass
@Serializable sealed interface ChatRoute
@Serializable sealed interface ChatRoute : NavKey
@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
/** Checks if this destination's route matches the given typed route class. */
internal fun NavDestination?.isRoute(routeClass: KClass<*>): Boolean {
val qualifiedName = routeClass.qualifiedName ?: return false
return this?.route?.startsWith(qualifiedName) == true
}
fun NavController.navigateToChat(conversationId: String) {
val currentEntry = currentBackStackEntry
val isCurrentlyInChat = currentEntry?.destination.isRoute(Chat::class)
val currentDestId = currentEntry?.destination?.id
navigate(Chat(conversationId = conversationId)) {
if (isCurrentlyInChat && currentDestId != null) {
// Already viewing a chat -- replace it so switching chats
// doesn't stack entries. Back will go to whatever was
// before the first chat (e.g. new_chat, settings, agents).
popUpTo(currentDestId) { inclusive = true }
}
// From non-chat screens just push onto the backstack normally
launchSingleTop = true
}
}
fun NavController.navigateToPromptsLibrary() {
navigate(PromptsLibrary)
}
fun NavController.navigateToPromptEditor(groupId: String? = null) {
navigate(PromptEditor(groupId = groupId))
}
fun NavGraphBuilder.chatGraph(
navController: NavController,
fun EntryProviderScope<NavKey>.chatEntries(
onNavigate: (NavKey) -> Unit,
onBack: () -> Unit,
onNavigateToChat: (String) -> Unit,
onOpenDrawer: (() -> Unit)? = null,
) {
navigation<ChatRoute>(startDestination = NewChat::class) {
composable<NewChat>(
enterTransition = { EnterTransition.None },
exitTransition = { ExitTransition.None },
popEnterTransition = { null },
popExitTransition = { null },
) {
NewChatScreen(
onConversationStarted = { conversationId ->
navController.navigateToChat(conversationId)
},
onOpenDrawer = onOpenDrawer,
onNavigateToPromptsLibrary = { navController.navigateToPromptsLibrary() },
)
}
composable<Chat>(
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<PromptsLibrary> {
PromptsLibraryScreen(
onNavigateBack = { navController.popBackStack() },
onUseInChat = { promptText ->
navController.popBackStack()
},
onNavigateToEditor = { groupId ->
navController.navigateToPromptEditor(groupId)
},
)
}
composable<PromptEditor> {
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(ChatRoute::class) {
polymorphic(NavKey::class) {
subclass(NewChat::class, NewChat.serializer())
subclass(Chat::class, Chat.serializer())
subclass(PromptsLibrary::class, PromptsLibrary.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,8 +1,7 @@
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
@ -10,35 +9,31 @@ import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import kotlinx.serialization.modules.subclass
@Serializable sealed interface ConversationsRoute
@Serializable sealed interface ConversationsRoute : NavKey
@Serializable data object Conversations : ConversationsRoute
@Serializable data object ArchivedConversations : ConversationsRoute
fun NavGraphBuilder.conversationsGraph(
fun EntryProviderScope<NavKey>.conversationsEntries(
onConversationClick: (String) -> Unit,
onNavigateToArchived: () -> Unit = {},
onNavigateBackFromArchived: () -> Unit = {},
onBack: () -> Unit = {},
) {
composable<Conversations> {
ScreenTransitionWrapper(transition) {
ConversationListScreen(
onConversationClick = onConversationClick,
onNavigateToArchived = onNavigateToArchived,
)
}
entry<Conversations> {
ConversationListScreen(
onConversationClick = onConversationClick,
onNavigateToArchived = onNavigateToArchived,
)
}
composable<ArchivedConversations> {
ScreenTransitionWrapper(transition) {
ArchivedConversationsScreen(
onNavigateBack = onNavigateBackFromArchived,
)
}
entry<ArchivedConversations> {
ArchivedConversationsScreen(
onNavigateBack = onBack,
)
}
}
val conversationsSerializersModule = SerializersModule {
polymorphic(ConversationsRoute::class) {
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,30 +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
@Serializable sealed interface FilesRoute
@Serializable sealed interface FilesRoute : NavKey
@Serializable data object Files : FilesRoute
fun NavGraphBuilder.filesGraph(
fun EntryProviderScope<NavKey>.filesEntries(
onBack: (() -> Unit)? = null,
) {
composable<Files> {
ScreenTransitionWrapper(transition) {
FilesScreen(onBack = onBack)
}
entry<Files> {
FilesScreen(onBack = onBack)
}
}
val filesSerializersModule = SerializersModule {
polymorphic(FilesRoute::class) {
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,18 +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
@Serializable data object McpServers : SettingsRoute
fun NavGraphBuilder.mcpServersScreen(
onNavigateBack: () -> Unit,
fun EntryProviderScope<NavKey>.mcpServersEntry(
onBack: () -> Unit,
) {
composable<McpServers> {
entry<McpServers> {
McpServersScreen(
onNavigateBack = onNavigateBack,
onNavigateBack = onBack,
)
}
}

View file

@ -1,18 +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
@Serializable data object Memories : SettingsRoute
fun NavGraphBuilder.memoriesScreen(
onNavigateBack: () -> Unit,
fun EntryProviderScope<NavKey>.memoriesEntry(
onBack: () -> Unit,
) {
composable<Memories> {
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
@ -19,7 +19,7 @@ import kotlinx.serialization.modules.polymorphic
import kotlinx.serialization.modules.subclass
import org.koin.compose.viewmodel.koinViewModel
@Serializable sealed interface SettingsRoute
@Serializable sealed interface SettingsRoute : NavKey
@Serializable data object SettingsTabbed : SettingsRoute
@Serializable data object SettingsGeneral : SettingsRoute
@ -30,54 +30,48 @@ import org.koin.compose.viewmodel.koinViewModel
@Serializable data object PresetManager : SettingsRoute
@Serializable data object ApiKeys : SettingsRoute
fun NavGraphBuilder.settingsGraph(
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<SettingsTabbed> {
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<SettingsGeneral> {
entry<SettingsGeneral> {
GeneralSettingsScreen(
onNavigateBack = onNavigateBack,
onNavigateBack = onBack,
)
}
composable<SettingsChat> {
entry<SettingsChat> {
ChatSettingsScreen(
onNavigateBack = onNavigateBack,
onNavigateToPresets = onNavigateToPresets,
onNavigateBack = onBack,
onNavigateToPresets = { onNavigate(PresetManager) },
)
}
composable<SettingsAccount> {
entry<SettingsAccount> {
AccountSettingsScreen(
onLogout = onLogout,
onNavigateBack = onNavigateBack,
onNavigateToApiKeys = onNavigateToApiKeys,
onNavigateBack = onBack,
onNavigateToApiKeys = { onNavigate(ApiKeys) },
)
}
composable<SettingsData> {
entry<SettingsData> {
DataSettingsScreen(
onNavigateBack = onNavigateBack,
onNavigateBack = onBack,
onNavigateToArchived = onNavigateToArchived,
onNavigateToSharedLinks = onNavigateToSharedLinks,
onNavigateToSharedLinks = { onNavigate(SharedLinks) },
)
}
composable<SharedLinks> {
entry<SharedLinks> {
val viewModel: SettingsViewModel = koinViewModel()
val uiState = viewModel.uiState.collectAsStateWithLifecycle()
@ -93,23 +87,23 @@ fun NavGraphBuilder.settingsGraph(
onLoadMore = viewModel::loadMoreSharedLinks,
onToggleVisibility = viewModel::toggleSharedLinkVisibility,
onDelete = viewModel::deleteSharedLink,
onNavigateBack = onNavigateBackFromSharedLinks,
onNavigateBack = onBack,
)
}
composable<PresetManager> {
entry<PresetManager> {
PresetManagerScreen(
onNavigateBack = onNavigateBackFromPresets,
onNavigateBack = onBack,
)
}
composable<ApiKeys> {
entry<ApiKeys> {
ApiKeysScreen(
onNavigateBack = onNavigateBackFromApiKeys,
onNavigateBack = onBack,
)
}
}
val settingsSerializersModule = SerializersModule {
polymorphic(SettingsRoute::class) {
polymorphic(NavKey::class) {
subclass(SettingsTabbed::class, SettingsTabbed.serializer())
subclass(SettingsGeneral::class, SettingsGeneral.serializer())
subclass(SettingsChat::class, SettingsChat.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,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,43 +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.navigation.NavDestination
import androidx.navigation.compose.NavHost
import androidx.navigation.toRoute
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import kotlin.reflect.KClass
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.AgentDetail
import com.garfiec.librechat.feature.agents.navigation.AgentEditorCreate
import com.garfiec.librechat.feature.agents.navigation.AgentEditorEdit
import com.garfiec.librechat.feature.agents.navigation.AgentMarketplace
import com.garfiec.librechat.feature.agents.navigation.agentsGraph
import com.garfiec.librechat.feature.agents.navigation.agentsEntries
import com.garfiec.librechat.feature.auth.navigation.AuthRoute
import com.garfiec.librechat.feature.auth.navigation.ServerUrl
import com.garfiec.librechat.feature.auth.navigation.authGraph
import com.garfiec.librechat.feature.auth.navigation.authEntries
import com.garfiec.librechat.feature.chat.navigation.Chat
import com.garfiec.librechat.feature.chat.navigation.ChatRoute
import com.garfiec.librechat.feature.chat.navigation.NewChat
import com.garfiec.librechat.feature.chat.navigation.chatGraph
import com.garfiec.librechat.feature.chat.navigation.navigateToChat
import com.garfiec.librechat.feature.chat.navigation.chatEntries
import com.garfiec.librechat.feature.conversations.navigation.ArchivedConversations
import com.garfiec.librechat.feature.conversations.navigation.conversationsGraph
import com.garfiec.librechat.feature.conversations.navigation.conversationsEntries
import com.garfiec.librechat.feature.files.navigation.Files
import com.garfiec.librechat.feature.files.navigation.filesGraph
import com.garfiec.librechat.feature.settings.navigation.ApiKeys
import com.garfiec.librechat.feature.settings.navigation.PresetManager
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.SharedLinks
import com.garfiec.librechat.feature.settings.navigation.settingsGraph
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
@ -72,12 +66,6 @@ import librechat_mobile.shared.generated.resources.version_mismatch_title
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
/** Checks if this destination's route matches the given typed route class. */
private fun NavDestination?.isRoute(routeClass: KClass<*>): Boolean {
val qualifiedName = routeClass.qualifiedName ?: return false
return this?.route?.startsWith(qualifiedName) == true
}
/** Maps a [SettingsCategory] to its corresponding typed navigation route. */
fun SettingsCategory.toRoute(): SettingsRoute = when (this) {
SettingsCategory.GENERAL -> SettingsGeneral
@ -99,41 +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.isRoute(AuthRoute::class) ||
currentDestination?.parent.isRoute(AuthRoute::class)
// 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 conversationId = if (navBackStackEntry?.destination.isRoute(Chat::class)) {
navBackStackEntry?.toRoute<Chat>()?.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(ServerUrl) {
popUpTo(0) { inclusive = true }
}
navigator.navigateToAuth()
}
}
val startDestination: Any = if (isLoggedIn) ChatRoute::class else AuthRoute::class
PhoneLayout(
navController = navController,
navigator = navigator,
navHostViewModel = navHostViewModel,
startDestination = startDestination,
isInAuthFlow = isInAuthFlow,
modifier = modifier,
)
@ -185,10 +167,8 @@ private fun VersionMismatchDialog(
@Composable
private fun PhoneLayout(
navController: androidx.navigation.NavHostController,
navigator: Navigator,
navHostViewModel: NavHostViewModel,
startDestination: Any,
isInAuthFlow: Boolean,
modifier: Modifier = Modifier,
) {
val drawerState = rememberDrawerState(DrawerValue.Closed)
@ -205,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
if (!currentRoute.isRoute(NewChat::class)) {
navController.navigate(NewChat) {
popUpTo<ChatRoute> { 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(SettingsTabbed) {
launchSingleTop = true
}
navigator.navigate(SettingsTabbed)
},
onSettingsCategorySelected = { category ->
navController.navigate(category.toRoute()) {
launchSingleTop = true
}
navigator.navigate(category.toRoute())
},
onAgentsClick = {
scope.launch { drawerState.close() }
navController.navigate(AgentMarketplace) {
launchSingleTop = true
}
navigator.navigate(AgentMarketplace)
},
onFilesClick = {
scope.launch { drawerState.close() }
navController.navigate(Files) {
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,
@ -260,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(NewChat) {
popUpTo<AuthRoute> { inclusive = true }
}
},
)
chatGraph(
navController = navController,
onOpenDrawer = {
scope.launch { drawerState.open() }
},
)
conversationsGraph(
onConversationClick = { conversationId ->
navController.navigateToChat(conversationId)
},
onNavigateToArchived = {
navController.navigate(ArchivedConversations)
},
onNavigateBackFromArchived = {
navController.popBackStack()
},
)
agentsGraph(
onAgentClick = { agentId ->
navController.navigate(AgentDetail(agentId = agentId))
},
onBack = { navController.popBackStack() },
onStartChat = { agentId ->
navController.navigate(NewChat) {
popUpTo<ChatRoute> { inclusive = false }
launchSingleTop = true
}
},
onCreateAgent = {
navController.navigate(AgentEditorCreate)
},
onEditAgent = { agentId ->
navController.navigate(AgentEditorEdit(agentId = agentId))
},
)
filesGraph(
onBack = { navController.popBackStack() },
)
settingsGraph(
onLogout = {
navHostViewModel.logout()
navController.navigate(ServerUrl) {
popUpTo(0) { inclusive = true }
}
},
onNavigateBack = { navController.popBackStack() },
onNavigateToArchived = {
navController.navigate(ArchivedConversations) {
launchSingleTop = true
}
},
onNavigateToSharedLinks = {
navController.navigate(SharedLinks) {
launchSingleTop = true
}
},
onNavigateBackFromSharedLinks = {
navController.popBackStack()
},
onNavigateToPresets = {
navController.navigate(PresetManager) {
launchSingleTop = true
}
},
onNavigateBackFromPresets = {
navController.popBackStack()
},
onNavigateToApiKeys = {
navController.navigate(ApiKeys) {
launchSingleTop = true
}
},
onNavigateBackFromApiKeys = {
navController.popBackStack()
},
)
}
)
}
}
}

View file

@ -1,5 +1,6 @@
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
@ -12,7 +13,7 @@ import kotlinx.serialization.modules.plus
/**
* Combined [SerializersModule] for all navigation route types.
* Registers polymorphic serializers for each feature module's sealed route hierarchy.
* This will be used by Nav 3's SavedStateConfiguration for type-safe state saving.
* Used by Nav 3's SavedStateConfiguration for type-safe state saving.
*/
val navigationSerializersModule: SerializersModule =
authSerializersModule +
@ -21,3 +22,11 @@ val navigationSerializersModule: SerializersModule =
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"),
}