Merge pull request #72 from garfiec/feat/issue-68-endpoint-icon-url
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build Android App (push) Has been cancelled
CI / Build iOS App (push) Has been cancelled

feat(ui): render custom endpoint iconURL in drawer and conversation list
This commit is contained in:
Garfie Chiu 2026-04-29 22:49:14 -06:00 committed by GitHub
commit 2b41b6f379
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 393 additions and 123 deletions

View file

@ -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")

View file

@ -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<SessionTask>(),

View file

@ -47,4 +47,7 @@ interface ConfigRepository {
* [VersionCheckResult.isCompatible] = true (fail-open).
*/
suspend fun checkBackendVersion(): Result<VersionCheckResult>
/** Clear all cached config state (in-memory + disk). Called on logout. */
suspend fun clear()
}

View file

@ -186,4 +186,12 @@ class ConfigRepositoryImpl(
}
}
}
override suspend fun clear() {
_endpointConfigs.value = emptyMap()
_availableModels.value = emptyMap()
_startupConfig.value = null
_detectedBackendVersion.value = null
configCache.clear()
}
}

View file

@ -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()
}
}

View file

@ -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, EndpointConfig>,
): String? = iconURL ?: endpoint?.let { endpointConfigs[it]?.iconURL }

View file

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

View file

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

View file

@ -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<ConversationExporter>(relaxed = true)
private val conversationImporter = mockk<ConversationImporter>(relaxed = true)
private val roleRepository = mockk<RoleRepository>(relaxed = true)
private val endpointConfigsFlow = MutableStateFlow<Map<String, EndpointConfig>>(emptyMap())
private val configRepository = mockk<ConfigRepository>(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")
}
}

View file

@ -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<String, EndpointConfig>,
): ConversationDisplayData = ConversationDisplayData(
conversationId = conversationId ?: "",
title = title ?: "New Chat",
endpoint = endpoint,
model = model,
updatedAt = updatedAt,
isBookmarked = SAVED_TAG in tags,
endpointIconUrl = resolveEndpointIconUrl(endpointConfigs),
)

View file

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

View file

@ -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<ConversationListEvent> = _events.asSharedFlow()
/** Raw conversations kept for internal lookups (action sheets, etc.). */
private var conversations: List<Conversation> = emptyList()
private val _conversations = MutableStateFlow<List<Conversation>>(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<Conversation>,
endpointConfigs: Map<String, EndpointConfig>,
): List<Pair<String, List<ConversationDisplayData>>> {
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) }
}
}
}

View file

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

View file

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

View file

@ -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<String, EndpointConfig>,
): 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),
)
}

View file

@ -16,6 +16,7 @@ data class DrawerConversationDisplayData(
val isActive: Boolean,
val isFavorite: Boolean,
val tags: List<String>,
val endpointIconUrl: String? = null,
)
/**

View file

@ -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
}

View file

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