Merge pull request #14 from garfiec/refactor/consolidate-navigation

refactor: consolidate duplicate navigation code between app and shared modules
This commit is contained in:
Garfie Chiu 2026-04-05 22:38:37 -05:00 committed by GitHub
commit c8a98f9962
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 213 additions and 1818 deletions

View file

@ -4,36 +4,34 @@ Single Activity architecture. `MainActivity` is the sole entry point.
## Navigation
`LibreChatNavHost` is the root composable using Nav 3's `NavDisplay` with `NavBackStack<NavKey>` and `entryProvider`. It uses adaptive layout based on `WindowSizeClass`:
`LibreChatNavHost` (in this module) is the Android-specific entry point. It wraps the shared module's `LibreChatNavHost` via a `content` lambda, adding:
- **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.
- **Deep link handling** (`librechat://conversation/{conversationId}`)
- **Share intent routing** (navigates to NewChat if not already on a chat screen)
- **Tablet layout branching** based on `WindowSizeClass`
The shared module owns the core navigation: `Navigator`, `NavHostViewModel`, `MainNavDisplay`, `PhoneLayout`, sidebar/drawer composables, and all feature entry providers. See `shared/CLAUDE.md` for details.
### Layout Modes
- **Phone**: Delegates to shared `PhoneLayout``ModalNavigationDrawer` sidebar-first pattern.
- **Tablet** (600dp+ width): Uses `TabletLayout` (Android-only, in this module) — persistent side panel with swipe gesture and `BackHandler`.
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
## Android-Only Files in This Module
`DrawerContent` is the rich sidebar matching the web's left panel:
- "New Chat" button at top
- Search bar with debounce filtering
- Conversation items grouped by date (Today, Yesterday, Previous 7 Days, etc.)
- Each item shows: endpoint icon, title, model name, relative time
- Active conversation highlighted with left border indicator and tinted background
- Footer: links to Agents, Files, Settings
- Sign out button at bottom
- `LibreChatNavHost.kt` — Thin wrapper adding deep links, share intents, and tablet/phone branching
- `TabletLayout.kt` — Persistent sidebar with `BackHandler`, `android.net.Uri`, and custom swipe gesture
## Top-Level Destinations
Top-level routes: `NewChat` (Chat), `Conversations`, `AgentMarketplace` (Agents), `Files`, `SettingsTabbed` (Settings).
Conversations are integrated into the drawer body. Agents, Files, and Settings are accessible via drawer footer links. All routes are registered in the `NavDisplay` entry provider.
## Active Conversation Tracking
`NavHostViewModel.activeConversationId` tracks the currently viewed conversation. Updated automatically from the navigation back stack when the chat route changes. Used by `DrawerContent` to highlight the active conversation.
Conversations are integrated into the drawer body. Agents, Files, and Settings are accessible via drawer footer links. All routes are registered in the shared `MainNavDisplay` entry provider.
## Auth & Session
- `NavHostViewModel` observes auth state via `isLoggedIn` flow
- `NavHostViewModel` (in shared) observes auth state via `isLoggedIn` flow
- Start destination is `NewChat` if logged in, `ServerUrl` otherwise
- `sessionExpired` flow triggers nav to auth flow with full back stack clear
- Drawer gestures are hidden during auth flow
@ -41,7 +39,7 @@ Conversations are integrated into the drawer body. Agents, Files, and Settings a
## Deep Linking
- Scheme: `librechat://conversation/{conversationId}`
- Handled in `MainActivity.handleDeepLink()` and forwarded to `LibreChatNavHost`
- Handled in `MainActivity.handleDeepLink()` and forwarded to this module's `LibreChatNavHost`
- `onNewIntent` handles deep links when app is already running
## Connectivity
@ -51,23 +49,22 @@ Conversations are integrated into the drawer body. Agents, Files, and Settings a
## Dependencies
This module depends on all `:core:*` and all `:feature:*` modules.
This module depends on `:shared`, all `:core:*`, and all `:feature:*` modules.
It applies convention plugins: `librechat.mobile.application`, `librechat.mobile.compose`, `librechat.mobile.koin`.
### Server-Synced Favorites
- `NavHostViewModel` exposes `favorites: StateFlow<Set<String>>` and `toggleFavorite()`
- `NavHostViewModel` (shared) exposes `favorites: StateFlow<Set<String>>` and `toggleFavorite()`
- Currently backed by `SettingsDataStore` (local) — server sync via `UserApi.getFavorites()/updateFavorites()` available but needs wiring
- `DrawerContent` shows star icon per conversation (filled = bookmarked)
- Favorites state passed through `LibreChatNavHost` and `TabletLayout`
- `DrawerContent` (shared) shows star icon per conversation (filled = bookmarked)
### Server Banners
- `NavHostViewModel` fetches banners from `BannerRepository` on init, filters expired via `displayFrom`/`displayTo` with `Instant.parse()`
- `NavHostViewModel` (shared) fetches banners from `BannerRepository` on init, filters expired via `displayFrom`/`displayTo` with `Instant.parse()`
- Dismissed banner IDs tracked in-memory via `dismissedBannerIds: StateFlow<Set<String>>` (session-scoped, not persisted)
- `BannerDisplay` composable shown at top of content in both `LibreChatNavHost` (phone) and `TabletLayout`
- `BannerDisplay` composable shown at top of content in both `PhoneLayout` (shared) and `TabletLayout` (app)
- Banner types: "info" (blue), "warning" (amber), "error" (red) — defaults to "info" if type is null
- **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
- `PresetManager` route registered in `SettingsNavigation.kt`
- `PresetManagerScreen` accessible from Settings → Chat section
- Navigation wired through `MainNavDisplay` entry provider, accessible from both phone and tablet layouts
- Navigation wired through shared `MainNavDisplay` entry provider, accessible from both phone and tablet layouts

View file

