diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/datastore/ConfigCacheDataStore.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/datastore/ConfigCacheDataStore.kt index f8ebbd5..4d16c72 100644 --- a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/datastore/ConfigCacheDataStore.kt +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/datastore/ConfigCacheDataStore.kt @@ -88,6 +88,18 @@ class ConfigCacheDataStore( } } + suspend fun clear() { + try { + dataStore.edit { prefs -> + prefs.remove(KEY_STARTUP_CONFIG) + prefs.remove(KEY_ENDPOINT_CONFIGS) + prefs.remove(KEY_AVAILABLE_MODELS) + } + } catch (e: Exception) { + Logger.w(e) { "Failed to clear cached config" } + } + } + companion object { private val KEY_STARTUP_CONFIG = stringPreferencesKey("cached_startup_config") private val KEY_ENDPOINT_CONFIGS = stringPreferencesKey("cached_endpoint_configs") diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/di/DataModule.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/di/DataModule.kt index cfd8e70..e118c5d 100644 --- a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/di/DataModule.kt +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/di/DataModule.kt @@ -53,6 +53,7 @@ import com.garfiec.librechat.core.data.repository.TagRepository import com.garfiec.librechat.core.data.repository.TagRepositoryImpl import com.garfiec.librechat.core.data.repository.UserRepository import com.garfiec.librechat.core.data.repository.UserRepositoryImpl +import com.garfiec.librechat.core.data.util.EndpointConfigFetchSessionTask import com.garfiec.librechat.core.data.util.PermissionGate import com.garfiec.librechat.core.data.util.RefreshTagsSessionTask import com.garfiec.librechat.core.data.util.RoleFetchSessionTask @@ -127,6 +128,7 @@ val dataModule = module { singleOf(::RoleFetchSessionTask) bind SessionTask::class singleOf(::RefreshTagsSessionTask) bind SessionTask::class singleOf(::SyncFavoritesSessionTask) bind SessionTask::class + singleOf(::EndpointConfigFetchSessionTask) bind SessionTask::class single { SessionTaskRunner( tasks = getAll(), diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/ConfigRepository.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/ConfigRepository.kt index 62a0360..14f3fb4 100644 --- a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/ConfigRepository.kt +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/ConfigRepository.kt @@ -47,4 +47,7 @@ interface ConfigRepository { * [VersionCheckResult.isCompatible] = true (fail-open). */ suspend fun checkBackendVersion(): Result + + /** Clear all cached config state (in-memory + disk). Called on logout. */ + suspend fun clear() } diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/ConfigRepositoryImpl.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/ConfigRepositoryImpl.kt index 4ff10c2..55b3110 100644 --- a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/ConfigRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/repository/ConfigRepositoryImpl.kt @@ -186,4 +186,12 @@ class ConfigRepositoryImpl( } } } + + override suspend fun clear() { + _endpointConfigs.value = emptyMap() + _availableModels.value = emptyMap() + _startupConfig.value = null + _detectedBackendVersion.value = null + configCache.clear() + } } diff --git a/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/util/EndpointConfigFetchSessionTask.kt b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/util/EndpointConfigFetchSessionTask.kt new file mode 100644 index 0000000..2755237 --- /dev/null +++ b/core/data/src/commonMain/kotlin/com/garfiec/librechat/core/data/util/EndpointConfigFetchSessionTask.kt @@ -0,0 +1,17 @@ +package com.garfiec.librechat.core.data.util + +import com.garfiec.librechat.core.data.repository.ConfigRepository + +/** + * Eagerly fetches `/api/config` endpoint configs when a session starts so the drawer's + * first frame after cold-start (or fresh login) can resolve `EndpointConfig.iconURL` + * for custom endpoints. Without this task, the drawer races against the chat screen's + * lazy fetch — empty configs leak through as missing icons until the user taps a chat. + */ +class EndpointConfigFetchSessionTask( + private val configRepository: ConfigRepository, +) : SessionTask { + override suspend fun run() { + configRepository.fetchEndpoints() + } +} diff --git a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/Conversation.kt b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/Conversation.kt index af090b0..7cf38d0 100644 --- a/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/Conversation.kt +++ b/core/model/src/commonMain/kotlin/com/garfiec/librechat/core/model/Conversation.kt @@ -37,3 +37,13 @@ data class Conversation( val createdAt: String? = null, val updatedAt: String? = null, ) + +/** + * Resolves the icon URL to display for this conversation, preferring the per-conversation + * [iconURL] (set by agents/assistants threads) over the per-endpoint URL from + * [EndpointConfig.iconURL]. Returns null when no URL is available — callers fall through + * to the bundled brand glyph or Material fallback. + */ +fun Conversation.resolveEndpointIconUrl( + endpointConfigs: Map, +): String? = iconURL ?: endpoint?.let { endpointConfigs[it]?.iconURL } diff --git a/core/ui/src/commonMain/kotlin/com/garfiec/librechat/core/ui/components/EndpointIcons.kt b/core/ui/src/commonMain/kotlin/com/garfiec/librechat/core/ui/components/EndpointIcons.kt index 2922855..4292528 100644 --- a/core/ui/src/commonMain/kotlin/com/garfiec/librechat/core/ui/components/EndpointIcons.kt +++ b/core/ui/src/commonMain/kotlin/com/garfiec/librechat/core/ui/components/EndpointIcons.kt @@ -1,7 +1,28 @@ package com.garfiec.librechat.core.ui.components +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AutoAwesome +import androidx.compose.material.icons.outlined.Create +import androidx.compose.material.icons.outlined.SmartToy +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.garfiec.librechat.core.common.EndpointConstants import com.garfiec.librechat.core.model.EModelEndpoint import com.garfiec.librechat.core.ui.resources.Res import com.garfiec.librechat.core.ui.resources.ic_agents @@ -53,3 +74,75 @@ fun isMonochromeEndpointIcon(endpoint: String?): Boolean = when (endpoint) { "openAI", "assistants", "agents" -> true else -> false } + +/** + * Material icon fallback for endpoint names that don't have a bundled drawable. + * Used by both the model picker and the conversation/drawer rows so unknown endpoint + * names render consistently across surfaces. + */ +internal fun endpointFallbackIconVector(endpointName: String?): ImageVector = when (endpointName) { + EndpointConstants.AGENTS -> Icons.Outlined.Create + "assistants", "azureAssistants" -> Icons.Outlined.AutoAwesome + else -> Icons.Outlined.SmartToy +} + +/** + * Renders an endpoint icon, preferring a remote URL when supplied (for custom endpoints + * with `iconURL` configured) and falling back to the bundled brand glyph or a Material + * icon when no URL or brand drawable is available. + * + * Mirrors the web frontend's `URLIcon`/`MinimalIcon` precedence (without the red + * AlertCircle overlay on load failure — mobile silently falls back to the glyph). + * + * [glyphTint] colors monochrome bundled glyphs and the unknown-endpoint Material fallback. + * Brand-colored painters (Anthropic, Google, Azure, Bedrock) ignore this and render with + * their own colors. The size in [modifier] is overridden by the [size] parameter. + */ +@Composable +fun EndpointIcon( + endpointName: String?, + iconUrl: String?, + modifier: Modifier = Modifier, + size: Dp = 24.dp, + contentDescription: String? = null, + glyphTint: Color = MaterialTheme.colorScheme.onSurfaceVariant, +) { + var loadFailed by remember(iconUrl) { mutableStateOf(false) } + if (iconUrl != null && !loadFailed) { + AsyncImage( + model = iconUrl, + contentDescription = contentDescription, + modifier = modifier.size(size).clip(CircleShape), + contentScale = ContentScale.Crop, + onError = { loadFailed = true }, + ) + } else { + EndpointGlyph(endpointName, size, contentDescription, glyphTint, modifier) + } +} + +@Composable +private fun EndpointGlyph( + endpointName: String?, + size: Dp, + contentDescription: String?, + glyphTint: Color, + modifier: Modifier = Modifier, +) { + val painter = endpointIconPainter(endpointName) + if (painter != null) { + Icon( + painter = painter, + contentDescription = contentDescription, + modifier = modifier.size(size), + tint = if (isMonochromeEndpointIcon(endpointName)) glyphTint else Color.Unspecified, + ) + } else { + Icon( + imageVector = endpointFallbackIconVector(endpointName), + contentDescription = contentDescription, + modifier = modifier.size(size), + tint = glyphTint, + ) + } +} diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/components/ModelSelectorSheet.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/components/ModelSelectorSheet.kt index 24a84b9..3192311 100644 --- a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/components/ModelSelectorSheet.kt +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/components/ModelSelectorSheet.kt @@ -21,10 +21,8 @@ import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.StarBorder -import androidx.compose.material.icons.outlined.AutoAwesome import androidx.compose.material.icons.outlined.Create import androidx.compose.material.icons.outlined.Public -import androidx.compose.material.icons.outlined.SmartToy import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -43,16 +41,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.garfiec.librechat.core.common.EndpointConstants import com.garfiec.librechat.core.model.Agent import com.garfiec.librechat.core.model.EndpointConfig +import com.garfiec.librechat.core.ui.components.EndpointIcon import com.garfiec.librechat.core.ui.components.ErrorBanner -import com.garfiec.librechat.core.ui.components.endpointIconPainter -import com.garfiec.librechat.core.ui.components.isMonochromeEndpointIcon import com.garfiec.librechat.feature.chat.resources.* import com.garfiec.librechat.feature.chat.resources.Res import com.garfiec.librechat.feature.chat.util.FuzzyMatch @@ -78,15 +74,6 @@ private fun fuzzyScore(candidate: String, query: String): Int { return FuzzyMatch.partialRatio(query, candidate) } -/** - * Returns a Material icon fallback for endpoint names that don't have a bundled drawable. - */ -private fun endpointFallbackIcon(endpointName: String): ImageVector = when (endpointName) { - EndpointConstants.AGENTS -> Icons.Outlined.Create - "assistants", "azureAssistants" -> Icons.Outlined.AutoAwesome - else -> Icons.Outlined.SmartToy -} - @OptIn(ExperimentalMaterial3Api::class) @Composable fun ModelSelectorSheet( @@ -322,6 +309,9 @@ private fun EndpointGroupHeader( EndpointIcon( endpointName = endpointName, iconUrl = iconUrl, + size = IconSize, + contentDescription = "$endpointName icon", + glyphTint = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.width(8.dp)) Text( @@ -341,47 +331,6 @@ private fun EndpointGroupHeader( } } -@Composable -private fun EndpointIcon( - endpointName: String, - iconUrl: String?, -) { - // If the endpoint config provides a remote icon URL, use it - if (iconUrl != null) { - AsyncImage( - model = iconUrl, - contentDescription = "$endpointName icon", - modifier = Modifier - .size(IconSize) - .clip(CircleShape), - contentScale = ContentScale.Crop, - ) - return - } - // Use the multiplatform endpointIconPainter API - val painter = endpointIconPainter(endpointName) - if (painter != null) { - val tint = if (isMonochromeEndpointIcon(endpointName)) { - MaterialTheme.colorScheme.onSurface - } else { - Color.Unspecified - } - Icon( - painter = painter, - contentDescription = "$endpointName icon", - modifier = Modifier.size(IconSize), - tint = tint, - ) - } else { - Icon( - imageVector = endpointFallbackIcon(endpointName), - contentDescription = "$endpointName icon", - modifier = Modifier.size(IconSize), - tint = MaterialTheme.colorScheme.onSurface, - ) - } -} - @Composable private fun LazyItemScope.AgentListItem( agent: Agent, diff --git a/feature/conversations/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/conversations/viewmodel/ConversationListViewModelTest.kt b/feature/conversations/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/conversations/viewmodel/ConversationListViewModelTest.kt index d4cdc48..713d24a 100644 --- a/feature/conversations/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/conversations/viewmodel/ConversationListViewModelTest.kt +++ b/feature/conversations/src/androidUnitTest/kotlin/com/garfiec/librechat/feature/conversations/viewmodel/ConversationListViewModelTest.kt @@ -1,6 +1,7 @@ package com.garfiec.librechat.feature.conversations.viewmodel import com.garfiec.librechat.core.common.result.Result +import com.garfiec.librechat.core.data.repository.ConfigRepository import com.garfiec.librechat.core.data.repository.ConversationRepository import com.garfiec.librechat.core.data.repository.RoleRepository import com.garfiec.librechat.core.data.repository.SearchRepository @@ -8,6 +9,7 @@ import com.garfiec.librechat.core.data.repository.ShareRepository import com.garfiec.librechat.core.data.repository.TagRepository import com.garfiec.librechat.core.model.Conversation import com.garfiec.librechat.core.model.ConversationTag +import com.garfiec.librechat.core.model.EndpointConfig import com.garfiec.librechat.core.model.SAVED_TAG import com.garfiec.librechat.feature.conversations.export.ConversationExporter import com.garfiec.librechat.feature.conversations.export.ConversationImporter @@ -42,6 +44,10 @@ class ConversationListViewModelTest { private val conversationExporter = mockk(relaxed = true) private val conversationImporter = mockk(relaxed = true) private val roleRepository = mockk(relaxed = true) + private val endpointConfigsFlow = MutableStateFlow>(emptyMap()) + private val configRepository = mockk(relaxed = true) { + every { endpointConfigs } returns endpointConfigsFlow + } private lateinit var viewModel: ConversationListViewModel @@ -92,6 +98,7 @@ class ConversationListViewModelTest { conversationExporter = conversationExporter, conversationImporter = conversationImporter, roleRepository = roleRepository, + configRepository = configRepository, ) @Test @@ -539,4 +546,37 @@ class ConversationListViewModelTest { tagRepository.toggleFavorite(any(), any()) } } + + @Test + fun `endpointConfigs arriving late re-groups rows with iconURL`() = runTest { + // Conversation has no per-convo iconURL; relies on the endpoint config fallback. + val convo = Conversation( + conversationId = "convo-icon-1", + title = "OpenRouter chat", + endpoint = "OpenRouter", + iconURL = null, + updatedAt = "2026-02-19T10:00:00.000Z", + ) + every { conversationRepository.observeConversations(any()) } returns flowOf( + Result.Success(listOf(convo)), + ) + + viewModel = createViewModel() + advanceUntilIdle() + + // Initial frame: configs are empty; endpointIconUrl must be null. + val initialFlat = viewModel.uiState.value.groupedConversations.flatMap { it.second } + assertThat(initialFlat).hasSize(1) + assertThat(initialFlat[0].endpointIconUrl).isNull() + + // Configs arrive after the first emission. + endpointConfigsFlow.value = mapOf( + "OpenRouter" to EndpointConfig(iconURL = "https://openrouter.ai/favicon.ico"), + ) + advanceUntilIdle() + + val updatedFlat = viewModel.uiState.value.groupedConversations.flatMap { it.second } + assertThat(updatedFlat).hasSize(1) + assertThat(updatedFlat[0].endpointIconUrl).isEqualTo("https://openrouter.ai/favicon.ico") + } } diff --git a/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/components/ConversationDisplayData.kt b/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/components/ConversationDisplayData.kt index a99669e..d215202 100644 --- a/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/components/ConversationDisplayData.kt +++ b/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/components/ConversationDisplayData.kt @@ -2,7 +2,9 @@ package com.garfiec.librechat.feature.conversations.components import androidx.compose.runtime.Immutable import com.garfiec.librechat.core.model.Conversation +import com.garfiec.librechat.core.model.EndpointConfig import com.garfiec.librechat.core.model.SAVED_TAG +import com.garfiec.librechat.core.model.resolveEndpointIconUrl @Immutable data class ConversationDisplayData( @@ -12,13 +14,17 @@ data class ConversationDisplayData( val model: String?, val updatedAt: String?, val isBookmarked: Boolean, + val endpointIconUrl: String? = null, ) -fun Conversation.toDisplayData() = ConversationDisplayData( +fun Conversation.toDisplayData( + endpointConfigs: Map, +): ConversationDisplayData = ConversationDisplayData( conversationId = conversationId ?: "", title = title ?: "New Chat", endpoint = endpoint, model = model, updatedAt = updatedAt, isBookmarked = SAVED_TAG in tags, + endpointIconUrl = resolveEndpointIconUrl(endpointConfigs), ) diff --git a/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/components/ConversationItem.kt b/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/components/ConversationItem.kt index 5357dd2..013c785 100644 --- a/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/components/ConversationItem.kt +++ b/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/components/ConversationItem.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.SmartToy import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -20,13 +19,11 @@ import androidx.compose.runtime.Composable 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.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.garfiec.librechat.core.common.extensions.toInstantOrNull import com.garfiec.librechat.core.model.EModelEndpoint -import com.garfiec.librechat.core.ui.components.endpointIconPainter -import com.garfiec.librechat.core.ui.components.isMonochromeEndpointIcon +import com.garfiec.librechat.core.ui.components.EndpointIcon import com.garfiec.librechat.feature.conversations.resources.* import com.garfiec.librechat.feature.conversations.resources.Res import kotlinx.datetime.TimeZone @@ -51,8 +48,6 @@ fun ConversationItem( data.endpoint?.toDisplayLabel() ?: "Chat" } - val iconPainter = data.endpoint?.let { endpointIconPainter(it) } - Row( modifier = modifier .fillMaxWidth() @@ -60,26 +55,12 @@ fun ConversationItem( .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { - if (iconPainter != null) { - val isMonochrome = isMonochromeEndpointIcon(data.endpoint) - Icon( - painter = iconPainter, - contentDescription = endpointLabel, - modifier = Modifier.size(24.dp), - tint = if (isMonochrome) { - MaterialTheme.colorScheme.onSurfaceVariant - } else { - Color.Unspecified - }, - ) - } else { - Icon( - imageVector = Icons.Default.SmartToy, - contentDescription = endpointLabel, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + EndpointIcon( + endpointName = data.endpoint, + iconUrl = data.endpointIconUrl, + size = 24.dp, + contentDescription = endpointLabel, + ) Spacer(modifier = Modifier.width(12.dp)) @@ -106,7 +87,7 @@ fun ConversationItem( val modelName = data.model if (modelName != null) { Text( - text = " \u00B7 ", + text = " · ", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline, ) @@ -122,7 +103,7 @@ fun ConversationItem( if (relativeTime.isNotEmpty()) { Text( - text = " \u00B7 ", + text = " · ", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline, ) diff --git a/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/viewmodel/ConversationListViewModel.kt b/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/viewmodel/ConversationListViewModel.kt index 160ab23..286716e 100644 --- a/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/viewmodel/ConversationListViewModel.kt +++ b/feature/conversations/src/commonMain/kotlin/com/garfiec/librechat/feature/conversations/viewmodel/ConversationListViewModel.kt @@ -7,6 +7,7 @@ import co.touchlab.kermit.Logger 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.ConfigRepository import com.garfiec.librechat.core.data.repository.ConversationRepository import com.garfiec.librechat.core.data.repository.RoleRepository import com.garfiec.librechat.core.data.repository.SearchRepository @@ -14,6 +15,7 @@ import com.garfiec.librechat.core.data.repository.ShareRepository import com.garfiec.librechat.core.data.repository.TagRepository import com.garfiec.librechat.core.model.Conversation import com.garfiec.librechat.core.model.ConversationTag +import com.garfiec.librechat.core.model.EndpointConfig import com.garfiec.librechat.core.model.SAVED_TAG import com.garfiec.librechat.core.model.permissions.Permission import com.garfiec.librechat.core.model.permissions.PermissionType @@ -32,6 +34,7 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch @Immutable @@ -67,6 +70,7 @@ class ConversationListViewModel( private val conversationExporter: ConversationExporter, private val conversationImporter: ConversationImporter, private val roleRepository: RoleRepository, + private val configRepository: ConfigRepository, ) : ViewModel() { private val _uiState = MutableStateFlow(ConversationListUiState()) @@ -76,7 +80,7 @@ class ConversationListViewModel( val events: SharedFlow = _events.asSharedFlow() /** Raw conversations kept for internal lookups (action sheets, etc.). */ - private var conversations: List = emptyList() + private val _conversations = MutableStateFlow>(emptyList()) private var searchJob: Job? = null init { @@ -84,6 +88,7 @@ class ConversationListViewModel( observeConversations() observeTags() observePermissions() + observeGroupedConversations() } private fun observePermissions() { @@ -106,8 +111,7 @@ class ConversationListViewModel( is Result.Success -> { // Only update from Room when not in search mode if (_uiState.value.searchQuery.isEmpty()) { - conversations = result.data - recomputeGroupedConversations() + _conversations.value = result.data _uiState.value = _uiState.value.copy(isLoading = false) } } @@ -133,16 +137,21 @@ class ConversationListViewModel( } } - private fun recomputeGroupedConversations() { - val grouped = groupConversationsByDate(conversations) - _uiState.value = _uiState.value.copy( - groupedConversations = grouped, - conversationCount = conversations.size, - ) + private fun observeGroupedConversations() { + viewModelScope.launch { + combine(_conversations, configRepository.endpointConfigs) { convos, configs -> + groupConversationsByDate(convos, configs) to convos.size + }.collect { (grouped, count) -> + _uiState.value = _uiState.value.copy( + groupedConversations = grouped, + conversationCount = count, + ) + } + } } fun getConversation(conversationId: String): Conversation? = - conversations.firstOrNull { it.conversationId == conversationId } + _conversations.value.firstOrNull { it.conversationId == conversationId } fun toggleFavorite(conversation: Conversation) { val id = conversation.conversationId ?: return @@ -260,8 +269,7 @@ class ConversationListViewModel( _uiState.value = _uiState.value.copy(isSearching = true) when (val result = searchRepository.search(query)) { is Result.Success -> { - conversations = result.data - recomputeGroupedConversations() + _conversations.value = result.data _uiState.value = _uiState.value.copy( isSearching = false, hasMore = false, @@ -452,6 +460,7 @@ class ConversationListViewModel( companion object { private fun groupConversationsByDate( conversations: List, + endpointConfigs: Map, ): List>> { if (conversations.isEmpty()) return emptyList() return conversations @@ -462,7 +471,7 @@ class ConversationListViewModel( ?: "Unknown" } .map { (group, convos) -> - group to convos.map { it.toDisplayData() } + group to convos.map { it.toDisplayData(endpointConfigs) } } } } diff --git a/feature/conversations/src/commonTest/kotlin/com/garfiec/librechat/feature/conversations/components/ConversationDisplayDataTest.kt b/feature/conversations/src/commonTest/kotlin/com/garfiec/librechat/feature/conversations/components/ConversationDisplayDataTest.kt new file mode 100644 index 0000000..36ce4a6 --- /dev/null +++ b/feature/conversations/src/commonTest/kotlin/com/garfiec/librechat/feature/conversations/components/ConversationDisplayDataTest.kt @@ -0,0 +1,72 @@ +package com.garfiec.librechat.feature.conversations.components + +import com.garfiec.librechat.core.model.Conversation +import com.garfiec.librechat.core.model.EndpointConfig +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ConversationDisplayDataTest { + + @Test + fun iconURLPrefersConversationOverEndpointConfig() { + val convo = Conversation( + conversationId = "c1", + endpoint = "OpenRouter", + iconURL = "https://convo-icon.example.com/icon.png", + ) + val configs = mapOf( + "OpenRouter" to EndpointConfig(iconURL = "https://config-icon.example.com/icon.png"), + ) + + val data = convo.toDisplayData(configs) + + assertEquals("https://convo-icon.example.com/icon.png", data.endpointIconUrl) + } + + @Test + fun iconURLFallsBackToEndpointConfig() { + val convo = Conversation( + conversationId = "c1", + endpoint = "OpenRouter", + iconURL = null, + ) + val configs = mapOf( + "OpenRouter" to EndpointConfig(iconURL = "https://config-icon.example.com/icon.png"), + ) + + val data = convo.toDisplayData(configs) + + assertEquals("https://config-icon.example.com/icon.png", data.endpointIconUrl) + } + + @Test + fun iconURLNullWhenNeitherSet() { + val convo = Conversation( + conversationId = "c1", + endpoint = "OpenRouter", + iconURL = null, + ) + val configs = mapOf( + "OpenRouter" to EndpointConfig(iconURL = null), + ) + + val data = convo.toDisplayData(configs) + + assertNull(data.endpointIconUrl) + } + + @Test + fun iconURLNullWhenEndpointMissingFromConfig() { + // Cold-start race: configs map empty for that key — composable falls back to glyph. + val convo = Conversation( + conversationId = "c1", + endpoint = "OpenRouter", + iconURL = null, + ) + + val data = convo.toDisplayData(endpointConfigs = emptyMap()) + + assertNull(data.endpointIconUrl) + } +} diff --git a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerContent.kt b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerContent.kt index a0d4f19..e45289d 100644 --- a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerContent.kt +++ b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerContent.kt @@ -45,15 +45,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState 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.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.core.ui.components.endpointIconPainter -import com.garfiec.librechat.core.ui.components.isMonochromeEndpointIcon +import com.garfiec.librechat.core.ui.components.EndpointIcon import com.garfiec.librechat.shared.resources.Res import com.garfiec.librechat.shared.resources.agents import com.garfiec.librechat.shared.resources.bookmark @@ -365,8 +363,6 @@ private fun DrawerConversationItem( onToggleFavorite: () -> Unit = {}, showBookmarkToggle: Boolean = true, ) { - val iconPainter = data.endpoint?.let { endpointIconPainter(it) } - val backgroundColor = if (data.isActive) { MaterialTheme.colorScheme.secondaryContainer } else { @@ -399,24 +395,12 @@ private fun DrawerConversationItem( modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.primary, ) - } else if (iconPainter != null) { - val isMonochrome = isMonochromeEndpointIcon(data.endpoint) - Icon( - painter = iconPainter, - 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) { + EndpointIcon( + endpointName = data.endpoint, + iconUrl = data.endpointIconUrl, + size = 18.dp, + glyphTint = if (data.isActive) { MaterialTheme.colorScheme.primary } else { MaterialTheme.colorScheme.onSurfaceVariant diff --git a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerDisplayMapping.kt b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerDisplayMapping.kt index 6d1463a..278a834 100644 --- a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerDisplayMapping.kt +++ b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerDisplayMapping.kt @@ -3,7 +3,9 @@ package com.garfiec.librechat.shared.navigation import com.garfiec.librechat.core.common.extensions.formatMonthAbbrev import com.garfiec.librechat.core.common.extensions.toInstantOrNull import com.garfiec.librechat.core.model.Conversation +import com.garfiec.librechat.core.model.EndpointConfig import com.garfiec.librechat.core.model.SAVED_TAG +import com.garfiec.librechat.core.model.resolveEndpointIconUrl import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import kotlin.time.Clock @@ -18,6 +20,7 @@ internal data class DrawerDataSnapshot( internal fun Conversation.toDrawerDisplayData( activeConversationId: String?, + endpointConfigs: Map, ): DrawerConversationDisplayData { val convId = conversationId ?: "" return DrawerConversationDisplayData( @@ -29,6 +32,7 @@ internal fun Conversation.toDrawerDisplayData( isActive = convId == activeConversationId, isFavorite = SAVED_TAG in tags, tags = tags, + endpointIconUrl = resolveEndpointIconUrl(endpointConfigs), ) } diff --git a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerUiModels.kt b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerUiModels.kt index b61fd5d..06f9a6a 100644 --- a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerUiModels.kt +++ b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/DrawerUiModels.kt @@ -16,6 +16,7 @@ data class DrawerConversationDisplayData( val isActive: Boolean, val isFavorite: Boolean, val tags: List, + val endpointIconUrl: String? = null, ) /** diff --git a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/NavHostViewModel.kt b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/NavHostViewModel.kt index 5c6747f..5081815 100644 --- a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/NavHostViewModel.kt +++ b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/NavHostViewModel.kt @@ -33,7 +33,7 @@ import kotlinx.coroutines.launch class NavHostViewModel( private val authRepository: AuthRepository, bannerRepository: BannerRepository, - configRepository: ConfigRepository, + private val configRepository: ConfigRepository, private val conversationRepository: ConversationRepository, private val roleRepository: RoleRepository, private val sessionTaskRunner: SessionTaskRunner, @@ -117,12 +117,16 @@ class NavHostViewModel( Triple(refreshing, loadingMore, hasMore) }, drawerPermissionFlags, - ) { data, (refreshing, loadingMore, hasMore), perms -> + configRepository.endpointConfigs, + ) { data, refreshState, perms, endpointConfigs -> + val (refreshing, loadingMore, hasMore) = refreshState DrawerUiState( groupedConversations = data.grouped.map { (group, convos) -> - group to convos.map { it.toDrawerDisplayData(data.activeId) } + group to convos.map { it.toDrawerDisplayData(data.activeId, endpointConfigs) } + }, + favoriteConversations = data.favConvos.map { + it.toDrawerDisplayData(data.activeId, endpointConfigs) }, - favoriteConversations = data.favConvos.map { it.toDrawerDisplayData(data.activeId) }, searchQuery = data.query, isRefreshing = refreshing, isLoadingMore = loadingMore, @@ -216,6 +220,7 @@ class NavHostViewModel( _isLoggedIn.value = false conversationListStateHolder.reset() tagRepository.clearCache() + configRepository.clear() _sidebarMode.value = SidebarMode.Conversations _selectedSettingsCategory.value = null } diff --git a/shared/src/commonTest/kotlin/com/garfiec/librechat/shared/navigation/DrawerDisplayMappingTest.kt b/shared/src/commonTest/kotlin/com/garfiec/librechat/shared/navigation/DrawerDisplayMappingTest.kt new file mode 100644 index 0000000..6511a18 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/garfiec/librechat/shared/navigation/DrawerDisplayMappingTest.kt @@ -0,0 +1,74 @@ +package com.garfiec.librechat.shared.navigation + +import com.garfiec.librechat.core.model.Conversation +import com.garfiec.librechat.core.model.EndpointConfig +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class DrawerDisplayMappingTest { + + @Test + fun iconURLPrefersConversationOverEndpointConfig() { + val convo = Conversation( + conversationId = "c1", + endpoint = "OpenRouter", + iconURL = "https://convo-icon.example.com/icon.png", + ) + val configs = mapOf( + "OpenRouter" to EndpointConfig(iconURL = "https://config-icon.example.com/icon.png"), + ) + + val data = convo.toDrawerDisplayData(activeConversationId = null, endpointConfigs = configs) + + assertEquals("https://convo-icon.example.com/icon.png", data.endpointIconUrl) + } + + @Test + fun iconURLFallsBackToEndpointConfig() { + val convo = Conversation( + conversationId = "c1", + endpoint = "OpenRouter", + iconURL = null, + ) + val configs = mapOf( + "OpenRouter" to EndpointConfig(iconURL = "https://config-icon.example.com/icon.png"), + ) + + val data = convo.toDrawerDisplayData(activeConversationId = null, endpointConfigs = configs) + + assertEquals("https://config-icon.example.com/icon.png", data.endpointIconUrl) + } + + @Test + fun iconURLNullWhenNeitherSet() { + val convo = Conversation( + conversationId = "c1", + endpoint = "OpenRouter", + iconURL = null, + ) + val configs = mapOf( + "OpenRouter" to EndpointConfig(iconURL = null), + ) + + val data = convo.toDrawerDisplayData(activeConversationId = null, endpointConfigs = configs) + + assertNull(data.endpointIconUrl) + } + + @Test + fun iconURLNullWhenEndpointMissingFromConfig() { + val convo = Conversation( + conversationId = "c1", + endpoint = "OpenRouter", + iconURL = null, + ) + + val data = convo.toDrawerDisplayData( + activeConversationId = null, + endpointConfigs = emptyMap(), + ) + + assertNull(data.endpointIconUrl) + } +}