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