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

View file

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

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 ## Navigation
`LibreChatNavHost` is the root composable. It uses adaptive layout based on `WindowSizeClass`: `LibreChatNavHost` is the root composable using Nav 3's `NavDisplay` with `NavBackStack<NavKey>` and `entryProvider`. It uses adaptive layout based on `WindowSizeClass`:
- **Phone**: `ModalNavigationDrawer` as primary navigation (sidebar-first pattern matching the web frontend). No bottom navigation bar. Drawer opens via hamburger button in chat header or swipe gesture. - **Phone**: `ModalNavigationDrawer` as primary navigation (sidebar-first pattern matching the web frontend). No bottom navigation bar. Drawer opens via hamburger button in chat header or swipe gesture.
- **Tablet** (600dp+ width): Persistent side panel with full conversation list. No `NavigationRail` or bottom bar. - **Tablet** (600dp+ width): Persistent side panel with full conversation list. No `NavigationRail` or bottom bar.
Feature modules provide entries via `EntryProviderScope<NavKey>` extensions (e.g., `authEntries()`, `chatEntries()`). Navigation is driven by `onNavigate: (NavKey) -> Unit` and `onBack: () -> Unit` lambdas — feature modules never receive `NavBackStack` directly.
## Sidebar / Drawer Content ## Sidebar / Drawer Content
`DrawerContent` is the rich sidebar matching the web's left panel: `DrawerContent` is the rich sidebar matching the web's left panel:
@ -22,8 +24,8 @@ Single Activity architecture. `MainActivity` is the sole entry point.
## Top-Level Destinations ## Top-Level Destinations
Defined in `TopLevelDestination` enum: Chat, Conversations, Agents, Files, Settings. Top-level routes: `NewChat` (Chat), `Conversations`, `AgentMarketplace` (Agents), `Files`, `SettingsTabbed` (Settings).
Conversations are integrated into the drawer body. Agents, Files, and Settings are accessible via drawer footer links. All routes remain registered in the NavHost. Conversations are integrated into the drawer body. Agents, Files, and Settings are accessible via drawer footer links. All routes are registered in the `NavDisplay` entry provider.
## Active Conversation Tracking ## Active Conversation Tracking
@ -32,8 +34,8 @@ Conversations are integrated into the drawer body. Agents, Files, and Settings a
## Auth & Session ## Auth & Session
- `NavHostViewModel` observes auth state via `isLoggedIn` flow - `NavHostViewModel` observes auth state via `isLoggedIn` flow
- Start destination is `CHAT_GRAPH_ROUTE` if logged in, `AUTH_GRAPH_ROUTE` otherwise - Start destination is `NewChat` if logged in, `ServerUrl` otherwise
- `sessionExpired` flow triggers nav to auth graph with full backstack clear - `sessionExpired` flow triggers nav to auth flow with full back stack clear
- Drawer gestures are hidden during auth flow - Drawer gestures are hidden during auth flow
## Deep Linking ## Deep Linking
@ -66,6 +68,6 @@ It applies convention plugins: `librechat.mobile.application`, `librechat.mobile
- **Gotcha**: `displayFrom`/`displayTo` are ISO 8601 strings parsed with `Instant.parse()` — wrap in `runCatching` since the format from the server is not guaranteed - **Gotcha**: `displayFrom`/`displayTo` are ISO 8601 strings parsed with `Instant.parse()` — wrap in `runCatching` since the format from the server is not guaranteed
### Preset Navigation ### Preset Navigation
- `PRESET_MANAGER_ROUTE` registered in `SettingsNavigation.kt` - `PresetManager` route registered in `SettingsNavigation.kt`
- `PresetManagerScreen` accessible from Settings → Chat section - `PresetManagerScreen` accessible from Settings → Chat section
- Navigation wired in both `LibreChatNavHost` and `TabletLayout` - Navigation wired through `MainNavDisplay` entry provider, accessible from both phone and tablet layouts

View file

@ -31,7 +31,8 @@ dependencies {
implementation(project(":feature:files")) implementation(project(":feature:files"))
implementation(libs.activity.compose) implementation(libs.activity.compose)
implementation(libs.navigation.compose) implementation(libs.navigation3.ui.kmp)
implementation(libs.lifecycle.viewmodel.navigation3.kmp)
implementation(libs.koin.compose) implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel) implementation(libs.koin.compose.viewmodel)
implementation(libs.koin.compose.viewmodel.navigation) implementation(libs.koin.compose.viewmodel.navigation)

View file

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

View file

@ -81,7 +81,7 @@ private val ActiveIndicatorShape = RoundedCornerShape(2.dp)
/** /**
* Stateful DrawerContent that collects its own state from the ViewModel. * Stateful DrawerContent that collects its own state from the ViewModel.
* State changes only recompose inside this composable not the parent * State changes only recompose inside this composable not the parent
* PhoneLayout/TabletLayout, which avoids recomposing the NavHost/main content. * PhoneLayout/TabletLayout, which avoids recomposing the NavDisplay/main content.
*/ */
@Composable @Composable
fun DrawerContent( fun DrawerContent(

View file

@ -1,7 +1,6 @@
package com.garfiec.librechat.navigation package com.garfiec.librechat.navigation
import android.net.Uri import android.net.Uri
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
@ -9,7 +8,9 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideOut import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -29,55 +30,42 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavDestination import androidx.navigation3.runtime.NavKey
import androidx.navigation.compose.NavHost import androidx.navigation3.runtime.entryProvider
import androidx.navigation.toRoute import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation3.ui.NavDisplay
import androidx.navigation.compose.rememberNavController import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import kotlin.reflect.KClass import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import com.garfiec.librechat.MainActivity import com.garfiec.librechat.MainActivity
import com.garfiec.librechat.R import com.garfiec.librechat.R
import com.garfiec.librechat.core.ui.components.BannerDisplay import com.garfiec.librechat.core.ui.components.BannerDisplay
import com.garfiec.librechat.feature.agents.navigation.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.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.AuthRoute
import com.garfiec.librechat.feature.auth.navigation.ServerUrl 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.Chat
import com.garfiec.librechat.feature.chat.navigation.ChatRoute
import com.garfiec.librechat.feature.chat.navigation.NewChat import com.garfiec.librechat.feature.chat.navigation.NewChat
import com.garfiec.librechat.feature.chat.navigation.chatGraph import com.garfiec.librechat.feature.chat.navigation.chatEntries
import com.garfiec.librechat.feature.chat.navigation.navigateToChat
import com.garfiec.librechat.feature.conversations.navigation.ArchivedConversations import com.garfiec.librechat.feature.conversations.navigation.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.Files
import com.garfiec.librechat.feature.files.navigation.filesGraph import com.garfiec.librechat.feature.files.navigation.filesEntries
import com.garfiec.librechat.feature.settings.navigation.ApiKeys
import com.garfiec.librechat.feature.settings.navigation.PresetManager
import com.garfiec.librechat.feature.settings.navigation.SettingsAccount import com.garfiec.librechat.feature.settings.navigation.SettingsAccount
import com.garfiec.librechat.feature.settings.navigation.SettingsChat import com.garfiec.librechat.feature.settings.navigation.SettingsChat
import com.garfiec.librechat.feature.settings.navigation.SettingsData import com.garfiec.librechat.feature.settings.navigation.SettingsData
import com.garfiec.librechat.feature.settings.navigation.SettingsGeneral import com.garfiec.librechat.feature.settings.navigation.SettingsGeneral
import com.garfiec.librechat.feature.settings.navigation.SettingsRoute import com.garfiec.librechat.feature.settings.navigation.SettingsRoute
import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed
import com.garfiec.librechat.feature.settings.navigation.SharedLinks import com.garfiec.librechat.feature.settings.navigation.memoriesEntry
import com.garfiec.librechat.feature.settings.navigation.settingsGraph import com.garfiec.librechat.feature.settings.navigation.mcpServersEntry
import com.garfiec.librechat.feature.settings.navigation.settingsEntries
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
/** 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. */ /** Maps a [SettingsCategory] to its corresponding typed navigation route. */
fun SettingsCategory.toRoute(): SettingsRoute = when (this) { fun SettingsCategory.toRoute(): SettingsRoute = when (this) {
SettingsCategory.GENERAL -> SettingsGeneral SettingsCategory.GENERAL -> SettingsGeneral
@ -95,46 +83,40 @@ fun LibreChatNavHost(
shareNavigationTrigger: Int = 0, shareNavigationTrigger: Int = 0,
navHostViewModel: NavHostViewModel = koinViewModel(), navHostViewModel: NavHostViewModel = koinViewModel(),
) { ) {
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
val isLoggedIn by navHostViewModel.isLoggedIn.collectAsStateWithLifecycle() val isLoggedIn by navHostViewModel.isLoggedIn.collectAsStateWithLifecycle()
val isInAuthFlow = currentDestination.isRoute(AuthRoute::class) || // Stable start key — auth redirect handled via LaunchedEffect below.
currentDestination?.parent.isRoute(AuthRoute::class) val backStack = rememberNavBackStack(navigationSavedStateConfig, NewChat)
val navigator = Navigator(backStack)
// Redirect to auth if not logged in (on first composition only).
LaunchedEffect(Unit) {
if (!navHostViewModel.isLoggedIn.value) {
navigator.navigateToAuth()
}
}
// Track active conversation from nav back stack // Track active conversation from nav back stack
LaunchedEffect(navBackStackEntry) { LaunchedEffect(navigator.currentRoute) {
val conversationId = if (navBackStackEntry?.destination.isRoute(Chat::class)) { val conversationId = (navigator.currentRoute as? Chat)?.conversationId
navBackStackEntry?.toRoute<Chat>()?.conversationId
} else {
null
}
navHostViewModel.setActiveConversation(conversationId) navHostViewModel.setActiveConversation(conversationId)
} }
// Handle session expiry // Handle session expiry
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
navHostViewModel.sessionExpired.collect { navHostViewModel.sessionExpired.collect {
navController.navigate(ServerUrl) { navigator.navigateToAuth()
popUpTo(0) { inclusive = true }
}
} }
} }
val startDestination: Any = if (isLoggedIn) ChatRoute::class else AuthRoute::class
val isTablet = windowSizeClass?.widthSizeClass?.let { val isTablet = windowSizeClass?.widthSizeClass?.let {
it >= WindowWidthSizeClass.Medium it >= WindowWidthSizeClass.Medium
} ?: false } ?: false
if (isTablet) { if (isTablet) {
TabletLayout( TabletLayout(
navController = navController, navigator = navigator,
navHostViewModel = navHostViewModel, navHostViewModel = navHostViewModel,
startDestination = startDestination,
isInAuthFlow = isInAuthFlow,
deepLinkUri = deepLinkUri, deepLinkUri = deepLinkUri,
onDeepLinkConsumed = onDeepLinkConsumed, onDeepLinkConsumed = onDeepLinkConsumed,
shareNavigationTrigger = shareNavigationTrigger, shareNavigationTrigger = shareNavigationTrigger,
@ -142,10 +124,8 @@ fun LibreChatNavHost(
) )
} else { } else {
PhoneLayout( PhoneLayout(
navController = navController, navigator = navigator,
navHostViewModel = navHostViewModel, navHostViewModel = navHostViewModel,
startDestination = startDestination,
isInAuthFlow = isInAuthFlow,
deepLinkUri = deepLinkUri, deepLinkUri = deepLinkUri,
onDeepLinkConsumed = onDeepLinkConsumed, onDeepLinkConsumed = onDeepLinkConsumed,
shareNavigationTrigger = shareNavigationTrigger, shareNavigationTrigger = shareNavigationTrigger,
@ -206,10 +186,8 @@ private fun VersionMismatchDialog(
@Composable @Composable
private fun PhoneLayout( private fun PhoneLayout(
navController: androidx.navigation.NavHostController, navigator: Navigator,
navHostViewModel: NavHostViewModel, navHostViewModel: NavHostViewModel,
startDestination: Any,
isInAuthFlow: Boolean,
deepLinkUri: Uri?, deepLinkUri: Uri?,
onDeepLinkConsumed: () -> Unit, onDeepLinkConsumed: () -> Unit,
shareNavigationTrigger: Int, shareNavigationTrigger: Int,
@ -217,7 +195,6 @@ private fun PhoneLayout(
) { ) {
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// Banner state only -- drawer state is collected inside DrawerContent itself
val banners by navHostViewModel.banners.collectAsStateWithLifecycle() val banners by navHostViewModel.banners.collectAsStateWithLifecycle()
val dismissedBannerIds by navHostViewModel.dismissedBannerIds.collectAsStateWithLifecycle() val dismissedBannerIds by navHostViewModel.dismissedBannerIds.collectAsStateWithLifecycle()
@ -227,7 +204,7 @@ private fun PhoneLayout(
if (uri.scheme == "librechat" && uri.host == "conversation") { if (uri.scheme == "librechat" && uri.host == "conversation") {
uri.lastPathSegment?.let { conversationId -> uri.lastPathSegment?.let { conversationId ->
if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) { if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) {
navController.navigateToChat(conversationId) navigator.navigateToChat(conversationId)
} else { } else {
Logger.w { "Ignoring deep link with invalid conversation ID: $conversationId" } Logger.w { "Ignoring deep link with invalid conversation ID: $conversationId" }
} }
@ -237,22 +214,14 @@ private fun PhoneLayout(
} }
} }
// Handle share intent: if already on a chat screen, let the active ChatViewModel // Handle share intent
// consume the shared content reactively. Only navigate to new chat when on a
// non-chat screen (e.g. settings, agents, files).
LaunchedEffect(shareNavigationTrigger) { LaunchedEffect(shareNavigationTrigger) {
if (shareNavigationTrigger > 0) { if (shareNavigationTrigger > 0) {
val currentDest = navController.currentBackStackEntry?.destination val currentRoute = navigator.currentRoute
val isOnChatScreen = currentDest.isRoute(Chat::class) || val isOnChatScreen = currentRoute is Chat || currentRoute is NewChat
currentDest.isRoute(NewChat::class)
if (!isOnChatScreen) { if (!isOnChatScreen) {
navController.navigate(NewChat) { navigator.navigateToTopLevel(NewChat)
popUpTo<ChatRoute> { inclusive = false }
launchSingleTop = true
}
} }
// 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( ModalNavigationDrawer(
drawerState = drawerState, drawerState = drawerState,
gesturesEnabled = !isInAuthFlow, gesturesEnabled = !navigator.isInAuthFlow,
drawerContent = { drawerContent = {
ModalDrawerSheet { ModalDrawerSheet {
SidebarScaffold( SidebarScaffold(
viewModel = navHostViewModel, viewModel = navHostViewModel,
onNewChat = { onNewChat = {
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
// Skip navigation if already on the new chat screen if (navigator.currentRoute !is NewChat) {
val currentDest = navController.currentBackStackEntry?.destination navigator.navigateToTopLevel(NewChat)
if (!currentDest.isRoute(NewChat::class)) {
navController.navigate(NewChat) {
popUpTo<ChatRoute> { inclusive = false }
launchSingleTop = true
}
} }
}, },
onConversationClick = { conversationId -> onConversationClick = { conversationId ->
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
navController.navigateToChat(conversationId) navigator.navigateToChat(conversationId)
}, },
onSettingsClick = { onSettingsClick = {
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
navController.navigate(SettingsTabbed) { navigator.navigate(SettingsTabbed)
launchSingleTop = true
}
}, },
onSettingsCategorySelected = { category -> onSettingsCategorySelected = { category ->
// Navigate to the settings sub-page but keep the drawer open navigator.navigate(category.toRoute())
navController.navigate(category.toRoute()) {
launchSingleTop = true
}
}, },
onAgentsClick = { onAgentsClick = {
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
navController.navigate(AgentMarketplace) { navigator.navigate(AgentMarketplace)
launchSingleTop = true
}
}, },
onFilesClick = { onFilesClick = {
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
navController.navigate(Files) { navigator.navigate(Files)
launchSingleTop = true
}
}, },
) )
} }
}, },
) { ) {
// No bottom bar -- drawer is the primary navigation
Column(modifier = modifier.fillMaxSize()) { Column(modifier = modifier.fillMaxSize()) {
if (!isInAuthFlow && banners.isNotEmpty()) { if (!navigator.isInAuthFlow && banners.isNotEmpty()) {
BannerDisplay( BannerDisplay(
banners = banners, banners = banners,
dismissedIds = dismissedBannerIds, dismissedIds = dismissedBannerIds,
@ -323,122 +277,100 @@ private fun PhoneLayout(
modifier = Modifier.padding(top = 4.dp), modifier = Modifier.padding(top = 4.dp),
) )
} }
NavHost( MainNavDisplay(
navController = navController, navigator = navigator,
startDestination = startDestination, navHostViewModel = navHostViewModel,
onOpenDrawer = { scope.launch { drawerState.open() } },
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
enterTransition = { )
slideIntoContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Start,
animationSpec = tween(300),
)
},
exitTransition = {
fadeOut(animationSpec = tween(150))
},
popEnterTransition = {
fadeIn(
initialAlpha = 0.5f,
animationSpec = tween(300, easing = LinearEasing),
) + scaleIn(
initialScale = 0.92f,
animationSpec = tween(300, easing = LinearEasing),
)
},
popExitTransition = {
slideOut(
targetOffset = { IntOffset((it.width * 0.15f).toInt(), 0) },
animationSpec = tween(300, easing = FastOutSlowInEasing),
) + scaleOut(
targetScale = 0.92f,
animationSpec = tween(300, easing = LinearEasing),
)
},
) {
authGraph(
navController = navController,
onAuthComplete = {
navHostViewModel.onAuthComplete()
navController.navigate(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 package com.garfiec.librechat.navigation
import androidx.savedstate.serialization.SavedStateConfiguration
import com.garfiec.librechat.feature.agents.navigation.agentsSerializersModule import com.garfiec.librechat.feature.agents.navigation.agentsSerializersModule
import com.garfiec.librechat.feature.auth.navigation.authSerializersModule import com.garfiec.librechat.feature.auth.navigation.authSerializersModule
import com.garfiec.librechat.feature.chat.navigation.chatSerializersModule import com.garfiec.librechat.feature.chat.navigation.chatSerializersModule
@ -12,7 +13,7 @@ import kotlinx.serialization.modules.plus
/** /**
* Combined [SerializersModule] for all navigation route types. * Combined [SerializersModule] for all navigation route types.
* Registers polymorphic serializers for each feature module's sealed route hierarchy. * 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 = val navigationSerializersModule: SerializersModule =
authSerializersModule + authSerializersModule +
@ -21,3 +22,11 @@ val navigationSerializersModule: SerializersModule =
agentsSerializersModule + agentsSerializersModule +
filesSerializersModule + filesSerializersModule +
settingsSerializersModule settingsSerializersModule
/**
* Nav 3 [SavedStateConfiguration] with polymorphic serialization for all route types.
* Required for KMP (iOS) since Nav 3 cannot use reflection-based serialization on non-JVM platforms.
*/
val navigationSavedStateConfig = SavedStateConfiguration {
serializersModule = navigationSerializersModule
}

View file

@ -0,0 +1,60 @@
package com.garfiec.librechat.navigation
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import com.garfiec.librechat.feature.auth.navigation.AuthRoute
import com.garfiec.librechat.feature.auth.navigation.ServerUrl
import com.garfiec.librechat.feature.chat.navigation.Chat
import com.garfiec.librechat.feature.chat.navigation.NewChat
/**
* Encapsulates all back stack mutations for Nav 3 navigation.
* Follows UDF: UI events Navigator back stack state NavDisplay recomposition.
*/
class Navigator(val backStack: NavBackStack<NavKey>) {
val currentRoute: NavKey? get() = backStack.lastOrNull()
val isInAuthFlow: Boolean get() = currentRoute is AuthRoute
/** Navigate forward to a route. */
fun navigate(route: NavKey) {
backStack.add(route)
}
/** Pop the top entry. Safe on empty stacks. */
fun goBack() {
backStack.removeLastOrNull()
}
/** Navigate to a chat, replacing any current chat on the stack. */
fun navigateToChat(conversationId: String) {
if (backStack.lastOrNull() is Chat) {
backStack.removeLastOrNull()
}
backStack.add(Chat(conversationId))
}
/** Navigate to a top-level destination, popping to root first. */
fun navigateToTopLevel(route: NavKey) {
while (backStack.size > 1) {
backStack.removeLastOrNull()
}
if (backStack.lastOrNull()?.let { it::class } != route::class) {
backStack.removeLastOrNull()
backStack.add(route)
}
}
/** Clear back stack and navigate to auth (session expiry / logout). */
fun navigateToAuth() {
backStack.clear()
backStack.add(ServerUrl)
}
/** Clear back stack and navigate to chat (auth complete). */
fun navigateToChat() {
backStack.clear()
backStack.add(NewChat)
}
}

View file

@ -2,18 +2,9 @@ package com.garfiec.librechat.navigation
import android.net.Uri import android.net.Uri
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideOut
import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -34,46 +25,19 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavDestination import androidx.navigation3.runtime.NavKey
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import kotlin.reflect.KClass
import com.garfiec.librechat.MainActivity import com.garfiec.librechat.MainActivity
import com.garfiec.librechat.core.ui.components.BannerDisplay import com.garfiec.librechat.core.ui.components.BannerDisplay
import com.garfiec.librechat.feature.agents.navigation.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.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.Chat
import com.garfiec.librechat.feature.chat.navigation.ChatRoute
import com.garfiec.librechat.feature.chat.navigation.NewChat 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.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.SettingsTabbed
import com.garfiec.librechat.feature.settings.navigation.SharedLinks
import com.garfiec.librechat.feature.settings.navigation.settingsGraph
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
/** Checks if this destination's route matches the given typed route class. */
private fun NavDestination?.isRoute(routeClass: KClass<*>): Boolean {
val qualifiedName = routeClass.qualifiedName ?: return false
return this?.route?.startsWith(qualifiedName) == true
}
private val SidebarWidth = 320.dp private val SidebarWidth = 320.dp
/** Velocity (px/s) above which a fling commits the gesture regardless of position. */ /** Velocity (px/s) above which a fling commits the gesture regardless of position. */
@ -81,10 +45,8 @@ private const val FLING_VELOCITY_THRESHOLD = 800f
@Composable @Composable
fun TabletLayout( fun TabletLayout(
navController: NavHostController, navigator: Navigator,
navHostViewModel: NavHostViewModel, navHostViewModel: NavHostViewModel,
startDestination: Any,
isInAuthFlow: Boolean,
deepLinkUri: Uri?, deepLinkUri: Uri?,
onDeepLinkConsumed: () -> Unit, onDeepLinkConsumed: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -95,8 +57,6 @@ fun TabletLayout(
val dismissedBannerIds by navHostViewModel.dismissedBannerIds.collectAsStateWithLifecycle() val dismissedBannerIds by navHostViewModel.dismissedBannerIds.collectAsStateWithLifecycle()
// Persisted sidebar state from DataStore -- single source of truth in the ViewModel. // Persisted sidebar state from DataStore -- single source of truth in the ViewModel.
// The ViewModel's StateFlow initial value is read synchronously from DataStore,
// so the first composition already has the correct persisted state (no flicker).
val isSidebarOpen by navHostViewModel.tabletSidebarOpen.collectAsStateWithLifecycle() val isSidebarOpen by navHostViewModel.tabletSidebarOpen.collectAsStateWithLifecycle()
// Whether swipe gesture is enabled (from settings) // Whether swipe gesture is enabled (from settings)
@ -106,7 +66,6 @@ fun TabletLayout(
val sidebarWidthPx = with(density) { SidebarWidth.toPx() } val sidebarWidthPx = with(density) { SidebarWidth.toPx() }
// Animatable tracks sidebar reveal in pixels: 0 = closed, sidebarWidthPx = open. // Animatable tracks sidebar reveal in pixels: 0 = closed, sidebarWidthPx = open.
// During a drag it follows the finger; on release it animates to 0 or sidebarWidthPx.
val sidebarOffset = remember { Animatable(if (isSidebarOpen) sidebarWidthPx else 0f) } val sidebarOffset = remember { Animatable(if (isSidebarOpen) sidebarWidthPx else 0f) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -121,7 +80,7 @@ fun TabletLayout(
if (uri.scheme == "librechat" && uri.host == "conversation") { if (uri.scheme == "librechat" && uri.host == "conversation") {
uri.lastPathSegment?.let { conversationId -> uri.lastPathSegment?.let { conversationId ->
if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) { if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) {
navController.navigateToChat(conversationId) navigator.navigateToChat(conversationId)
} else { } else {
Logger.w { "Ignoring deep link with invalid conversation ID: $conversationId" } Logger.w { "Ignoring deep link with invalid conversation ID: $conversationId" }
} }
@ -131,19 +90,13 @@ fun TabletLayout(
} }
} }
// Handle share intent: if already on a chat screen, let the active ChatViewModel // Handle share intent
// consume the shared content reactively. Only navigate to new chat when on a
// non-chat screen (e.g. settings, agents, files).
LaunchedEffect(shareNavigationTrigger) { LaunchedEffect(shareNavigationTrigger) {
if (shareNavigationTrigger > 0) { if (shareNavigationTrigger > 0) {
val currentDest = navController.currentBackStackEntry?.destination val currentRoute = navigator.currentRoute
val isOnChatScreen = currentDest.isRoute(Chat::class) || val isOnChatScreen = currentRoute is Chat || currentRoute is NewChat
currentDest.isRoute(NewChat::class)
if (!isOnChatScreen) { if (!isOnChatScreen) {
navController.navigate(NewChat) { navigator.navigateToTopLevel(NewChat)
popUpTo<ChatRoute> { inclusive = false }
launchSingleTop = true
}
} }
} }
} }
@ -163,7 +116,7 @@ fun TabletLayout(
} }
} }
if (!isInAuthFlow) { if (!navigator.isInAuthFlow) {
val swipeModifier = if (gestureEnabled) { val swipeModifier = if (gestureEnabled) {
Modifier.pointerInput(Unit) { Modifier.pointerInput(Unit) {
val velocityTracker = VelocityTracker() val velocityTracker = VelocityTracker()
@ -174,7 +127,6 @@ fun TabletLayout(
onDragEnd = { onDragEnd = {
val velocity = velocityTracker.calculateVelocity().x val velocity = velocityTracker.calculateVelocity().x
val currentOffset = sidebarOffset.value val currentOffset = sidebarOffset.value
// Decide target: fling velocity wins, otherwise use 50% threshold
val shouldOpen = when { val shouldOpen = when {
velocity > FLING_VELOCITY_THRESHOLD -> true velocity > FLING_VELOCITY_THRESHOLD -> true
velocity < -FLING_VELOCITY_THRESHOLD -> false velocity < -FLING_VELOCITY_THRESHOLD -> false
@ -192,7 +144,6 @@ fun TabletLayout(
} }
}, },
onDragCancel = { onDragCancel = {
// Snap back to the current committed state
val target = if (isSidebarOpen) sidebarWidthPx else 0f val target = if (isSidebarOpen) sidebarWidthPx else 0f
scope.launch { scope.launch {
sidebarOffset.animateTo( sidebarOffset.animateTo(
@ -229,37 +180,24 @@ fun TabletLayout(
SidebarScaffold( SidebarScaffold(
viewModel = navHostViewModel, viewModel = navHostViewModel,
onNewChat = { onNewChat = {
// Skip navigation if already on the new chat screen if (navigator.currentRoute !is NewChat) {
val currentDest = navController.currentBackStackEntry?.destination navigator.navigateToTopLevel(NewChat)
if (!currentDest.isRoute(NewChat::class)) {
navController.navigate(NewChat) {
popUpTo<ChatRoute> { inclusive = false }
launchSingleTop = true
}
} }
}, },
onConversationClick = { conversationId -> onConversationClick = { conversationId ->
navController.navigateToChat(conversationId) navigator.navigateToChat(conversationId)
}, },
onSettingsClick = { onSettingsClick = {
navController.navigate(SettingsTabbed) { navigator.navigate(SettingsTabbed)
launchSingleTop = true
}
}, },
onSettingsCategorySelected = { category -> onSettingsCategorySelected = { category ->
navController.navigate(category.toRoute()) { navigator.navigate(category.toRoute())
launchSingleTop = true
}
}, },
onAgentsClick = { onAgentsClick = {
navController.navigate(AgentMarketplace) { navigator.navigate(AgentMarketplace)
launchSingleTop = true
}
}, },
onFilesClick = { onFilesClick = {
navController.navigate(Files) { navigator.navigate(Files)
launchSingleTop = true
}
}, },
modifier = Modifier modifier = Modifier
.width(SidebarWidth) .width(SidebarWidth)
@ -271,9 +209,8 @@ fun TabletLayout(
} }
// Slot 1: Main content -- resizes to fill remaining space // Slot 1: Main content -- resizes to fill remaining space
MainContent( MainContent(
navController = navController, navigator = navigator,
navHostViewModel = navHostViewModel, navHostViewModel = navHostViewModel,
startDestination = startDestination,
isInAuthFlow = false, isInAuthFlow = false,
banners = banners, banners = banners,
dismissedBannerIds = dismissedBannerIds, dismissedBannerIds = dismissedBannerIds,
@ -287,29 +224,24 @@ fun TabletLayout(
val sidebarPx = SidebarWidth.roundToPx() val sidebarPx = SidebarWidth.roundToPx()
val animatedPx = sidebarOffset.value.toInt() val animatedPx = sidebarOffset.value.toInt()
// Sidebar: always measured at full 320dp width
val sidebarPlaceable = measurables[0].measure( val sidebarPlaceable = measurables[0].measure(
constraints.copy(minWidth = sidebarPx, maxWidth = sidebarPx), constraints.copy(minWidth = sidebarPx, maxWidth = sidebarPx),
) )
// Main content: resizes to fill screen minus the visible sidebar portion
val mainWidth = (constraints.maxWidth - animatedPx).coerceAtLeast(0) val mainWidth = (constraints.maxWidth - animatedPx).coerceAtLeast(0)
val mainPlaceable = measurables[1].measure( val mainPlaceable = measurables[1].measure(
constraints.copy(minWidth = mainWidth, maxWidth = mainWidth), constraints.copy(minWidth = mainWidth, maxWidth = mainWidth),
) )
layout(constraints.maxWidth, constraints.maxHeight) { layout(constraints.maxWidth, constraints.maxHeight) {
// Sidebar slides: -320px (off-screen) -> 0px (fully visible)
sidebarPlaceable.placeRelative(animatedPx - sidebarPx, 0) sidebarPlaceable.placeRelative(animatedPx - sidebarPx, 0)
// Main content starts right after the visible sidebar portion
mainPlaceable.placeRelative(animatedPx, 0) mainPlaceable.placeRelative(animatedPx, 0)
} }
} }
} else { } else {
// Auth flow -- no sidebar, just main content // Auth flow -- no sidebar, just main content
MainContent( MainContent(
navController = navController, navigator = navigator,
navHostViewModel = navHostViewModel, navHostViewModel = navHostViewModel,
startDestination = startDestination,
isInAuthFlow = true, isInAuthFlow = true,
banners = banners, banners = banners,
dismissedBannerIds = dismissedBannerIds, dismissedBannerIds = dismissedBannerIds,
@ -321,9 +253,8 @@ fun TabletLayout(
@Composable @Composable
private fun MainContent( private fun MainContent(
navController: NavHostController, navigator: Navigator,
navHostViewModel: NavHostViewModel, navHostViewModel: NavHostViewModel,
startDestination: Any,
isInAuthFlow: Boolean, isInAuthFlow: Boolean,
banners: List<com.garfiec.librechat.core.model.Banner>, banners: List<com.garfiec.librechat.core.model.Banner>,
dismissedBannerIds: Set<String>, dismissedBannerIds: Set<String>,
@ -339,119 +270,11 @@ private fun MainContent(
modifier = Modifier.padding(top = 4.dp), modifier = Modifier.padding(top = 4.dp),
) )
} }
NavHost( MainNavDisplay(
navController = navController, navigator = navigator,
startDestination = startDestination, navHostViewModel = navHostViewModel,
onOpenDrawer = onToggleDrawer,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
enterTransition = { )
slideIntoContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Start,
animationSpec = tween(300),
)
},
exitTransition = {
fadeOut(animationSpec = tween(150))
},
popEnterTransition = {
fadeIn(
initialAlpha = 0.5f,
animationSpec = tween(300, easing = LinearEasing),
) + scaleIn(
initialScale = 0.92f,
animationSpec = tween(300, easing = LinearEasing),
)
},
popExitTransition = {
slideOut(
targetOffset = { IntOffset((it.width * 0.15f).toInt(), 0) },
animationSpec = tween(300, easing = FastOutSlowInEasing),
) + scaleOut(
targetScale = 0.92f,
animationSpec = tween(300, easing = LinearEasing),
)
},
) {
authGraph(
navController = navController,
onAuthComplete = {
navHostViewModel.onAuthComplete()
navController.navigate(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 that apply consistent Gradle configuration across all modules.
## Convention Plugins (7 total) ## Convention Plugins (13 total)
### Android
| Plugin ID | Class | What it does | | Plugin ID | Class | What it does |
|-----------|-------|-------------| |-----------|-------|-------------|
| `librechat.mobile.application` | `AndroidApplicationConventionPlugin` | AGP application plugin, compileSdk 35, minSdk 26, JDK 17 | | `librechat.mobile.application` | `AndroidApplicationConventionPlugin` | AGP application plugin, compileSdk 36, minSdk 26, JDK 17 |
| `librechat.mobile.library` | `AndroidLibraryConventionPlugin` | AGP library plugin, same SDK/JDK config | | `librechat.mobile.library` | `AndroidLibraryConventionPlugin` | AGP library plugin, same SDK/JDK config |
| `librechat.mobile.compose` | `AndroidComposeConventionPlugin` | Compose compiler, Compose BOM, Material 3 | | `librechat.mobile.compose` | `AndroidComposeConventionPlugin` | Compose compiler, Compose BOM, Material 3 |
| `librechat.mobile.koin` | `AndroidKoinConventionPlugin` | Koin core + Android dependencies | | `librechat.mobile.koin` | `AndroidKoinConventionPlugin` | Koin core + Android dependencies |
| `librechat.mobile.feature` | `AndroidFeatureConventionPlugin` | Auto-applies: library + compose + koin + serialization | | `librechat.mobile.feature` | `AndroidFeatureConventionPlugin` | Auto-applies: library + compose + koin + serialization + Nav 3 |
| `librechat.mobile.room` | `AndroidRoomConventionPlugin` | Room + KSP, schema export config | | `librechat.mobile.room` | `AndroidRoomConventionPlugin` | Room + KSP, schema export config |
| `librechat.mobile.detekt` | `AndroidDetektConventionPlugin` | Detekt static analysis |
### KMP
| Plugin ID | Class | What it does |
|-----------|-------|-------------|
| `librechat.kmp.library` | `KmpLibraryConventionPlugin` | KMP library setup (Android + iOS targets) |
| `librechat.kmp.compose` | `KmpComposeConventionPlugin` | Compose Multiplatform + Material 3 |
| `librechat.kmp.koin` | `KmpKoinConventionPlugin` | Koin multiplatform dependencies |
| `librechat.kmp.feature` | `KmpFeatureConventionPlugin` | Auto-applies: kmp library + compose + koin + serialization + Nav 3 |
| `librechat.kmp.room` | `KmpRoomConventionPlugin` | Room multiplatform + KSP |
### Shared
| Plugin ID | Class | What it does |
|-----------|-------|-------------|
| `librechat.kotlin.serialization` | `KotlinSerializationConventionPlugin` | Kotlinx Serialization plugin + JSON dependency | | `librechat.kotlin.serialization` | `KotlinSerializationConventionPlugin` | Kotlinx Serialization plugin + JSON dependency |
## Critical: apply() Signature ## Critical: apply() Signature
@ -32,6 +47,6 @@ build.gradle.kts files. Use `libs.` accessors (e.g., `libs.koin.android`, `libs.
## Feature Module Convention ## Feature Module Convention
Feature modules use `id("librechat.mobile.feature")` which auto-applies library, compose, Feature modules use `id("librechat.kmp.feature")` (KMP) or `id("librechat.mobile.feature")` (Android-only) which auto-applies library, compose,
koin, and serialization. They only need to add feature-specific dependencies. koin, serialization, and Nav 3. They only need to add feature-specific dependencies.
Feature modules depend on `:core:*` only, never on other feature modules. Feature modules depend on `:core:*` only, never on other feature modules.

