feat(#365): add pin support for models with pinned section at top (#414)

* feat(#365): add pin support for models with pinned section at top

* fix(#365): stub Pinned Models methods in E2eIntegrationTest

* fix(#365): use state flow for pin/unpin, add max cap, add ViewModel tests

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-06-28 02:23:25 +03:00
1 parent 42a67bffa9
commit 4745728920
8 files changed
+630 -10

No files matched your search

@@ -0,0 +1,100 @@
package com.m57.hermescontrol.ui.model
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class ModelScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
private val mockViewModel = mockk<ModelViewModel>(relaxed = true)
private val uiStateFlow = MutableStateFlow(ModelUiState())
@Before
fun setUp() {
every { mockViewModel.uiState } returns uiStateFlow.asStateFlow()
}
@Test
fun modelScreen_pinnedSectionHidden_whenNoPinnedModels() {
uiStateFlow.value =
ModelUiState(
providers =
listOf(
ModelProvider(
slug = "openai",
name = "OpenAI",
is_current = true,
is_user_defined = false,
models = listOf("gpt-4"),
total_models = 1,
source = null,
authenticated = true,
auth_type = null,
warning = null,
),
),
pinnedModels = emptyList(),
)
composeTestRule.setContent {
ModelScreen(
onOpenDrawer = {},
viewModel = mockViewModel,
)
}
composeTestRule.onNodeWithText("Pinned Models").assertDoesNotExist()
}
@Test
fun modelScreen_rendersPinnedModels_whenPresent() {
uiStateFlow.value =
ModelUiState(
providers =
listOf(
ModelProvider(
slug = "openai",
name = "OpenAI",
is_current = true,
is_user_defined = false,
models = listOf("gpt-4"),
total_models = 1,
source = null,
authenticated = true,
auth_type = null,
warning = null,
),
),
pinnedModels =
listOf(
PinnedModel(providerSlug = "openai", modelName = "gpt-4"),
),
)
composeTestRule.setContent {
ModelScreen(
onOpenDrawer = {},
viewModel = mockViewModel,
)
}
composeTestRule.onNodeWithText("Pinned Models").assertIsDisplayed()
composeTestRule.onNodeWithText("gpt-4").assertIsDisplayed()
}
}
@@ -40,6 +40,7 @@ object AuthManager {
private const val KEY_USE_DYNAMIC_COLORS = "use_dynamic_colors"
private const val KEY_THEME_PRESET = "theme_preset"
private const val KEY_BOTTOM_NAV_DISPLAY_MODE = "bottom_nav_display_mode"
private const val KEY_PINNED_MODELS = "pinned_models"
private const val DEFAULT_HOST = "127.0.0.1"
private const val DEFAULT_PORT = 9119
@@ -180,6 +181,29 @@ object AuthManager {
requirePrefs().edit().putString(KEY_CONNECTION_PROFILES, json).apply()
}
// ── Pinned Models ────────────────────────────────────────────────────
fun getPinnedModels(): List<com.m57.hermescontrol.data.model.PinnedModel> {
val json = requirePrefs().getString(KEY_PINNED_MODELS, null) ?: return emptyList()
return try {
val type =
object : com.google.gson.reflect.TypeToken<
List<com.m57.hermescontrol.data.model.PinnedModel>,
>() {}.type
gson.fromJson(json, type) ?: emptyList()
} catch (e: Exception) {
if (com.m57.hermescontrol.BuildConfig.DEBUG) {
android.util.Log.w("AuthManager", "Failed to parse pinned models", e)
}
emptyList()
}
}
fun savePinnedModels(pinned: List<com.m57.hermescontrol.data.model.PinnedModel>) {
val json = gson.toJson(pinned)
requirePrefs().edit().putString(KEY_PINNED_MODELS, json).apply()
}
fun getProfileToken(profileId: String): String? = requirePrefs().getString("token_$profileId", null)
fun setProfileToken(
@@ -16,3 +16,8 @@ data class ModelProvider(
val auth_type: String?,
val warning: String?,
)
data class PinnedModel(
val providerSlug: String,
val modelName: String,
)
@@ -1,6 +1,7 @@
package com.m57.hermescontrol.ui.model
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -11,14 +12,20 @@ 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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.outlined.PushPin
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
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
@@ -26,6 +33,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -116,6 +124,205 @@ fun ModelScreen(
placeholder = "Search models...",
)
}
if (state.pinnedModels.isNotEmpty()) {
item {
var isPinnedSectionExpanded by rememberSaveable { mutableStateOf(true) }
val filteredPinned =
remember(query, state.pinnedModels) {
if (query.isBlank()) {
state.pinnedModels
} else {
state.pinnedModels.filter {
it.modelName.contains(query, ignoreCase = true) ||
it.providerSlug.contains(query, ignoreCase = true)
}
}
}
if (filteredPinned.isNotEmpty()) {
Card(
modifier =
Modifier
.fillMaxWidth()
.animateContentSize()
.padding(bottom = 8.dp),
colors =
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.surfaceVariant.copy(
alpha = 0.5f,
),
),
onClick = {
isPinnedSectionExpanded = !isPinnedSectionExpanded
},
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Filled.PushPin,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = stringResource(R.string.model_section_pinned),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
}
IconButton(
onClick = {
isPinnedSectionExpanded = !isPinnedSectionExpanded
},
) {
Icon(
imageVector =
if (isPinnedSectionExpanded) {
Icons.Filled.KeyboardArrowUp
} else {
Icons.Filled.KeyboardArrowDown
},
contentDescription =
stringResource(
R.string.content_desc_pinned_section_toggle,
),
)
}
}
AnimatedVisibility(visible = isPinnedSectionExpanded) {
Column(modifier = Modifier.padding(top = 8.dp)) {
filteredPinned.forEach { pinned ->
val isActive =
state.activeProfile?.provider == pinned.providerSlug &&
state.activeProfile?.model == pinned.modelName
Card(
onClick = {
viewModel.selectModel(
pinned.providerSlug,
pinned.modelName,
)
},
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
colors =
CardDefaults.cardColors(
containerColor =
if (isActive) {
MaterialTheme.colorScheme
.primaryContainer
.copy(alpha = 0.3f)
} else {
MaterialTheme.colorScheme.surface
},
),
border =
BorderStroke(
width = 1.dp,
color =
if (isActive) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outline.copy(
alpha = 0.2f,
)
},
),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = pinned.modelName,
style = MaterialTheme.typography.bodyMedium,
fontWeight =
if (isActive) {
FontWeight.Bold
} else {
FontWeight.Normal
},
color =
if (isActive) {
MaterialTheme.colorScheme
.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurface
},
)
Text(
text = pinned.providerSlug,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (isActive) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription =
stringResource(
R.string.content_desc_active_model,
),
tint = MaterialTheme.colorScheme.primary,
)
}
IconButton(
onClick = {
viewModel.unpinModel(
pinned.providerSlug,
pinned.modelName,
)
},
modifier = Modifier.size(24.dp),
) {
Icon(
imageVector = Icons.Filled.PushPin,
contentDescription =
stringResource(
R.string.content_desc_unpin_model,
),
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
}
}
}
}
}
}
}
items(filteredProviders, key = { it.slug }) { provider ->
val isExpanded = expandedProviderSlug == provider.slug
val isCurrent = provider.is_current == true
@@ -268,15 +475,61 @@ fun ModelScreen(
MaterialTheme.colorScheme.onSurface
},
)
if (isActive) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription =
stringResource(
R.string.content_desc_active_model,
),
tint = MaterialTheme.colorScheme.primary,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (isActive) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription =
stringResource(
R.string.content_desc_active_model,
),
tint = MaterialTheme.colorScheme.primary,
)
}
val isPinned =
state.pinnedModels.any {
it.providerSlug == provider.slug &&
it.modelName == model
}
IconButton(
onClick = {
if (isPinned) {
viewModel.unpinModel(provider.slug, model)
} else {
viewModel.pinModel(provider.slug, model)
}
},
modifier = Modifier.size(24.dp),
) {
Icon(
imageVector =
if (isPinned) {
Icons.Filled.PushPin
} else {
Icons.Outlined.PushPin
},
contentDescription =
stringResource(
if (isPinned) {
R.string.content_desc_unpin_model
} else {
R.string.content_desc_pin_model
},
),
tint =
if (isPinned) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme
.onSurfaceVariant
.copy(alpha = 0.6f)
},
)
}
}
}
}
@@ -2,7 +2,9 @@ package com.m57.hermescontrol.ui.model
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.model.ProfileInfo
import com.m57.hermescontrol.data.model.UpdateProfileModelRequest
import com.m57.hermescontrol.data.remote.ApiClient
@@ -24,12 +26,19 @@ data class ModelUiState(
val activeProfile: ProfileInfo? = null,
val errorMessage: String? = null,
val toastMessage: String? = null,
val pinnedModels: List<PinnedModel> = emptyList(),
)
class ModelViewModel : ViewModel(), ToastHost {
class ModelViewModel :
ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(ModelUiState())
val uiState: StateFlow<ModelUiState> = _uiState.asStateFlow()
init {
_uiState.update { it.copy(pinnedModels = AuthManager.getPinnedModels()) }
}
fun loadModelOptions() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch {
@@ -70,6 +79,7 @@ class ModelViewModel : ViewModel(), ToastHost {
isLoading = false,
providers = responseResult.data.providers.orEmpty(),
activeProfile = activeProfile,
pinnedModels = AuthManager.getPinnedModels(),
)
}
}
@@ -139,4 +149,38 @@ class ModelViewModel : ViewModel(), ToastHost {
override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}
companion object {
private const val MAX_PINNED_MODELS = 15
}
fun pinModel(
providerSlug: String,
modelName: String,
) {
val currentPinned = _uiState.value.pinnedModels.toMutableList()
val newPin = PinnedModel(providerSlug, modelName)
if (currentPinned.contains(newPin)) return
if (currentPinned.size >= MAX_PINNED_MODELS) {
_uiState.update {
it.copy(toastMessage = "Maximum of $MAX_PINNED_MODELS pinned models reached")
}
return
}
currentPinned.add(newPin)
AuthManager.savePinnedModels(currentPinned)
_uiState.update { it.copy(pinnedModels = currentPinned) }
}
fun unpinModel(
providerSlug: String,
modelName: String,
) {
val currentPinned = _uiState.value.pinnedModels.toMutableList()
val pinToRemove = PinnedModel(providerSlug, modelName)
if (currentPinned.remove(pinToRemove)) {
AuthManager.savePinnedModels(currentPinned)
_uiState.update { it.copy(pinnedModels = currentPinned) }
}
}
}
+4
View File
@@ -268,6 +268,10 @@
<string name="model_label_available">Available Models:</string>
<string name="model_no_models">No models reported or provider needs authentication.</string>
<string name="content_desc_active_model">Active Model</string>
<string name="model_section_pinned">Pinned Models</string>
<string name="content_desc_pin_model">Pin model</string>
<string name="content_desc_unpin_model">Unpin model</string>
<string name="content_desc_pinned_section_toggle">Toggle pinned section visibility</string>
<!-- Sessions (History) Screen -->
<string name="history_empty_title">No history</string>
@@ -88,6 +88,8 @@ class E2eIntegrationTest {
every { AuthManager.getProfileToken(any()) } returns null
every { AuthManager.setProfileToken(any(), any()) } returns Unit
every { AuthManager.saveConnectionProfiles(any()) } returns Unit
every { AuthManager.getPinnedModels() } returns emptyList()
every { AuthManager.savePinnedModels(any()) } returns Unit
// Application stubs
every { mockApp.getString(R.string.connect_error_401) } returns "Invalid token (401 Unauthorized)"
@@ -0,0 +1,188 @@
package com.m57.hermescontrol.ui.model
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.PinnedModel
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ModelViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private var storedPinnedModels: MutableList<PinnedModel> = mutableListOf()
private fun createViewModel(): ModelViewModel {
val vm = ModelViewModel()
testDispatcher.scheduler.advanceUntilIdle()
return vm
}
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
mockkObject(AuthManager)
storedPinnedModels = mutableListOf()
every { AuthManager.getPinnedModels() } answers { storedPinnedModels.toList() }
every { AuthManager.savePinnedModels(any()) } answers {
storedPinnedModels = firstArg<List<PinnedModel>>().toMutableList()
}
}
@After
fun tearDown() {
Dispatchers.resetMain()
unmockkAll()
}
@Test
fun `init loads pinned models from AuthManager`() {
storedPinnedModels.add(PinnedModel("openai", "gpt-4"))
storedPinnedModels.add(PinnedModel("anthropic", "claude-3"))
val viewModel = createViewModel()
val state = viewModel.uiState.value
assertEquals(2, state.pinnedModels.size)
assertEquals("gpt-4", state.pinnedModels[0].modelName)
assertEquals("claude-3", state.pinnedModels[1].modelName)
}
@Test
fun `init with empty pinned models`() {
val viewModel = createViewModel()
assertTrue(viewModel.uiState.value.pinnedModels.isEmpty())
}
@Test
fun `pinModel adds model to pinned list`() {
val viewModel = createViewModel()
viewModel.pinModel("openai", "gpt-4")
testDispatcher.scheduler.advanceUntilIdle()
val state = viewModel.uiState.value
assertEquals(1, state.pinnedModels.size)
assertEquals(PinnedModel("openai", "gpt-4"), state.pinnedModels[0])
}
@Test
fun `pinModel prevents duplicate pins`() {
val viewModel = createViewModel()
viewModel.pinModel("openai", "gpt-4")
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(1, viewModel.uiState.value.pinnedModels.size)
viewModel.pinModel("openai", "gpt-4")
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(1, viewModel.uiState.value.pinnedModels.size)
}
@Test
fun `pinModel persists to AuthManager`() {
val viewModel = createViewModel()
viewModel.pinModel("openai", "gpt-4")
testDispatcher.scheduler.advanceUntilIdle()
verify { AuthManager.savePinnedModels(listOf(PinnedModel("openai", "gpt-4"))) }
}
@Test
fun `unpinModel removes model from pinned list`() {
val viewModel = createViewModel()
viewModel.pinModel("openai", "gpt-4")
viewModel.pinModel("anthropic", "claude-3")
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(2, viewModel.uiState.value.pinnedModels.size)
viewModel.unpinModel("openai", "gpt-4")
testDispatcher.scheduler.advanceUntilIdle()
val state = viewModel.uiState.value
assertEquals(1, state.pinnedModels.size)
assertEquals(PinnedModel("anthropic", "claude-3"), state.pinnedModels[0])
}
@Test
fun `unpinModel does nothing for non-pinned model`() {
val viewModel = createViewModel()
viewModel.pinModel("openai", "gpt-4")
testDispatcher.scheduler.advanceUntilIdle()
viewModel.unpinModel("openai", "nonexistent-model")
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(1, viewModel.uiState.value.pinnedModels.size)
}
@Test
fun `unpinModel persists removal to AuthManager`() {
val viewModel = createViewModel()
viewModel.pinModel("openai", "gpt-4")
viewModel.pinModel("anthropic", "claude-3")
testDispatcher.scheduler.advanceUntilIdle()
viewModel.unpinModel("openai", "gpt-4")
testDispatcher.scheduler.advanceUntilIdle()
verify { AuthManager.savePinnedModels(listOf(PinnedModel("anthropic", "claude-3"))) }
}
@Test
fun `pinModel shows toast when max cap reached`() {
val viewModel = createViewModel()
// Pin 15 models to reach cap
for (i in 1..15) {
viewModel.pinModel("provider-$i", "model-$i")
}
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(15, viewModel.uiState.value.pinnedModels.size)
// Try pinning one more
viewModel.pinModel("overflow", "too-many")
testDispatcher.scheduler.advanceUntilIdle()
val state = viewModel.uiState.value
assertEquals(15, state.pinnedModels.size)
assertNull(state.pinnedModels.find { it.providerSlug == "overflow" })
// Toast should be set
val toastMessage = state.toastMessage
assertTrue(toastMessage?.contains("15") == true)
}
@Test
fun `pin and unpin cycle works`() {
val viewModel = createViewModel()
viewModel.pinModel("openai", "gpt-4")
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(1, viewModel.uiState.value.pinnedModels.size)
viewModel.unpinModel("openai", "gpt-4")
testDispatcher.scheduler.advanceUntilIdle()
assertTrue(viewModel.uiState.value.pinnedModels.isEmpty())
// Re-pin same model after unpin
viewModel.pinModel("openai", "gpt-4")
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(1, viewModel.uiState.value.pinnedModels.size)
}
}