@ -18,6 +18,7 @@ android {
}
dependencies {
implementation(project(":shared"))
implementation(project(":core:ui"))
implementation(project(":core:data"))
implementation(project(":core:network"))

View file

@ -18,7 +18,7 @@ import com.garfiec.librechat.feature.chat.di.chatModule
import com.garfiec.librechat.feature.conversations.di.conversationsModule
import com.garfiec.librechat.feature.files.di.filesModule
import com.garfiec.librechat.feature.settings.di.settingsModule
import com.garfiec.librechat.navigation.appModule
import com.garfiec.librechat.shared.navigation.sharedAppModule
import io.ktor.client.HttpClient
import org.koin.android.ext.android.get
import org.koin.android.ext.koin.androidContext
@ -41,7 +41,7 @@ class LibreChatApplication : Application(), SingletonImageLoader.Factory {
commonModule,
networkModule,
dataModule,
appModule,
sharedAppModule,
authModule,
authPlatformModule,
chatModule,

View file

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

View file

@ -1,519 +0,0 @@
package com.garfiec.librechat.navigation
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SmartToy
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.garfiec.librechat.R
import com.garfiec.librechat.core.model.EModelEndpoint
import com.garfiec.librechat.core.ui.components.isMonochromeIcon
import com.garfiec.librechat.core.ui.components.toIconRes
/**
* Lightweight snapshot of fields DrawerConversationItem actually renders.
* Avoids passing the full 28-field Conversation through composition.
*/
@Immutable
data class DrawerConversationDisplayData(
val conversationId: String,
val title: String,
val model: String?,
val endpoint: EModelEndpoint?,
val relativeTime: String,
val isActive: Boolean,
val isFavorite: Boolean,
)
// Pre-computed shapes to avoid creating new ones per item per frame
private val ItemShape = RoundedCornerShape(8.dp)
private val ActiveIndicatorShape = RoundedCornerShape(2.dp)
/**
* Stateful DrawerContent that collects its own state from the ViewModel.
* State changes only recompose inside this composable not the parent
* PhoneLayout/TabletLayout, which avoids recomposing the NavDisplay/main content.
*/
@Composable
fun DrawerContent(
viewModel: NavHostViewModel,
onNewChat: () -> Unit,
onConversationClick: (String) -> Unit,
onSettingsClick: () -> Unit,
onAgentsClick: () -> Unit,
onFilesClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val uiState by viewModel.drawerUiState.collectAsStateWithLifecycle()
DrawerContent(
uiState = uiState,
onSearchQueryChanged = viewModel::onSearchQueryChanged,
onNewChat = onNewChat,
onConversationClick = onConversationClick,
onSettingsClick = onSettingsClick,
onAgentsClick = onAgentsClick,
onFilesClick = onFilesClick,
onToggleFavorite = viewModel::toggleFavorite,
onRefresh = viewModel::refreshConversations,
onLoadMore = viewModel::loadMoreConversations,
modifier = modifier,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DrawerContent(
uiState: DrawerUiState,
onSearchQueryChanged: (String) -> Unit,
onNewChat: () -> Unit,
onConversationClick: (String) -> Unit,
onSettingsClick: () -> Unit,
onAgentsClick: () -> Unit,
onFilesClick: () -> Unit,
modifier: Modifier = Modifier,
onToggleFavorite: (String) -> Unit = {},
onRefresh: () -> Unit = {},
onLoadMore: () -> Unit = {},
) {
Column(
modifier = modifier
.fillMaxHeight()
.width(300.dp)
.statusBarsPadding()
.padding(top = 16.dp),
) {
// "New Chat" button at top
Surface(
onClick = onNewChat,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
shape = ItemShape,
color = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.new_chat),
style = MaterialTheme.typography.titleSmall,
)
}
}
Spacer(modifier = Modifier.height(12.dp))
// Search bar
OutlinedTextField(
value = uiState.searchQuery,
onValueChange = onSearchQueryChanged,
leadingIcon = {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.cd_search),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
},
trailingIcon = {
if (uiState.searchQuery.isNotEmpty()) {
IconButton(onClick = { onSearchQueryChanged("") }) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.cd_clear_search),
modifier = Modifier.size(20.dp),
)
}
}
},
placeholder = {
Text(
text = stringResource(R.string.search_conversations_placeholder),
style = MaterialTheme.typography.bodySmall,
)
},
singleLine = true,
shape = ItemShape,
textStyle = MaterialTheme.typography.bodySmall,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
)
Spacer(modifier = Modifier.height(8.dp))
// Conversation list with favorites section and date groups
val listState = rememberLazyListState()
// Detect when scrolled near the end to trigger load-more
val shouldLoadMore = remember {
derivedStateOf {
val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
val totalItems = listState.layoutInfo.totalItemsCount
lastVisibleItem >= totalItems - 8 && totalItems > 0
}
}
LaunchedEffect(shouldLoadMore.value) {
if (shouldLoadMore.value && uiState.hasMore && !uiState.isLoadingMore) {
onLoadMore()
}
}
PullToRefreshBox(
isRefreshing = uiState.isRefreshing,
onRefresh = onRefresh,
modifier = Modifier.weight(1f),
) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
) {
// Favorites section
if (uiState.favoriteConversations.isNotEmpty() && uiState.searchQuery.isEmpty()) {
item(key = "favorites_header") {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
start = 16.dp,
end = 16.dp,
top = 8.dp,
bottom = 4.dp,
),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Default.Star,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = stringResource(R.string.favorites),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.semantics { heading() },
)
}
}
items(
items = uiState.favoriteConversations,
key = { "fav_${it.conversationId}" },
contentType = { "conversation" },
) { data ->
DrawerConversationItem(
data = data,
onClick = { onConversationClick(data.conversationId) },
onToggleFavorite = { onToggleFavorite(data.conversationId) },
)
}
item(key = "favorites_divider") {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
}
}
if (uiState.groupedConversations.isEmpty() && uiState.searchQuery.isNotEmpty()) {
item(key = "empty_search") {
Text(
text = stringResource(R.string.no_conversations_found),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp),
)
}
}
uiState.groupedConversations.forEach { (dateGroup, displayItems) ->
// Date group header
item(key = "header_$dateGroup") {
Text(
text = dateGroup,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(
start = 16.dp,
end = 16.dp,
top = 12.dp,
bottom = 4.dp,
),
)
}
// Conversation items in this group
items(
items = displayItems,
key = { it.conversationId },
contentType = { "conversation" },
) { data ->
DrawerConversationItem(
data = data,
onClick = { onConversationClick(data.conversationId) },
onToggleFavorite = { onToggleFavorite(data.conversationId) },
)
}
}
// Loading indicator at the bottom when loading more
if (uiState.isLoadingMore) {
item(key = "loading_more") {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
)
}
}
}
}
}
// Bottom section: divider + footer links + sign out
HorizontalDivider(
modifier = Modifier.padding(horizontal = 12.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
// Footer navigation links
DrawerFooterItem(
icon = Icons.Default.SmartToy,
label = stringResource(R.string.agents),
onClick = onAgentsClick,
)
DrawerFooterItem(
icon = Icons.Default.Folder,
label = stringResource(R.string.files),
onClick = onFilesClick,
)
DrawerFooterItem(
icon = Icons.Default.Settings,
label = stringResource(R.string.settings),
onClick = onSettingsClick,
)
Spacer(modifier = Modifier.height(8.dp))
}
}
@Composable
private fun DrawerConversationItem(
data: DrawerConversationDisplayData,
onClick: () -> Unit,
modifier: Modifier = Modifier,
onToggleFavorite: () -> Unit = {},
) {
val endpointIconRes = remember(data.endpoint) {
data.endpoint?.toIconRes()
}
val backgroundColor = if (data.isActive) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.surface
}
Row(
modifier = modifier
.padding(horizontal = 4.dp, vertical = 1.dp)
.fillMaxWidth()
.background(backgroundColor, ItemShape)
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Active indicator (left border simulation)
if (data.isActive) {
Box(
modifier = Modifier
.width(3.dp)
.height(24.dp)
.background(MaterialTheme.colorScheme.primary, ActiveIndicatorShape),
)
Spacer(modifier = Modifier.width(8.dp))
}
// Endpoint icon or star for favorites
if (data.isFavorite) {
Icon(
imageVector = Icons.Default.Star,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary,
)
} else if (endpointIconRes != null) {
val isMonochrome = data.endpoint?.isMonochromeIcon() == true
Icon(
painter = painterResource(id = endpointIconRes),
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = if (isMonochrome) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
Color.Unspecified
},
)
} else {
Icon(
imageVector = Icons.Default.SmartToy,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = if (data.isActive) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
Spacer(modifier = Modifier.width(10.dp))
// Title and metadata
Column(modifier = Modifier.weight(1f)) {
Text(
text = data.title,
style = MaterialTheme.typography.bodyMedium,
color = if (data.isActive) {
MaterialTheme.colorScheme.onSecondaryContainer
} else {
MaterialTheme.colorScheme.onSurface
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
// Model and time on second line
val subtitle = remember(data.model, data.relativeTime) {
buildString {
data.model?.let { model ->
append(model.take(20))
}
if (data.relativeTime.isNotEmpty()) {
if (isNotEmpty()) append(" \u00B7 ")
append(data.relativeTime)
}
}
}
if (subtitle.isNotEmpty()) {
Text(
text = subtitle,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
// Bookmark toggle — lightweight clickable icon instead of IconButton
Icon(
imageVector = if (data.isFavorite) Icons.Default.Star else Icons.Default.StarBorder,
contentDescription = if (data.isFavorite) stringResource(R.string.remove_bookmark) else stringResource(R.string.bookmark),
modifier = Modifier
.size(32.dp)
.clickable(onClick = onToggleFavorite)
.padding(8.dp),
tint = if (data.isFavorite) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
@Composable
private fun DrawerFooterItem(
icon: ImageVector,
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = label,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
}

View file

@ -1,79 +1,22 @@
package com.garfiec.librechat.navigation
import android.net.Uri
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
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.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDrawerState
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import com.garfiec.librechat.MainActivity
import com.garfiec.librechat.R
import com.garfiec.librechat.core.ui.components.BannerDisplay
import com.garfiec.librechat.feature.agents.navigation.AgentMarketplace
import com.garfiec.librechat.feature.agents.navigation.agentsEntries
import com.garfiec.librechat.feature.auth.navigation.AuthRoute
import com.garfiec.librechat.feature.auth.navigation.ServerUrl
import com.garfiec.librechat.feature.auth.navigation.authEntries
import com.garfiec.librechat.shared.navigation.LibreChatNavHost as SharedLibreChatNavHost
import com.garfiec.librechat.shared.navigation.PhoneLayout
import com.garfiec.librechat.feature.chat.navigation.Chat
import com.garfiec.librechat.feature.chat.navigation.NewChat
import com.garfiec.librechat.feature.chat.navigation.chatEntries
import com.garfiec.librechat.feature.conversations.navigation.ArchivedConversations
import com.garfiec.librechat.feature.conversations.navigation.conversationsEntries
import com.garfiec.librechat.feature.files.navigation.Files
import com.garfiec.librechat.feature.files.navigation.filesEntries
import com.garfiec.librechat.feature.settings.navigation.SettingsAccount
import com.garfiec.librechat.feature.settings.navigation.SettingsChat
import com.garfiec.librechat.feature.settings.navigation.SettingsData
import com.garfiec.librechat.feature.settings.navigation.SettingsGeneral
import com.garfiec.librechat.feature.settings.navigation.SettingsRoute
import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed
import com.garfiec.librechat.feature.settings.navigation.memoriesEntry
import com.garfiec.librechat.feature.settings.navigation.mcpServersEntry
import com.garfiec.librechat.feature.settings.navigation.settingsEntries
import kotlinx.coroutines.launch
import org.koin.compose.viewmodel.koinViewModel
import co.touchlab.kermit.Logger
/** Maps a [SettingsCategory] to its corresponding typed navigation route. */
fun SettingsCategory.toRoute(): SettingsRoute = when (this) {
SettingsCategory.GENERAL -> SettingsGeneral
SettingsCategory.CHAT -> SettingsChat
SettingsCategory.ACCOUNT -> SettingsAccount
SettingsCategory.DATA -> SettingsData
}
/**
* Android-specific entry point that wraps the shared [SharedLibreChatNavHost]
* with deep link handling, share intent support, and tablet layout branching.
*/
@Composable
fun LibreChatNavHost(
modifier: Modifier = Modifier,
@ -81,296 +24,50 @@ fun LibreChatNavHost(
deepLinkUri: Uri? = null,
onDeepLinkConsumed: () -> Unit = {},
shareNavigationTrigger: Int = 0,
navHostViewModel: NavHostViewModel = koinViewModel(),
) {
val isLoggedIn by navHostViewModel.isLoggedIn.collectAsStateWithLifecycle()
// Stable start key — auth redirect handled via LaunchedEffect below.
val backStack = rememberNavBackStack(navigationSavedStateConfig, NewChat)
val navigator = Navigator(backStack)
// Redirect to auth if not logged in (on first composition only).
LaunchedEffect(Unit) {
if (!navHostViewModel.isLoggedIn.value) {
navigator.navigateToAuth()
}
}
// Track active conversation from nav back stack
LaunchedEffect(navigator.currentRoute) {
val conversationId = (navigator.currentRoute as? Chat)?.conversationId
navHostViewModel.setActiveConversation(conversationId)
}
// Handle session expiry
LaunchedEffect(Unit) {
navHostViewModel.sessionExpired.collect {
navigator.navigateToAuth()
}
}
val isTablet = windowSizeClass?.widthSizeClass?.let {
it >= WindowWidthSizeClass.Medium
} ?: false
if (isTablet) {
TabletLayout(
navigator = navigator,
navHostViewModel = navHostViewModel,
deepLinkUri = deepLinkUri,
onDeepLinkConsumed = onDeepLinkConsumed,
shareNavigationTrigger = shareNavigationTrigger,
modifier = modifier,
)
} else {
PhoneLayout(
navigator = navigator,
navHostViewModel = navHostViewModel,
deepLinkUri = deepLinkUri,
onDeepLinkConsumed = onDeepLinkConsumed,
shareNavigationTrigger = shareNavigationTrigger,
modifier = modifier,
)
}
// Version mismatch warning dialog (shown over any layout)
val versionMismatch by navHostViewModel.versionMismatch.collectAsStateWithLifecycle()
versionMismatch?.let { mismatch ->
VersionMismatchDialog(
supportedVersion = mismatch.supportedVersion,
backendVersion = mismatch.backendVersion,
onDismiss = navHostViewModel::dismissVersionWarning,
onDismissPermanently = navHostViewModel::dismissVersionWarningPermanently,
)
}
}
/**
* Dialog shown when the backend server version does not match the version
* this app was built for. Offers "Dismiss" (session-only) and
* "Don't warn again" (persisted per backend version) options.
*/
@Composable
private fun VersionMismatchDialog(
supportedVersion: String,
backendVersion: String,
onDismiss: () -> Unit,
onDismissPermanently: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = stringResource(R.string.version_mismatch_title),
style = MaterialTheme.typography.headlineSmall,
)
},
text = {
Text(
text = stringResource(R.string.version_mismatch_message, supportedVersion, backendVersion),
style = MaterialTheme.typography.bodyMedium,
)
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.dismiss))
}
},
dismissButton = {
TextButton(onClick = onDismissPermanently) {
Text(stringResource(R.string.dont_warn_again))
}
},
)
}
@Composable
private fun PhoneLayout(
navigator: Navigator,
navHostViewModel: NavHostViewModel,
deepLinkUri: Uri?,
onDeepLinkConsumed: () -> Unit,
shareNavigationTrigger: Int,
modifier: Modifier = Modifier,
) {
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val banners by navHostViewModel.banners.collectAsStateWithLifecycle()
val dismissedBannerIds by navHostViewModel.dismissedBannerIds.collectAsStateWithLifecycle()
// Handle deep links
LaunchedEffect(deepLinkUri) {
deepLinkUri?.let { uri ->
if (uri.scheme == "librechat" && uri.host == "conversation") {
uri.lastPathSegment?.let { conversationId ->
if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) {
navigator.navigateToChat(conversationId)
} else {
Logger.w { "Ignoring deep link with invalid conversation ID: $conversationId" }
SharedLibreChatNavHost(modifier = modifier) { navigator, navHostViewModel, mod ->
// Handle deep links
LaunchedEffect(deepLinkUri) {
deepLinkUri?.let { uri ->
if (uri.scheme == "librechat" && uri.host == "conversation") {
uri.lastPathSegment?.let { conversationId ->
if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) {
navigator.navigateToChat(conversationId)
} else {
Logger.w { "Ignoring deep link with invalid conversation ID: $conversationId" }
}
}
}
}
onDeepLinkConsumed()
}
}
// Handle share intent
LaunchedEffect(shareNavigationTrigger) {
if (shareNavigationTrigger > 0) {
val currentRoute = navigator.currentRoute
val isOnChatScreen = currentRoute is Chat || currentRoute is NewChat
if (!isOnChatScreen) {
navigator.navigateToTopLevel(NewChat)
onDeepLinkConsumed()
}
}
}
// Reset sidebar mode to Conversations when the drawer closes
LaunchedEffect(drawerState.isClosed) {
if (drawerState.isClosed) {
navHostViewModel.setSidebarMode(SidebarMode.Conversations)
// Handle share intent
LaunchedEffect(shareNavigationTrigger) {
if (shareNavigationTrigger > 0) {
val currentRoute = navigator.currentRoute
if (currentRoute !is Chat && currentRoute !is NewChat) {
navigator.navigateToTopLevel(NewChat)
}
}
}
}
ModalNavigationDrawer(
drawerState = drawerState,
gesturesEnabled = !navigator.isInAuthFlow,
drawerContent = {
ModalDrawerSheet {
SidebarScaffold(
viewModel = navHostViewModel,
onNewChat = {
scope.launch { drawerState.close() }
if (navigator.currentRoute !is NewChat) {
navigator.navigateToTopLevel(NewChat)
}
},
onConversationClick = { conversationId ->
scope.launch { drawerState.close() }
navigator.navigateToChat(conversationId)
},
onSettingsClick = {
scope.launch { drawerState.close() }
navigator.navigate(SettingsTabbed)
},
onSettingsCategorySelected = { category ->
navigator.navigate(category.toRoute())
},
onAgentsClick = {
scope.launch { drawerState.close() }
navigator.navigate(AgentMarketplace)
},
onFilesClick = {
scope.launch { drawerState.close() }
navigator.navigate(Files)
},
)
}
},
) {
Column(modifier = modifier.fillMaxSize()) {
if (!navigator.isInAuthFlow && banners.isNotEmpty()) {
BannerDisplay(
banners = banners,
dismissedIds = dismissedBannerIds,
onDismiss = navHostViewModel::dismissBanner,
modifier = Modifier.padding(top = 4.dp),
)
}
MainNavDisplay(
val isTablet = windowSizeClass?.widthSizeClass?.let {
it >= WindowWidthSizeClass.Medium
} ?: false
if (isTablet) {
TabletLayout(
navigator = navigator,
navHostViewModel = navHostViewModel,
onOpenDrawer = { scope.launch { drawerState.open() } },
modifier = Modifier.fillMaxSize(),
modifier = mod,
)
} else {
PhoneLayout(
navigator = navigator,
navHostViewModel = navHostViewModel,
modifier = mod,
)
}
}
}
@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,457 +0,0 @@
package com.garfiec.librechat.navigation
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.garfiec.librechat.core.common.extensions.toInstantOrNull
import com.garfiec.librechat.core.common.extensions.toRelativeDateGroup
import com.garfiec.librechat.core.common.result.Result
import com.garfiec.librechat.core.data.repository.AuthRepository
import com.garfiec.librechat.core.data.repository.BannerRepository
import com.garfiec.librechat.core.data.repository.ConfigRepository
import com.garfiec.librechat.core.data.repository.ConversationRepository
import com.garfiec.librechat.core.model.Banner
import com.garfiec.librechat.core.model.Conversation
import com.garfiec.librechat.core.network.client.TokenManager
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import co.touchlab.kermit.Logger
import kotlin.time.Clock
import kotlin.time.Instant
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
/**
* Combined UI state for the drawer sidebar. A single emission replaces
* 10+ individual StateFlow collections in the parent composable, preventing
* unnecessary recomposition of the entire PhoneLayout/TabletLayout tree.
*/
@Immutable
data class DrawerUiState(
val groupedConversations: List<Pair<String, List<DrawerConversationDisplayData>>> = emptyList(),
val favoriteConversations: List<DrawerConversationDisplayData> = emptyList(),
val searchQuery: String = "",
val isRefreshing: Boolean = false,
val isLoadingMore: Boolean = false,
val hasMore: Boolean = true,
)
/**
* UI state for a backend version mismatch warning.
*/
@Immutable
data class VersionMismatchState(
val supportedVersion: String,
val backendVersion: String,
)
class NavHostViewModel(
private val authRepository: AuthRepository,
private val bannerRepository: BannerRepository,
private val configRepository: ConfigRepository,
private val conversationRepository: ConversationRepository,
private val tokenManager: TokenManager,
private val settingsDataStore: com.garfiec.librechat.core.data.datastore.SettingsDataStore,
) : ViewModel() {
private val _isLoggedIn = MutableStateFlow(tokenManager.isAuthenticated)
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
/**
* Non-null when a version mismatch has been detected and should be shown to the user.
* Set to null after the user dismisses the dialog (for this session or permanently).
*/
private val _versionMismatch = MutableStateFlow<VersionMismatchState?>(null)
val versionMismatch: StateFlow<VersionMismatchState?> = _versionMismatch.asStateFlow()
private val _recentConversations = MutableStateFlow<List<Conversation>>(emptyList())
/** Conversations grouped by date (internal, feeds into drawerUiState). */
private val _groupedConversations = MutableStateFlow<List<Pair<String, List<Conversation>>>>(emptyList())
/** The ID of the currently viewed conversation (for active highlight). */
private val _activeConversationId = MutableStateFlow<String?>(null)
/** Drawer search query. */
private val _searchQuery = MutableStateFlow("")
/** Server-synced bookmarked conversation IDs. */
private val _favorites = MutableStateFlow<Set<String>>(emptySet())
/** Bookmarked/favorite conversations (internal, feeds into drawerUiState). */
private val _favoriteConversations = MutableStateFlow<List<Conversation>>(emptyList())
/** Pagination cursor for loading more conversations from the server. */
private var nextCursor: String? = null
/** Whether there are more conversations to load from the server. */
private val _hasMore = MutableStateFlow(true)
/** Whether a load-more request is in progress. */
private val _isLoadingMore = MutableStateFlow(false)
/** Whether a pull-to-refresh is in progress. */
private val _isRefreshing = MutableStateFlow(false)
/** Active banners from the server, filtered to non-expired. */
private val _banners = MutableStateFlow<List<Banner>>(emptyList())
val banners: StateFlow<List<Banner>> = _banners.asStateFlow()
/** Banner IDs dismissed by the user during this session. */
private val _dismissedBannerIds = MutableStateFlow<Set<String>>(emptySet())
val dismissedBannerIds: StateFlow<Set<String>> = _dismissedBannerIds.asStateFlow()
val sessionExpired: SharedFlow<Unit> = tokenManager.sessionExpiredFlow
/** Persisted sidebar open/close state for tablet mode.
* The initial value is read synchronously so that first composition
* sees the real persisted state instead of a hardcoded default,
* avoiding a visible open-then-close flicker on cold start. */
val tabletSidebarOpen: StateFlow<Boolean> = settingsDataStore.tabletSidebarOpen
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
runBlocking { settingsDataStore.tabletSidebarOpen.first() },
)
/** Whether the swipe gesture to open/close the tablet sidebar is enabled. */
val tabletSidebarGestureEnabled: StateFlow<Boolean> = settingsDataStore.tabletSidebarGestureEnabled
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
/** Current sidebar mode: Conversations (default) or Settings. */
private val _sidebarMode = MutableStateFlow<SidebarMode>(SidebarMode.Conversations)
val sidebarMode: StateFlow<SidebarMode> = _sidebarMode.asStateFlow()
/** Currently selected settings category in the sidebar nav menu. */
private val _selectedSettingsCategory = MutableStateFlow<SettingsCategory?>(null)
val selectedSettingsCategory: StateFlow<SettingsCategory?> = _selectedSettingsCategory.asStateFlow()
fun setSidebarMode(mode: SidebarMode) {
_sidebarMode.value = mode
}
fun selectSettingsCategory(category: SettingsCategory) {
_selectedSettingsCategory.value = category
}
private var searchJob: Job? = null
/**
* Single combined drawer UI state. All display-data mapping (Conversation
* DrawerConversationDisplayData) happens here in the ViewModel, not in composition.
* Collected inside DrawerContent so state changes only recompose the drawer,
* not the entire PhoneLayout/TabletLayout.
*/
val drawerUiState: StateFlow<DrawerUiState> = combine(
combine(
_groupedConversations,
_activeConversationId,
_favorites,
_favoriteConversations,
_searchQuery,
) { grouped, activeId, favIds, favConvos, query ->
DrawerDataSnapshot(grouped, activeId, favIds, favConvos, query)
},
combine(_isRefreshing, _isLoadingMore, _hasMore) { refreshing, loadingMore, hasMore ->
Triple(refreshing, loadingMore, hasMore)
},
) { data, (refreshing, loadingMore, hasMore) ->
DrawerUiState(
groupedConversations = data.grouped.map { (group, convos) ->
group to convos.map { it.toDrawerDisplayData(data.activeId, data.favIds) }
},
favoriteConversations = data.favConvos.map { it.toDrawerDisplayData(data.activeId, data.favIds) },
searchQuery = data.query,
isRefreshing = refreshing,
isLoadingMore = loadingMore,
hasMore = hasMore,
)
}.stateIn(viewModelScope, SharingStarted.Eagerly, DrawerUiState())
init {
viewModelScope.launch {
val loggedIn = authRepository.isLoggedIn()
_isLoggedIn.value = loggedIn
// Check backend version on cold start for returning users
if (loggedIn) {
checkBackendVersion()
}
}
observeConversations()
observeBookmarks()
fetchBanners()
loadInitialConversations()
}
private fun observeConversations() {
viewModelScope.launch {
conversationRepository.observeConversations().collect { result ->
if (result is Result.Success) {
_recentConversations.value = result.data
// Only regroup if not in search mode
if (_searchQuery.value.isBlank()) {
_groupedConversations.value = groupConversationsByDate(result.data)
}
updateFavoriteConversations()
}
}
}
}
private fun loadInitialConversations() {
viewModelScope.launch {
val result = conversationRepository.loadNextPage(cursor = null)
if (result is Result.Success) {
nextCursor = result.data
_hasMore.value = result.data != null
}
}
}
fun loadMoreConversations() {
if (_isLoadingMore.value || !_hasMore.value) return
_isLoadingMore.value = true
viewModelScope.launch {
val result = conversationRepository.loadNextPage(cursor = nextCursor)
if (result is Result.Success) {
nextCursor = result.data
_hasMore.value = result.data != null
} else {
Logger.w { "Failed to load more conversations" }
}
_isLoadingMore.value = false
}
}
fun onAuthComplete() {
_isLoggedIn.value = true
loadInitialConversations()
fetchBanners()
checkBackendVersion()
}
fun refreshConversations() {
_isRefreshing.value = true
viewModelScope.launch {
nextCursor = null
_hasMore.value = true
val result = conversationRepository.loadNextPage(cursor = null)
if (result is Result.Success) {
nextCursor = result.data
_hasMore.value = result.data != null
}
_isRefreshing.value = false
}
}
fun setActiveConversation(conversationId: String?) {
_activeConversationId.value = conversationId
}
fun onSearchQueryChanged(query: String) {
_searchQuery.value = query
searchJob?.cancel()
if (query.isBlank()) {
// Reset to full grouped list from cached conversations
_groupedConversations.value = groupConversationsByDate(_recentConversations.value)
return
}
searchJob = viewModelScope.launch {
delay(300) // debounce
val filtered = _recentConversations.value.filter { conversation ->
conversation.title?.contains(query, ignoreCase = true) == true
}
_groupedConversations.value = groupConversationsByDate(filtered)
}
}
private fun observeBookmarks() {
viewModelScope.launch {
settingsDataStore.bookmarkedConversationIds.collect { bookmarkedIds ->
_favorites.value = bookmarkedIds
updateFavoriteConversations()
}
}
}
fun toggleFavorite(conversationId: String) {
viewModelScope.launch {
settingsDataStore.toggleBookmark(conversationId)
}
}
private fun updateFavoriteConversations() {
val bookmarkedIds = _favorites.value
val allConversations = _recentConversations.value
_favoriteConversations.value = allConversations.filter { conversation ->
conversation.conversationId in bookmarkedIds ||
conversation.tags.any {
it.equals("favorite", ignoreCase = true) ||
it.equals("bookmark", ignoreCase = true)
}
}
}
private fun fetchBanners() {
viewModelScope.launch {
val result = bannerRepository.getBanners()
if (result is Result.Success) {
val now = Clock.System.now()
_banners.value = result.data.filter { banner ->
val from = banner.displayFrom?.let { runCatching { Instant.parse(it) }.getOrNull() }
val to = banner.displayTo?.let { runCatching { Instant.parse(it) }.getOrNull() }
val afterStart = from == null || now >= from
val beforeEnd = to == null || now < to
afterStart && beforeEnd
}
}
}
}
fun setTabletSidebarOpen(open: Boolean) {
viewModelScope.launch {
settingsDataStore.setTabletSidebarOpen(open)
}
}
fun dismissBanner(bannerId: String) {
_dismissedBannerIds.value = _dismissedBannerIds.value + bannerId
}
/**
* Performs an async, non-blocking version check against the backend.
* If the versions are incompatible and the user hasn't dismissed the warning
* for this specific backend version, sets [versionMismatch] to trigger the dialog.
*/
private fun checkBackendVersion() {
viewModelScope.launch {
val result = configRepository.checkBackendVersion()
if (result is Result.Success) {
val checkResult = result.data
val detectedVersion = checkResult.backendVersion
if (!checkResult.isCompatible && detectedVersion != null) {
// Check if the user has already dismissed the warning for this version
val dismissedVersion = settingsDataStore.dismissedVersionWarning.first()
if (dismissedVersion != detectedVersion) {
_versionMismatch.value = VersionMismatchState(
supportedVersion = checkResult.supportedVersion,
backendVersion = detectedVersion,
)
} else {
Logger.d {
"Version mismatch (backend=$detectedVersion, supported=${checkResult.supportedVersion}) but user dismissed for this version"
}
}
}
}
// On error, silently ignore -- the version check is non-critical
}
}
/** Dismiss the version mismatch dialog for this session only. */
fun dismissVersionWarning() {
_versionMismatch.value = null
}
/** Dismiss the version mismatch dialog and suppress it for this backend version permanently. */
fun dismissVersionWarningPermanently() {
val backendVersion = _versionMismatch.value?.backendVersion
_versionMismatch.value = null
if (backendVersion != null) {
viewModelScope.launch {
settingsDataStore.setDismissedVersionWarning(backendVersion)
}
}
}
fun logout() {
viewModelScope.launch {
authRepository.logout()
_isLoggedIn.value = false
_recentConversations.value = emptyList()
_groupedConversations.value = emptyList()
_activeConversationId.value = null
_searchQuery.value = ""
_favorites.value = emptySet()
_favoriteConversations.value = emptyList()
_sidebarMode.value = SidebarMode.Conversations
_selectedSettingsCategory.value = null
}
}
private fun groupConversationsByDate(
conversations: List<Conversation>,
): List<Pair<String, List<Conversation>>> {
if (conversations.isEmpty()) return emptyList()
return conversations
.groupBy { conversation ->
conversation.updatedAt
?.toInstantOrNull()
?.toRelativeDateGroup()
?: "Unknown"
}
.toList()
}
}
/** Intermediate holder for the 5-argument combine. */
private data class DrawerDataSnapshot(
val grouped: List<Pair<String, List<Conversation>>>,
val activeId: String?,
val favIds: Set<String>,
val favConvos: List<Conversation>,
val query: String,
)
private fun Conversation.toDrawerDisplayData(
activeConversationId: String?,
favoriteIds: Set<String>,
): DrawerConversationDisplayData {
val convId = conversationId ?: ""
return DrawerConversationDisplayData(
conversationId = convId,
title = title ?: "New Chat",
model = model,
endpoint = endpoint,
relativeTime = updatedAt?.toInstantOrNull()?.toRelativeTimeString() ?: "",
isActive = convId == activeConversationId,
isFavorite = convId in favoriteIds,
)
}
private fun Instant.toRelativeTimeString(): String {
val now = Clock.System.now()
val duration = now - this
val minutes = duration.inWholeMinutes
val hours = duration.inWholeHours
val days = duration.inWholeDays
return when {
minutes < 1 -> "Just now"
minutes < 60 -> "${minutes}m ago"
hours < 24 -> "${hours}h ago"
days < 7 -> "${days}d ago"
else -> {
val date = toLocalDateTime(TimeZone.currentSystemDefault()).date
val monthAbbr = when (date.monthNumber) {
1 -> "Jan"; 2 -> "Feb"; 3 -> "Mar"; 4 -> "Apr"
5 -> "May"; 6 -> "Jun"; 7 -> "Jul"; 8 -> "Aug"
9 -> "Sep"; 10 -> "Oct"; 11 -> "Nov"; 12 -> "Dec"
else -> ""
}
"$monthAbbr ${date.dayOfMonth}"
}
}
}

View file

@ -1,32 +0,0 @@
package com.garfiec.librechat.navigation
import androidx.savedstate.serialization.SavedStateConfiguration
import com.garfiec.librechat.feature.agents.navigation.agentsSerializersModule
import com.garfiec.librechat.feature.auth.navigation.authSerializersModule
import com.garfiec.librechat.feature.chat.navigation.chatSerializersModule
import com.garfiec.librechat.feature.conversations.navigation.conversationsSerializersModule
import com.garfiec.librechat.feature.files.navigation.filesSerializersModule
import com.garfiec.librechat.feature.settings.navigation.settingsSerializersModule
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.plus
/**
* Combined [SerializersModule] for all navigation route types.
* Registers polymorphic serializers for each feature module's sealed route hierarchy.
* Used by Nav 3's SavedStateConfiguration for type-safe state saving.
*/
val navigationSerializersModule: SerializersModule =
authSerializersModule +
chatSerializersModule +
conversationsSerializersModule +
agentsSerializersModule +
filesSerializersModule +
settingsSerializersModule
/**
* Nav 3 [SavedStateConfiguration] with polymorphic serialization for all route types.
* Required for KMP (iOS) since Nav 3 cannot use reflection-based serialization on non-JVM platforms.
*/
val navigationSavedStateConfig = SavedStateConfiguration {
serializersModule = navigationSerializersModule
}

View file

@ -1,60 +0,0 @@
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

@ -1,151 +0,0 @@
package com.garfiec.librechat.navigation
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.garfiec.librechat.R
/**
* Settings sidebar content shown when the sidebar mode is [SidebarMode.Settings].
* Displays a simple vertical list of settings categories. Tapping a category
* navigates to its sub-page in the main content area.
*/
@Composable
fun SettingsSidebarContent(
selectedCategory: SettingsCategory?,
onBackToConversations: () -> Unit,
onCategorySelected: (SettingsCategory) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxHeight()
.width(300.dp)
.statusBarsPadding()
.padding(top = 8.dp),
) {
// Header with back arrow
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBackToConversations) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.cd_back_to_conversations),
)
}
Text(
text = stringResource(R.string.settings),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.weight(1f)
.semantics { heading() },
)
}
Spacer(modifier = Modifier.padding(top = 8.dp))
// Category list
SettingsCategoryItem(
icon = Icons.Default.Settings,
category = SettingsCategory.GENERAL,
isSelected = selectedCategory == SettingsCategory.GENERAL,
onClick = { onCategorySelected(SettingsCategory.GENERAL) },
)
SettingsCategoryItem(
icon = Icons.AutoMirrored.Filled.Chat,
category = SettingsCategory.CHAT,
isSelected = selectedCategory == SettingsCategory.CHAT,
onClick = { onCategorySelected(SettingsCategory.CHAT) },
)
SettingsCategoryItem(
icon = Icons.Default.AccountCircle,
category = SettingsCategory.ACCOUNT,
isSelected = selectedCategory == SettingsCategory.ACCOUNT,
onClick = { onCategorySelected(SettingsCategory.ACCOUNT) },
)
SettingsCategoryItem(
icon = Icons.Default.Storage,
category = SettingsCategory.DATA,
isSelected = selectedCategory == SettingsCategory.DATA,
onClick = { onCategorySelected(SettingsCategory.DATA) },
)
Spacer(modifier = Modifier.weight(1f))
}
}
@Composable
private fun SettingsCategoryItem(
icon: ImageVector,
category: SettingsCategory,
isSelected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val backgroundColor = if (isSelected) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.surface
}
val contentColor = if (isSelected) {
MaterialTheme.colorScheme.onSecondaryContainer
} else {
MaterialTheme.colorScheme.onSurface
}
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 2.dp)
.clip(RoundedCornerShape(12.dp))
.background(backgroundColor, RoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(22.dp),
tint = contentColor,
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = category.label,
style = MaterialTheme.typography.bodyLarge,
color = contentColor,
)
}
}