View file

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

View file

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

View file

@ -73,7 +73,7 @@ These rules apply to ALL composables across feature modules, not just core:ui.
3. **Mark UI state classes `@Immutable`.** This lets the Compose compiler skip recomposition when the reference hasn't changed. All fields must be `val` with stable types. 3. **Mark UI state classes `@Immutable`.** This lets the Compose compiler skip recomposition when the reference hasn't changed. All fields must be `val` with stable types.
4. **Collect state at the narrowest scope.** If only `DrawerContent` uses drawer state, collect inside `DrawerContent` — not in the parent `PhoneLayout` which also owns the NavHost. State changes should only recompose the composable that reads them. 4. **Collect state at the narrowest scope.** If only `DrawerContent` uses drawer state, collect inside `DrawerContent` — not in the parent `PhoneLayout` which also owns the NavDisplay. State changes should only recompose the composable that reads them.
5. **Use `SharingStarted.Eagerly`** for UI state that should be ready before the composable subscribes (avoids empty→populated two-phase render jank on first frame). 5. **Use `SharingStarted.Eagerly`** for UI state that should be ready before the composable subscribes (avoids empty→populated two-phase render jank on first frame).

View file

@ -1,72 +0,0 @@
package com.garfiec.librechat.core.ui.components
import android.os.Build
import android.view.RoundedCorner
import androidx.compose.animation.EnterExitState
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.Transition
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp
private const val FALLBACK_CORNER_RADIUS_DP = 24f
/**
* Wraps screen content to apply rounded corners during navigation transitions.
* Pass the [transition] from AnimatedVisibilityScope inside a composable() block.
*/
@Composable
actual fun ScreenTransitionWrapper(
transition: Transition<EnterExitState>,
modifier: Modifier,
content: @Composable () -> Unit,
) {
val screenCornerRadiusDp = rememberScreenCornerRadiusDp()
val cornerRadius by transition.animateFloat(
transitionSpec = { tween(300, easing = LinearEasing) },
label = "screenCorner",
) { state ->
when (state) {
EnterExitState.PreEnter -> screenCornerRadiusDp
EnterExitState.Visible -> 0f
EnterExitState.PostExit -> screenCornerRadiusDp
}
}
Box(
modifier = modifier
.fillMaxSize()
.clip(RoundedCornerShape(cornerRadius.dp)),
) {
content()
}
}
@Composable
private fun rememberScreenCornerRadiusDp(): Float {
val view = LocalView.current
val density = LocalDensity.current
return remember(view, density) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val insets = view.rootWindowInsets
val topLeft = insets?.getRoundedCorner(RoundedCorner.POSITION_TOP_LEFT)
if (topLeft != null && topLeft.radius > 0) {
with(density) { topLeft.radius.toDp().value }
} else {
FALLBACK_CORNER_RADIUS_DP
}
} else {
FALLBACK_CORNER_RADIUS_DP
}
}
}

View file

@ -1,18 +0,0 @@
package com.garfiec.librechat.core.ui.components
import androidx.compose.animation.EnterExitState
import androidx.compose.animation.core.Transition
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
/**
* Wraps screen content to apply rounded corners during navigation transitions.
* On Android, reads the device screen corner radius for a polished effect.
* On iOS, renders content directly (no transition animation needed).
*/
@Composable
expect fun ScreenTransitionWrapper(
transition: Transition<EnterExitState>,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
)

View file

@ -1,38 +0,0 @@
package com.garfiec.librechat.core.ui.components
import androidx.compose.animation.EnterExitState
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.Transition
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
@Composable
actual fun ScreenTransitionWrapper(
transition: Transition<EnterExitState>,
modifier: Modifier,
content: @Composable () -> Unit,
) {
val alpha by transition.animateFloat(
transitionSpec = { tween(300, easing = LinearEasing) },
label = "screenAlpha",
) { state ->
when (state) {
EnterExitState.PreEnter -> 0f
EnterExitState.Visible -> 1f
EnterExitState.PostExit -> 0f
}
}
Box(
modifier = modifier
.fillMaxSize()
.graphicsLayer { this.alpha = alpha },
) {
content()
}
}

View file

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

View file

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

View file

@ -1,7 +1,7 @@
package com.garfiec.librechat.feature.agents.navigation package com.garfiec.librechat.feature.agents.navigation
import androidx.navigation.NavGraphBuilder import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation.compose.composable import androidx.navigation3.runtime.NavKey
import com.garfiec.librechat.feature.agents.screen.AgentDetailScreen import com.garfiec.librechat.feature.agents.screen.AgentDetailScreen
import com.garfiec.librechat.feature.agents.screen.AgentEditorScreen import com.garfiec.librechat.feature.agents.screen.AgentEditorScreen
import com.garfiec.librechat.feature.agents.screen.AgentMarketplaceScreen import com.garfiec.librechat.feature.agents.screen.AgentMarketplaceScreen
@ -10,51 +10,52 @@ import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic import kotlinx.serialization.modules.polymorphic
import kotlinx.serialization.modules.subclass import kotlinx.serialization.modules.subclass
@Serializable sealed interface AgentsRoute @Serializable sealed interface AgentsRoute : NavKey
@Serializable data object AgentMarketplace : AgentsRoute @Serializable data object AgentMarketplace : AgentsRoute
@Serializable data class AgentDetail(val agentId: String) : AgentsRoute @Serializable data class AgentDetail(val agentId: String) : AgentsRoute
@Serializable data object AgentEditorCreate : AgentsRoute @Serializable data object AgentEditorCreate : AgentsRoute
@Serializable data class AgentEditorEdit(val agentId: String) : AgentsRoute @Serializable data class AgentEditorEdit(val agentId: String) : AgentsRoute
fun NavGraphBuilder.agentsGraph( fun EntryProviderScope<NavKey>.agentsEntries(
onAgentClick: (String) -> Unit, onNavigate: (NavKey) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
onStartChat: (String) -> Unit, onStartChat: (String) -> Unit,
onCreateAgent: () -> Unit,
onEditAgent: (String) -> Unit,
) { ) {
composable<AgentMarketplace> { entry<AgentMarketplace> {
AgentMarketplaceScreen( AgentMarketplaceScreen(
onAgentClick = onAgentClick, onAgentClick = { agentId -> onNavigate(AgentDetail(agentId = agentId)) },
onCreateAgent = onCreateAgent, onCreateAgent = { onNavigate(AgentEditorCreate) },
onBack = onBack, onBack = onBack,
) )
} }
composable<AgentDetail> { entry<AgentDetail> { key ->
AgentDetailScreen( AgentDetailScreen(
onBack = onBack, onBack = onBack,
onStartChat = onStartChat, onStartChat = onStartChat,
onEdit = onEditAgent, onEdit = { agentId -> onNavigate(AgentEditorEdit(agentId = agentId)) },
onDuplicated = onAgentClick, onDuplicated = { agentId -> onNavigate(AgentDetail(agentId = agentId)) },
agentId = key.agentId,
) )
} }
composable<AgentEditorCreate> { entry<AgentEditorCreate> {
AgentEditorScreen( AgentEditorScreen(
onBack = onBack, onBack = onBack,
onSaved = { agentId -> onAgentClick(agentId) }, onSaved = { agentId -> onNavigate(AgentDetail(agentId = agentId)) },
agentId = null,
) )
} }
composable<AgentEditorEdit> { entry<AgentEditorEdit> { key ->
AgentEditorScreen( AgentEditorScreen(
onBack = onBack, onBack = onBack,
onSaved = { agentId -> onAgentClick(agentId) }, onSaved = { agentId -> onNavigate(AgentDetail(agentId = agentId)) },
agentId = key.agentId,
) )
} }
} }
val agentsSerializersModule = SerializersModule { val agentsSerializersModule = SerializersModule {
polymorphic(AgentsRoute::class) { polymorphic(NavKey::class) {
subclass(AgentMarketplace::class, AgentMarketplace.serializer()) subclass(AgentMarketplace::class, AgentMarketplace.serializer())
subclass(AgentDetail::class, AgentDetail.serializer()) subclass(AgentDetail::class, AgentDetail.serializer())
subclass(AgentEditorCreate::class, AgentEditorCreate.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.AgentDetailEvent
import com.garfiec.librechat.feature.agents.viewmodel.AgentDetailViewModel import com.garfiec.librechat.feature.agents.viewmodel.AgentDetailViewModel
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@ -65,8 +66,9 @@ fun AgentDetailScreen(
onStartChat: (String) -> Unit, onStartChat: (String) -> Unit,
onEdit: (String) -> Unit, onEdit: (String) -> Unit,
onDuplicated: (String) -> Unit, onDuplicated: (String) -> Unit,
agentId: String? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: AgentDetailViewModel = koinViewModel(), viewModel: AgentDetailViewModel = koinViewModel { parametersOf(agentId) },
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,10 +1,7 @@
package com.garfiec.librechat.feature.auth.navigation package com.garfiec.librechat.feature.auth.navigation
import androidx.navigation.NavController import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation.NavGraphBuilder import androidx.navigation3.runtime.NavKey
import androidx.navigation.compose.composable
import androidx.navigation.navigation
import com.garfiec.librechat.core.ui.components.ScreenTransitionWrapper
import com.garfiec.librechat.feature.auth.screen.ForgotPasswordScreen import com.garfiec.librechat.feature.auth.screen.ForgotPasswordScreen
import com.garfiec.librechat.feature.auth.screen.LoginScreen import com.garfiec.librechat.feature.auth.screen.LoginScreen
import com.garfiec.librechat.feature.auth.screen.RegisterScreen import com.garfiec.librechat.feature.auth.screen.RegisterScreen
@ -18,7 +15,7 @@ import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic import kotlinx.serialization.modules.polymorphic
import kotlinx.serialization.modules.subclass import kotlinx.serialization.modules.subclass
@Serializable sealed interface AuthRoute @Serializable sealed interface AuthRoute : NavKey
@Serializable data object ServerUrl : AuthRoute @Serializable data object ServerUrl : AuthRoute
@Serializable data object Login : AuthRoute @Serializable data object Login : AuthRoute
@ -29,90 +26,69 @@ import kotlinx.serialization.modules.subclass
@Serializable data object Terms : AuthRoute @Serializable data object Terms : AuthRoute
@Serializable data class ResetPassword(val userId: String, val token: String) : AuthRoute @Serializable data class ResetPassword(val userId: String, val token: String) : AuthRoute
fun NavController.navigateToVerifyEmail(email: String) { fun EntryProviderScope<NavKey>.authEntries(
navigate(VerifyEmail(email = email)) onNavigate: (NavKey) -> Unit,
} onBack: () -> Unit,
fun NavController.navigateToResetPassword(userId: String, token: String) {
navigate(ResetPassword(userId = userId, token = token))
}
fun NavGraphBuilder.authGraph(
navController: NavController,
onAuthComplete: () -> Unit, onAuthComplete: () -> Unit,
) { ) {
navigation<AuthRoute>(startDestination = ServerUrl::class) { entry<ServerUrl> {
composable<ServerUrl> { ServerUrlScreen(
ScreenTransitionWrapper(transition) { onServerValidated = { onNavigate(Login) },
ServerUrlScreen( )
onServerValidated = { navController.navigate(Login) }, }
) entry<Login> {
} LoginScreen(
} onLoginSuccess = onAuthComplete,
composable<Login> { onNavigateToRegister = { onNavigate(Register) },
ScreenTransitionWrapper(transition) { onNavigateToForgotPassword = { onNavigate(ForgotPassword) },
LoginScreen( onNavigateToTwoFactor = { tempToken ->
onLoginSuccess = onAuthComplete, onNavigate(TwoFactor(tempToken = tempToken))
onNavigateToRegister = { navController.navigate(Register) }, },
onNavigateToForgotPassword = { navController.navigate(ForgotPassword) }, )
onNavigateToTwoFactor = { tempToken -> }
navController.navigate(TwoFactor(tempToken = tempToken)) entry<Register> {
}, RegisterScreen(
) onRegistered = onBack,
} onNavigateToLogin = onBack,
} )
composable<Register> { }
ScreenTransitionWrapper(transition) { entry<ForgotPassword> {
RegisterScreen( ForgotPasswordScreen(
onRegistered = { navController.popBackStack<Login>(inclusive = false) }, onBack = onBack,
onNavigateToLogin = { navController.popBackStack() }, )
) }
} entry<TwoFactor> { key ->
} TwoFactorScreen(
composable<ForgotPassword> { onVerified = onAuthComplete,
ScreenTransitionWrapper(transition) { onBack = onBack,
ForgotPasswordScreen( tempToken = key.tempToken,
onBack = { navController.popBackStack() }, )
) }
} entry<VerifyEmail> { key ->
} VerifyEmailScreen(
composable<TwoFactor> { onVerified = onBack,
ScreenTransitionWrapper(transition) { onBack = onBack,
TwoFactorScreen( email = key.email,
onVerified = onAuthComplete, )
onBack = { navController.popBackStack() }, }
) entry<Terms> {
} TermsScreen(
} onAccepted = onAuthComplete,
composable<VerifyEmail> { onBack = onBack,
ScreenTransitionWrapper(transition) { )
VerifyEmailScreen( }
onVerified = { navController.popBackStack<Login>(inclusive = false) }, entry<ResetPassword> { key ->
onBack = { navController.popBackStack() }, ResetPasswordScreen(
) onResetComplete = onBack,
} onBack = onBack,
} userId = key.userId,
composable<Terms> { token = key.token,
ScreenTransitionWrapper(transition) { )
TermsScreen(
onAccepted = onAuthComplete,
onBack = { navController.popBackStack() },
)
}
}
composable<ResetPassword> {
ScreenTransitionWrapper(transition) {
ResetPasswordScreen(
onResetComplete = { navController.popBackStack<Login>(inclusive = false) },
onBack = { navController.popBackStack() },
)
}
}
} }
} }
val authSerializersModule = SerializersModule { val authSerializersModule = SerializersModule {
polymorphic(AuthRoute::class) { polymorphic(NavKey::class) {
subclass(ServerUrl::class, ServerUrl.serializer()) subclass(ServerUrl::class, ServerUrl.serializer())
subclass(Login::class, Login.serializer()) subclass(Login::class, Login.serializer())
subclass(Register::class, Register.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 librechat_mobile.feature.auth.generated.resources.*
import com.garfiec.librechat.feature.auth.viewmodel.ResetPasswordViewModel import com.garfiec.librechat.feature.auth.viewmodel.ResetPasswordViewModel
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ResetPasswordScreen( fun ResetPasswordScreen(
onResetComplete: () -> Unit, onResetComplete: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
userId: String? = null,
token: String? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: ResetPasswordViewModel = koinViewModel(), viewModel: ResetPasswordViewModel = koinViewModel { parametersOf(userId, token) },
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,13 +3,20 @@ package com.garfiec.librechat.feature.chat.di
import com.garfiec.librechat.feature.chat.prompts.PromptEditorViewModel import com.garfiec.librechat.feature.chat.prompts.PromptEditorViewModel
import com.garfiec.librechat.feature.chat.prompts.PromptsViewModel import com.garfiec.librechat.feature.chat.prompts.PromptsViewModel
import org.koin.core.module.Module import org.koin.core.module.Module
import org.koin.core.module.dsl.viewModel
import org.koin.core.module.dsl.viewModelOf import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module import org.koin.dsl.module
val chatModule = module { val chatModule = module {
includes(chatPlatformModule) includes(chatPlatformModule)
viewModelOf(::PromptsViewModel) viewModelOf(::PromptsViewModel)
viewModelOf(::PromptEditorViewModel) viewModel { params ->
PromptEditorViewModel(
promptRepository = get(),
savedStateHandle = get(),
initialGroupId = params.getOrNull(),
)
}
} }
expect val chatPlatformModule: Module expect val chatPlatformModule: Module

View file

@ -1,13 +1,7 @@
package com.garfiec.librechat.feature.chat.navigation package com.garfiec.librechat.feature.chat.navigation
import androidx.compose.animation.EnterTransition import androidx.navigation3.runtime.EntryProviderScope
import androidx.compose.animation.ExitTransition import androidx.navigation3.runtime.NavKey
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.navigation
import kotlin.reflect.KClass
import com.garfiec.librechat.feature.chat.prompts.PromptEditorScreen import com.garfiec.librechat.feature.chat.prompts.PromptEditorScreen
import com.garfiec.librechat.feature.chat.prompts.PromptsLibraryScreen import com.garfiec.librechat.feature.chat.prompts.PromptsLibraryScreen
import com.garfiec.librechat.feature.chat.screen.ChatScreen import com.garfiec.librechat.feature.chat.screen.ChatScreen
@ -17,96 +11,56 @@ import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic import kotlinx.serialization.modules.polymorphic
import kotlinx.serialization.modules.subclass import kotlinx.serialization.modules.subclass
@Serializable sealed interface ChatRoute @Serializable sealed interface ChatRoute : NavKey
@Serializable data object NewChat : ChatRoute @Serializable data object NewChat : ChatRoute
@Serializable data class Chat(val conversationId: String? = null) : ChatRoute @Serializable data class Chat(val conversationId: String? = null) : ChatRoute
@Serializable data object PromptsLibrary : ChatRoute @Serializable data object PromptsLibrary : ChatRoute
@Serializable data class PromptEditor(val groupId: String? = null) : ChatRoute @Serializable data class PromptEditor(val groupId: String? = null) : ChatRoute
/** Checks if this destination's route matches the given typed route class. */ fun EntryProviderScope<NavKey>.chatEntries(
internal fun NavDestination?.isRoute(routeClass: KClass<*>): Boolean { onNavigate: (NavKey) -> Unit,
val qualifiedName = routeClass.qualifiedName ?: return false onBack: () -> Unit,
return this?.route?.startsWith(qualifiedName) == true onNavigateToChat: (String) -> Unit,
}
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,
onOpenDrawer: (() -> Unit)? = null, onOpenDrawer: (() -> Unit)? = null,
) { ) {
navigation<ChatRoute>(startDestination = NewChat::class) { entry<NewChat> {
composable<NewChat>( NewChatScreen(
enterTransition = { EnterTransition.None }, onConversationStarted = { conversationId ->
exitTransition = { ExitTransition.None }, onNavigateToChat(conversationId)
popEnterTransition = { null }, },
popExitTransition = { null }, onOpenDrawer = onOpenDrawer,
) { onNavigateToPromptsLibrary = { onNavigate(PromptsLibrary) },
NewChatScreen( )
onConversationStarted = { conversationId -> }
navController.navigateToChat(conversationId) entry<Chat> { key ->
}, ChatScreen(
onOpenDrawer = onOpenDrawer, conversationId = key.conversationId,
onNavigateToPromptsLibrary = { navController.navigateToPromptsLibrary() }, onOpenDrawer = onOpenDrawer,
) onNavigateToPromptsLibrary = { onNavigate(PromptsLibrary) },
} onNavigateBack = onBack,
composable<Chat>( onNavigateToConversation = { conversationId -> onNavigateToChat(conversationId) },
enterTransition = { EnterTransition.None }, )
exitTransition = { ExitTransition.None }, }
popEnterTransition = { null }, entry<PromptsLibrary> {
popExitTransition = { null }, PromptsLibraryScreen(
) { _ -> onNavigateBack = onBack,
ChatScreen( onUseInChat = { _ -> onBack() },
onOpenDrawer = onOpenDrawer, onNavigateToEditor = { groupId ->
onNavigateToPromptsLibrary = { navController.navigateToPromptsLibrary() }, onNavigate(PromptEditor(groupId = groupId))
onNavigateBack = { navController.popBackStack() }, },
onNavigateToConversation = { conversationId -> navController.navigateToChat(conversationId) }, )
) }
} entry<PromptEditor> { key ->
composable<PromptsLibrary> { PromptEditorScreen(
PromptsLibraryScreen( onBack = onBack,
onNavigateBack = { navController.popBackStack() }, groupId = key.groupId,
onUseInChat = { promptText -> )
navController.popBackStack()
},
onNavigateToEditor = { groupId ->
navController.navigateToPromptEditor(groupId)
},
)
}
composable<PromptEditor> {
PromptEditorScreen(
onBack = { navController.popBackStack() },
)
}
} }
} }
val chatSerializersModule = SerializersModule { val chatSerializersModule = SerializersModule {
polymorphic(ChatRoute::class) { polymorphic(NavKey::class) {
subclass(NewChat::class, NewChat.serializer()) subclass(NewChat::class, NewChat.serializer())
subclass(Chat::class, Chat.serializer()) subclass(Chat::class, Chat.serializer())
subclass(PromptsLibrary::class, PromptsLibrary.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.Res
import librechat_mobile.feature.chat.generated.resources.* import librechat_mobile.feature.chat.generated.resources.*
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PromptEditorScreen( fun PromptEditorScreen(
onBack: () -> Unit, onBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: PromptEditorViewModel = koinViewModel(), groupId: String? = null,
viewModel: PromptEditorViewModel = koinViewModel { parametersOf(groupId) },
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,8 +2,8 @@ package com.garfiec.librechat.feature.settings.navigation
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavGraphBuilder import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation.compose.composable import androidx.navigation3.runtime.NavKey
import com.garfiec.librechat.feature.settings.screen.AccountSettingsScreen import com.garfiec.librechat.feature.settings.screen.AccountSettingsScreen
import com.garfiec.librechat.feature.settings.screen.ApiKeysScreen import com.garfiec.librechat.feature.settings.screen.ApiKeysScreen
import com.garfiec.librechat.feature.settings.screen.ChatSettingsScreen import com.garfiec.librechat.feature.settings.screen.ChatSettingsScreen
@ -19,7 +19,7 @@ import kotlinx.serialization.modules.polymorphic
import kotlinx.serialization.modules.subclass import kotlinx.serialization.modules.subclass
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@Serializable sealed interface SettingsRoute @Serializable sealed interface SettingsRoute : NavKey
@Serializable data object SettingsTabbed : SettingsRoute @Serializable data object SettingsTabbed : SettingsRoute
@Serializable data object SettingsGeneral : SettingsRoute @Serializable data object SettingsGeneral : SettingsRoute
@ -30,54 +30,48 @@ import org.koin.compose.viewmodel.koinViewModel
@Serializable data object PresetManager : SettingsRoute @Serializable data object PresetManager : SettingsRoute
@Serializable data object ApiKeys : SettingsRoute @Serializable data object ApiKeys : SettingsRoute
fun NavGraphBuilder.settingsGraph( fun EntryProviderScope<NavKey>.settingsEntries(
onNavigate: (NavKey) -> Unit,
onBack: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
onNavigateBack: () -> Unit,
onNavigateToArchived: () -> Unit = {}, onNavigateToArchived: () -> Unit = {},
onNavigateToSharedLinks: () -> Unit = {},
onNavigateBackFromSharedLinks: () -> Unit = {},
onNavigateToPresets: () -> Unit = {},
onNavigateBackFromPresets: () -> Unit = {},
onNavigateToApiKeys: () -> Unit = {},
onNavigateBackFromApiKeys: () -> Unit = {},
) { ) {
composable<SettingsTabbed> { entry<SettingsTabbed> {
TabbedSettingsScreen( TabbedSettingsScreen(
onNavigateBack = onNavigateBack, onNavigateBack = onBack,
onLogout = onLogout, onLogout = onLogout,
onNavigateToArchived = onNavigateToArchived, onNavigateToArchived = onNavigateToArchived,
onNavigateToSharedLinks = onNavigateToSharedLinks, onNavigateToSharedLinks = { onNavigate(SharedLinks) },
onNavigateToPresets = onNavigateToPresets, onNavigateToPresets = { onNavigate(PresetManager) },
onNavigateToApiKeys = onNavigateToApiKeys, onNavigateToApiKeys = { onNavigate(ApiKeys) },
) )
} }
entry<SettingsGeneral> {
composable<SettingsGeneral> {
GeneralSettingsScreen( GeneralSettingsScreen(
onNavigateBack = onNavigateBack, onNavigateBack = onBack,
) )
} }
composable<SettingsChat> { entry<SettingsChat> {
ChatSettingsScreen( ChatSettingsScreen(
onNavigateBack = onNavigateBack, onNavigateBack = onBack,
onNavigateToPresets = onNavigateToPresets, onNavigateToPresets = { onNavigate(PresetManager) },
) )
} }
composable<SettingsAccount> { entry<SettingsAccount> {
AccountSettingsScreen( AccountSettingsScreen(
onLogout = onLogout, onLogout = onLogout,
onNavigateBack = onNavigateBack, onNavigateBack = onBack,
onNavigateToApiKeys = onNavigateToApiKeys, onNavigateToApiKeys = { onNavigate(ApiKeys) },
) )
} }
composable<SettingsData> { entry<SettingsData> {
DataSettingsScreen( DataSettingsScreen(
onNavigateBack = onNavigateBack, onNavigateBack = onBack,
onNavigateToArchived = onNavigateToArchived, onNavigateToArchived = onNavigateToArchived,
onNavigateToSharedLinks = onNavigateToSharedLinks, onNavigateToSharedLinks = { onNavigate(SharedLinks) },
) )
} }
composable<SharedLinks> { entry<SharedLinks> {
val viewModel: SettingsViewModel = koinViewModel() val viewModel: SettingsViewModel = koinViewModel()
val uiState = viewModel.uiState.collectAsStateWithLifecycle() val uiState = viewModel.uiState.collectAsStateWithLifecycle()
@ -93,23 +87,23 @@ fun NavGraphBuilder.settingsGraph(
onLoadMore = viewModel::loadMoreSharedLinks, onLoadMore = viewModel::loadMoreSharedLinks,
onToggleVisibility = viewModel::toggleSharedLinkVisibility, onToggleVisibility = viewModel::toggleSharedLinkVisibility,
onDelete = viewModel::deleteSharedLink, onDelete = viewModel::deleteSharedLink,
onNavigateBack = onNavigateBackFromSharedLinks, onNavigateBack = onBack,
) )
} }
composable<PresetManager> { entry<PresetManager> {
PresetManagerScreen( PresetManagerScreen(
onNavigateBack = onNavigateBackFromPresets, onNavigateBack = onBack,
) )
} }
composable<ApiKeys> { entry<ApiKeys> {
ApiKeysScreen( ApiKeysScreen(
onNavigateBack = onNavigateBackFromApiKeys, onNavigateBack = onBack,
) )
} }
} }
val settingsSerializersModule = SerializersModule { val settingsSerializersModule = SerializersModule {
polymorphic(SettingsRoute::class) { polymorphic(NavKey::class) {
subclass(SettingsTabbed::class, SettingsTabbed.serializer()) subclass(SettingsTabbed::class, SettingsTabbed.serializer())
subclass(SettingsGeneral::class, SettingsGeneral.serializer()) subclass(SettingsGeneral::class, SettingsGeneral.serializer())
subclass(SettingsChat::class, SettingsChat.serializer()) subclass(SettingsChat::class, SettingsChat.serializer())

View file

@ -7,10 +7,10 @@ compose-bom = "2025.05.00"
compose-multiplatform = "1.11.0-beta01" compose-multiplatform = "1.11.0-beta01"
material3 = "1.3.1" material3 = "1.3.1"
activity-compose = "1.10.1" activity-compose = "1.10.1"
navigation-compose = "2.9.2"
lifecycle = "2.9.0" lifecycle = "2.9.0"
lifecycle-kmp = "2.9.0" lifecycle-kmp = "2.9.0"
navigation-kmp = "2.9.2" navigation3-kmp = "1.0.0-alpha06"
lifecycle-viewmodel-nav3-kmp = "2.10.0-alpha05"
# DI # DI
koin = "4.2.0" koin = "4.2.0"
@ -82,14 +82,13 @@ compose-foundation = { group = "androidx.compose.foundation", name = "foundation
# Activity / Navigation # Activity / Navigation
activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" }
navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation-compose" }
# Lifecycle # Lifecycle
lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
lifecycle-runtime-compose-kmp = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle-kmp" } lifecycle-runtime-compose-kmp = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle-kmp" }
lifecycle-viewmodel-compose-kmp = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle-kmp" } lifecycle-viewmodel-compose-kmp = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle-kmp" }
navigation-compose-kmp = { group = "org.jetbrains.androidx.navigation", name = "navigation-compose", version.ref = "navigation-kmp" } navigation3-ui-kmp = { group = "org.jetbrains.androidx.navigation3", name = "navigation3-ui", version.ref = "navigation3-kmp" }
lifecycle-viewmodel-navigation3-kmp = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel-navigation3", version.ref = "lifecycle-viewmodel-nav3-kmp" }
# Koin # Koin
koin-bom = { group = "io.insert-koin", name = "koin-bom", version.ref = "koin" } koin-bom = { group = "io.insert-koin", name = "koin-bom", version.ref = "koin" }

View file

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

View file

@ -1,6 +1,5 @@
package com.garfiec.librechat.shared.navigation package com.garfiec.librechat.shared.navigation
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
@ -8,7 +7,9 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideOut import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@ -26,43 +27,36 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavDestination import androidx.navigation3.runtime.NavKey
import androidx.navigation.compose.NavHost import androidx.navigation3.runtime.entryProvider
import androidx.navigation.toRoute import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation3.ui.NavDisplay
import androidx.navigation.compose.rememberNavController import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import kotlin.reflect.KClass import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import com.garfiec.librechat.core.ui.components.BannerDisplay import com.garfiec.librechat.core.ui.components.BannerDisplay
import com.garfiec.librechat.feature.agents.navigation.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.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.AuthRoute
import com.garfiec.librechat.feature.auth.navigation.ServerUrl 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.Chat
import com.garfiec.librechat.feature.chat.navigation.ChatRoute
import com.garfiec.librechat.feature.chat.navigation.NewChat import com.garfiec.librechat.feature.chat.navigation.NewChat
import com.garfiec.librechat.feature.chat.navigation.chatGraph import com.garfiec.librechat.feature.chat.navigation.chatEntries
import com.garfiec.librechat.feature.chat.navigation.navigateToChat
import com.garfiec.librechat.feature.conversations.navigation.ArchivedConversations import com.garfiec.librechat.feature.conversations.navigation.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.Files
import com.garfiec.librechat.feature.files.navigation.filesGraph import com.garfiec.librechat.feature.files.navigation.filesEntries
import com.garfiec.librechat.feature.settings.navigation.ApiKeys
import com.garfiec.librechat.feature.settings.navigation.PresetManager
import com.garfiec.librechat.feature.settings.navigation.SettingsAccount import com.garfiec.librechat.feature.settings.navigation.SettingsAccount
import com.garfiec.librechat.feature.settings.navigation.SettingsChat import com.garfiec.librechat.feature.settings.navigation.SettingsChat
import com.garfiec.librechat.feature.settings.navigation.SettingsData import com.garfiec.librechat.feature.settings.navigation.SettingsData
import com.garfiec.librechat.feature.settings.navigation.SettingsGeneral import com.garfiec.librechat.feature.settings.navigation.SettingsGeneral
import com.garfiec.librechat.feature.settings.navigation.SettingsRoute import com.garfiec.librechat.feature.settings.navigation.SettingsRoute
import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed
import com.garfiec.librechat.feature.settings.navigation.SharedLinks import com.garfiec.librechat.feature.settings.navigation.memoriesEntry
import com.garfiec.librechat.feature.settings.navigation.settingsGraph import com.garfiec.librechat.feature.settings.navigation.mcpServersEntry
import com.garfiec.librechat.feature.settings.navigation.settingsEntries
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import librechat_mobile.shared.generated.resources.Res import librechat_mobile.shared.generated.resources.Res
import librechat_mobile.shared.generated.resources.dismiss import librechat_mobile.shared.generated.resources.dismiss
@ -72,12 +66,6 @@ import librechat_mobile.shared.generated.resources.version_mismatch_title
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
/** 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. */ /** Maps a [SettingsCategory] to its corresponding typed navigation route. */
fun SettingsCategory.toRoute(): SettingsRoute = when (this) { fun SettingsCategory.toRoute(): SettingsRoute = when (this) {
SettingsCategory.GENERAL -> SettingsGeneral SettingsCategory.GENERAL -> SettingsGeneral
@ -99,41 +87,35 @@ fun LibreChatNavHost(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
navHostViewModel: NavHostViewModel = koinViewModel(), navHostViewModel: NavHostViewModel = koinViewModel(),
) { ) {
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
val isLoggedIn by navHostViewModel.isLoggedIn.collectAsStateWithLifecycle() val isLoggedIn by navHostViewModel.isLoggedIn.collectAsStateWithLifecycle()
val isInAuthFlow = currentDestination.isRoute(AuthRoute::class) || // Stable start key — auth redirect handled via LaunchedEffect below.
currentDestination?.parent.isRoute(AuthRoute::class) val backStack = rememberNavBackStack(navigationSavedStateConfig, NewChat)
val navigator = Navigator(backStack)
// Redirect to auth if not logged in (on first composition only).
LaunchedEffect(Unit) {
if (!navHostViewModel.isLoggedIn.value) {
navigator.navigateToAuth()
}
}
// Track active conversation from nav back stack // Track active conversation from nav back stack
LaunchedEffect(navBackStackEntry) { LaunchedEffect(navigator.currentRoute) {
val conversationId = if (navBackStackEntry?.destination.isRoute(Chat::class)) { val conversationId = (navigator.currentRoute as? Chat)?.conversationId
navBackStackEntry?.toRoute<Chat>()?.conversationId
} else {
null
}
navHostViewModel.setActiveConversation(conversationId) navHostViewModel.setActiveConversation(conversationId)
} }
// Handle session expiry // Handle session expiry
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
navHostViewModel.sessionExpired.collect { navHostViewModel.sessionExpired.collect {
navController.navigate(ServerUrl) { navigator.navigateToAuth()
popUpTo(0) { inclusive = true }
}
} }
} }
val startDestination: Any = if (isLoggedIn) ChatRoute::class else AuthRoute::class
PhoneLayout( PhoneLayout(
navController = navController, navigator = navigator,
navHostViewModel = navHostViewModel, navHostViewModel = navHostViewModel,
startDestination = startDestination,
isInAuthFlow = isInAuthFlow,
modifier = modifier, modifier = modifier,
) )
@ -185,10 +167,8 @@ private fun VersionMismatchDialog(
@Composable @Composable
private fun PhoneLayout( private fun PhoneLayout(
navController: androidx.navigation.NavHostController, navigator: Navigator,
navHostViewModel: NavHostViewModel, navHostViewModel: NavHostViewModel,
startDestination: Any,
isInAuthFlow: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
@ -205,54 +185,42 @@ private fun PhoneLayout(
ModalNavigationDrawer( ModalNavigationDrawer(
drawerState = drawerState, drawerState = drawerState,
gesturesEnabled = !isInAuthFlow, gesturesEnabled = !navigator.isInAuthFlow,
drawerContent = { drawerContent = {
ModalDrawerSheet { ModalDrawerSheet {
SidebarScaffold( SidebarScaffold(
viewModel = navHostViewModel, viewModel = navHostViewModel,
onNewChat = { onNewChat = {
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
val currentRoute = navController.currentBackStackEntry?.destination if (navigator.currentRoute !is NewChat) {
if (!currentRoute.isRoute(NewChat::class)) { navigator.navigateToTopLevel(NewChat)
navController.navigate(NewChat) {
popUpTo<ChatRoute> { inclusive = false }
launchSingleTop = true
}
} }
}, },
onConversationClick = { conversationId -> onConversationClick = { conversationId ->
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
navController.navigateToChat(conversationId) navigator.navigateToChat(conversationId)
}, },
onSettingsClick = { onSettingsClick = {
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
navController.navigate(SettingsTabbed) { navigator.navigate(SettingsTabbed)
launchSingleTop = true
}
}, },
onSettingsCategorySelected = { category -> onSettingsCategorySelected = { category ->
navController.navigate(category.toRoute()) { navigator.navigate(category.toRoute())
launchSingleTop = true
}
}, },
onAgentsClick = { onAgentsClick = {
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
navController.navigate(AgentMarketplace) { navigator.navigate(AgentMarketplace)
launchSingleTop = true
}
}, },
onFilesClick = { onFilesClick = {
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
navController.navigate(Files) { navigator.navigate(Files)
launchSingleTop = true
}
}, },
) )
} }
}, },
) { ) {
Column(modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { Column(modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
if (!isInAuthFlow && banners.isNotEmpty()) { if (!navigator.isInAuthFlow && banners.isNotEmpty()) {
BannerDisplay( BannerDisplay(
banners = banners, banners = banners,
dismissedIds = dismissedBannerIds, dismissedIds = dismissedBannerIds,
@ -260,124 +228,88 @@ private fun PhoneLayout(
modifier = Modifier.padding(top = 4.dp), modifier = Modifier.padding(top = 4.dp),
) )
} }
NavHost( NavDisplay(
navController = navController, backStack = navigator.backStack,
startDestination = startDestination, onBack = { navigator.goBack() },
modifier = Modifier.fillMaxSize(), transitionSpec = {
enterTransition = { slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(300)) togetherWith
slideIntoContainer( fadeOut(animationSpec = tween(150))
towards = AnimatedContentTransitionScope.SlideDirection.Start, },
animationSpec = tween(300), popTransitionSpec = {
(fadeIn(initialAlpha = 0.5f, animationSpec = tween(300, easing = LinearEasing)) +
scaleIn(initialScale = 0.92f, animationSpec = tween(300, easing = LinearEasing))) togetherWith
(slideOutHorizontally(
targetOffsetX = { (it * 0.15f).toInt() },
animationSpec = tween(300, easing = FastOutSlowInEasing),
) + scaleOut(
targetScale = 0.92f,
animationSpec = tween(300, easing = LinearEasing),
))
},
predictivePopTransitionSpec = {
(fadeIn(initialAlpha = 0.5f, animationSpec = tween(300, easing = LinearEasing)) +
scaleIn(initialScale = 0.92f, animationSpec = tween(300, easing = LinearEasing))) togetherWith
(slideOutHorizontally(
targetOffsetX = { (it * 0.15f).toInt() },
animationSpec = tween(300, easing = FastOutSlowInEasing),
) + scaleOut(
targetScale = 0.92f,
animationSpec = tween(300, easing = LinearEasing),
))
},
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
entryProvider = entryProvider {
authEntries(
onNavigate = { navigator.navigate(it) },
onBack = { navigator.goBack() },
onAuthComplete = {
navHostViewModel.onAuthComplete()
navigator.navigateToChat()
},
)
chatEntries(
onNavigate = { navigator.navigate(it) },
onBack = { navigator.goBack() },
onNavigateToChat = { navigator.navigateToChat(it) },
onOpenDrawer = {
scope.launch { drawerState.open() }
},
)
conversationsEntries(
onConversationClick = { navigator.navigateToChat(it) },
onNavigateToArchived = { navigator.navigate(ArchivedConversations) },
onBack = { navigator.goBack() },
)
agentsEntries(
onNavigate = { navigator.navigate(it) },
onBack = { navigator.goBack() },
onStartChat = { _ ->
navigator.navigateToTopLevel(NewChat)
},
)
filesEntries(
onBack = { navigator.goBack() },
)
settingsEntries(
onNavigate = { navigator.navigate(it) },
onBack = { navigator.goBack() },
onLogout = {
navHostViewModel.logout()
navigator.navigateToAuth()
},
onNavigateToArchived = { navigator.navigate(ArchivedConversations) },
)
memoriesEntry(
onBack = { navigator.goBack() },
)
mcpServersEntry(
onBack = { navigator.goBack() },
) )
}, },
exitTransition = { )
fadeOut(animationSpec = tween(150))
},
popEnterTransition = {
fadeIn(
initialAlpha = 0.5f,
animationSpec = tween(300, easing = LinearEasing),
) + scaleIn(
initialScale = 0.92f,
animationSpec = tween(300, easing = LinearEasing),
)
},
popExitTransition = {
slideOut(
targetOffset = { IntOffset((it.width * 0.15f).toInt(), 0) },
animationSpec = tween(300, easing = FastOutSlowInEasing),
) + scaleOut(
targetScale = 0.92f,
animationSpec = tween(300, easing = LinearEasing),
)
},
) {
authGraph(
navController = navController,
onAuthComplete = {
navHostViewModel.onAuthComplete()
navController.navigate(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 package com.garfiec.librechat.shared.navigation
import androidx.savedstate.serialization.SavedStateConfiguration
import com.garfiec.librechat.feature.agents.navigation.agentsSerializersModule import com.garfiec.librechat.feature.agents.navigation.agentsSerializersModule
import com.garfiec.librechat.feature.auth.navigation.authSerializersModule import com.garfiec.librechat.feature.auth.navigation.authSerializersModule
import com.garfiec.librechat.feature.chat.navigation.chatSerializersModule import com.garfiec.librechat.feature.chat.navigation.chatSerializersModule
@ -12,7 +13,7 @@ import kotlinx.serialization.modules.plus
/** /**
* Combined [SerializersModule] for all navigation route types. * Combined [SerializersModule] for all navigation route types.
* Registers polymorphic serializers for each feature module's sealed route hierarchy. * 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 = val navigationSerializersModule: SerializersModule =
authSerializersModule + authSerializersModule +
@ -21,3 +22,11 @@ val navigationSerializersModule: SerializersModule =
agentsSerializersModule + agentsSerializersModule +
filesSerializersModule + filesSerializersModule +
settingsSerializersModule 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"),
}