View file

@ -1,17 +0,0 @@
package com.garfiec.librechat.navigation
sealed interface SidebarMode {
data object Conversations : SidebarMode
data object Settings : SidebarMode
}
/**
* Settings categories shown as a navigation menu in the sidebar.
* Each maps to a dedicated settings sub-page in the main content area.
*/
enum class SettingsCategory(val label: String) {
GENERAL("General"),
CHAT("Chat"),
ACCOUNT("Account"),
DATA("Data"),
}

View file

@ -1,75 +0,0 @@
package com.garfiec.librechat.navigation
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
/**
* Sidebar scaffold that switches between Conversations and Settings modes.
* Uses [AnimatedContent] for a smooth horizontal slide transition.
*
* The Settings footer button now navigates directly to the tabbed settings
* screen via [onSettingsClick], rather than switching sidebar mode.
* The mode-switching infrastructure is preserved for potential future use.
*/
@Composable
fun SidebarScaffold(
viewModel: NavHostViewModel,
onNewChat: () -> Unit,
onConversationClick: (String) -> Unit,
onSettingsClick: () -> Unit,
onSettingsCategorySelected: (SettingsCategory) -> Unit,
onAgentsClick: () -> Unit,
onFilesClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val sidebarMode by viewModel.sidebarMode.collectAsStateWithLifecycle()
val selectedCategory by viewModel.selectedSettingsCategory.collectAsStateWithLifecycle()
AnimatedContent(
targetState = sidebarMode,
modifier = modifier,
transitionSpec = {
if (targetState is SidebarMode.Settings) {
// Slide in from right when switching to Settings
slideInHorizontally { fullWidth -> fullWidth } togetherWith
slideOutHorizontally { fullWidth -> -fullWidth }
} else {
// Slide in from left when switching back to Conversations
slideInHorizontally { fullWidth -> -fullWidth } togetherWith
slideOutHorizontally { fullWidth -> fullWidth }
}
},
label = "SidebarModeTransition",
) { mode ->
when (mode) {
is SidebarMode.Conversations -> {
DrawerContent(
viewModel = viewModel,
onNewChat = onNewChat,
onConversationClick = onConversationClick,
onSettingsClick = onSettingsClick,
onAgentsClick = onAgentsClick,
onFilesClick = onFilesClick,
)
}
is SidebarMode.Settings -> {
SettingsSidebarContent(
selectedCategory = selectedCategory,
onBackToConversations = {
viewModel.setSidebarMode(SidebarMode.Conversations)
},
onCategorySelected = { category ->
viewModel.selectSettingsCategory(category)
onSettingsCategorySelected(category)
},
)
}
}
}
}

View file

@ -1,6 +1,5 @@
package com.garfiec.librechat.navigation
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
@ -27,16 +26,17 @@ import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavKey
import com.garfiec.librechat.MainActivity
import com.garfiec.librechat.core.ui.components.BannerDisplay
import com.garfiec.librechat.shared.navigation.NavHostViewModel
import com.garfiec.librechat.shared.navigation.Navigator
import com.garfiec.librechat.shared.navigation.MainNavDisplay
import com.garfiec.librechat.shared.navigation.SidebarScaffold
import com.garfiec.librechat.shared.navigation.toRoute
import com.garfiec.librechat.feature.agents.navigation.AgentMarketplace
import com.garfiec.librechat.feature.chat.navigation.Chat
import com.garfiec.librechat.feature.chat.navigation.NewChat
import com.garfiec.librechat.feature.files.navigation.Files
import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed
import kotlinx.coroutines.launch
import co.touchlab.kermit.Logger
private val SidebarWidth = 320.dp
@ -47,10 +47,7 @@ private const val FLING_VELOCITY_THRESHOLD = 800f
fun TabletLayout(
navigator: Navigator,
navHostViewModel: NavHostViewModel,
deepLinkUri: Uri?,
onDeepLinkConsumed: () -> Unit,
modifier: Modifier = Modifier,
shareNavigationTrigger: Int = 0,
) {
// Banner state only -- drawer state is collected inside DrawerContent itself
val banners by navHostViewModel.banners.collectAsStateWithLifecycle()
@ -74,33 +71,6 @@ fun TabletLayout(
navHostViewModel.setTabletSidebarOpen(false)
}
// Handle deep links
LaunchedEffect(deepLinkUri) {
deepLinkUri?.let { uri ->
if (uri.scheme == "librechat" && uri.host == "conversation") {
uri.lastPathSegment?.let { conversationId ->
if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) {
navigator.navigateToChat(conversationId)
} else {
Logger.w { "Ignoring deep link with invalid conversation ID: $conversationId" }
}
}
}
onDeepLinkConsumed()
}
}
// Handle share intent
LaunchedEffect(shareNavigationTrigger) {
if (shareNavigationTrigger > 0) {
val currentRoute = navigator.currentRoute
val isOnChatScreen = currentRoute is Chat || currentRoute is NewChat
if (!isOnChatScreen) {
navigator.navigateToTopLevel(NewChat)
}
}
}
// Sync: when ViewModel state changes (e.g. hamburger button, back press),
// animate the sidebar to match.
LaunchedEffect(isSidebarOpen) {
@ -273,7 +243,7 @@ private fun MainContent(
MainNavDisplay(
navigator = navigator,
navHostViewModel = navHostViewModel,
onOpenDrawer = onToggleDrawer,
onMenuClick = onToggleDrawer,
modifier = Modifier.fillMaxSize(),
)
}

View file

@ -62,7 +62,7 @@ import com.garfiec.librechat.feature.conversations.di.conversationsModule
import com.garfiec.librechat.feature.files.di.filesModule
import com.garfiec.librechat.feature.files.platform.FileReader
import com.garfiec.librechat.feature.settings.di.settingsModule
import com.garfiec.librechat.navigation.appModule
import com.garfiec.librechat.shared.navigation.sharedAppModule
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import org.junit.Test
@ -76,7 +76,7 @@ class KoinGraphVerificationTest {
commonModule,
networkModule,
dataModule,
appModule,
sharedAppModule,
authModule,
chatModule,
conversationsModule,

View file

@ -2,6 +2,7 @@ package com.garfiec.librechat.navigation
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import com.garfiec.librechat.shared.navigation.Navigator
import com.garfiec.librechat.feature.agents.navigation.AgentMarketplace
import com.garfiec.librechat.feature.auth.navigation.Login
import com.garfiec.librechat.feature.auth.navigation.ServerUrl

View file

@ -0,0 +1,12 @@
package com.garfiec.librechat.core.common.extensions
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
actual fun <T> firstBlocking(flow: Flow<T>, default: T): T =
try {
runBlocking { flow.first() }
} catch (_: Exception) {
default
}

View file

@ -0,0 +1,12 @@
package com.garfiec.librechat.core.common.extensions
import kotlinx.coroutines.flow.Flow
/**
* Reads the first value from [flow] synchronously via `runBlocking`, returning
* [default] if the flow is empty or the read fails for any reason.
*
* Used so that initial UI state (e.g. persisted sidebar open/close) is available
* before first composition, avoiding visible flicker on cold start.
*/
expect fun <T> firstBlocking(flow: Flow<T>, default: T): T

View file

@ -0,0 +1,5 @@
package com.garfiec.librechat.core.common.extensions
import kotlinx.coroutines.flow.Flow
actual fun <T> firstBlocking(flow: Flow<T>, default: T): T = default

View file

@ -86,6 +86,7 @@ fun SettingsCategory.toRoute(): SettingsRoute = when (this) {
fun LibreChatNavHost(
modifier: Modifier = Modifier,
navHostViewModel: NavHostViewModel = koinViewModel(),
content: (@Composable (Navigator, NavHostViewModel, Modifier) -> Unit)? = null,
) {
val isLoggedIn by navHostViewModel.isLoggedIn.collectAsStateWithLifecycle()
@ -113,11 +114,15 @@ fun LibreChatNavHost(
}
}
PhoneLayout(
navigator = navigator,
navHostViewModel = navHostViewModel,
modifier = modifier,
)
if (content != null) {
content(navigator, navHostViewModel, modifier)
} else {
PhoneLayout(
navigator = navigator,
navHostViewModel = navHostViewModel,
modifier = modifier,
)
}
// Version mismatch warning dialog
val versionMismatch by navHostViewModel.versionMismatch.collectAsStateWithLifecycle()
@ -165,8 +170,9 @@ private fun VersionMismatchDialog(
)
}
/** Default phone layout with modal drawer sidebar. Used by iOS directly and by Android as the non-tablet path. */
@Composable
private fun PhoneLayout(
fun PhoneLayout(
navigator: Navigator,
navHostViewModel: NavHostViewModel,
modifier: Modifier = Modifier,
@ -228,88 +234,103 @@ private fun PhoneLayout(
modifier = Modifier.padding(top = 4.dp),
)
}
NavDisplay(
backStack = navigator.backStack,
onBack = { navigator.goBack() },
transitionSpec = {
slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(300)) togetherWith
fadeOut(animationSpec = tween(150))
},
popTransitionSpec = {
(fadeIn(initialAlpha = 0.5f, animationSpec = tween(300, easing = LinearEasing)) +
scaleIn(initialScale = 0.92f, animationSpec = tween(300, easing = LinearEasing))) togetherWith
(slideOutHorizontally(
targetOffsetX = { (it * 0.15f).toInt() },
animationSpec = tween(300, easing = FastOutSlowInEasing),
) + scaleOut(
targetScale = 0.92f,
animationSpec = tween(300, easing = LinearEasing),
))
},
predictivePopTransitionSpec = {
(fadeIn(initialAlpha = 0.5f, animationSpec = tween(300, easing = LinearEasing)) +
scaleIn(initialScale = 0.92f, animationSpec = tween(300, easing = LinearEasing))) togetherWith
(slideOutHorizontally(
targetOffsetX = { (it * 0.15f).toInt() },
animationSpec = tween(300, easing = FastOutSlowInEasing),
) + scaleOut(
targetScale = 0.92f,
animationSpec = tween(300, easing = LinearEasing),
))
},
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
entryProvider = entryProvider {
authEntries(
onNavigate = { navigator.navigate(it) },
onBack = { navigator.goBack() },
onAuthComplete = {
navHostViewModel.onAuthComplete()
navigator.navigateToChat()
},
)
chatEntries(
onNavigate = { navigator.navigate(it) },
onBack = { navigator.goBack() },
onNavigateToChat = { navigator.navigateToChat(it) },
onOpenDrawer = {
scope.launch { drawerState.open() }
},
)
conversationsEntries(
onConversationClick = { navigator.navigateToChat(it) },
onNavigateToArchived = { navigator.navigate(ArchivedConversations) },
onBack = { navigator.goBack() },
)
agentsEntries(
onNavigate = { navigator.navigate(it) },
onBack = { navigator.goBack() },
onStartChat = { _ ->
navigator.navigateToTopLevel(NewChat)
},
)
filesEntries(
onBack = { navigator.goBack() },
)
settingsEntries(
onNavigate = { navigator.navigate(it) },
onBack = { navigator.goBack() },
onLogout = {
navHostViewModel.logout()
navigator.navigateToAuth()
},
onNavigateToArchived = { navigator.navigate(ArchivedConversations) },
)
memoriesEntry(
onBack = { navigator.goBack() },
)
mcpServersEntry(
onBack = { navigator.goBack() },
)
},
MainNavDisplay(
navigator = navigator,
navHostViewModel = navHostViewModel,
onMenuClick = { scope.launch { drawerState.open() } },
modifier = Modifier.fillMaxSize(),
)
}
}
}
/** Core NavDisplay with entry providers for all feature modules. Used by both PhoneLayout and Android's TabletLayout. */
@Composable
fun MainNavDisplay(
navigator: Navigator,
navHostViewModel: NavHostViewModel,
onMenuClick: (() -> 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 = onMenuClick,
)
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

@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import co.touchlab.kermit.Logger
import com.garfiec.librechat.core.common.extensions.firstBlocking
import com.garfiec.librechat.core.common.extensions.formatMonthAbbrev
import kotlin.time.Clock
import kotlin.time.Instant
@ -107,7 +108,7 @@ class NavHostViewModel(
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
initialValue = false, // Avoid runBlocking — not safe on iOS/Kotlin Native main thread
firstBlocking(settingsDataStore.tabletSidebarOpen, false),
)
val tabletSidebarGestureEnabled: StateFlow<Boolean> = settingsDataStore.tabletSidebarGestureEnabled