fix(ui): collapse provider model pickers (#1126)

This commit is contained in:
M57 authored and GitHub committed 2026-09-15 13:10:13 +03:00
1 parent e4be8b146e
commit fe465ad93c
8 files changed
+316 -29

No files matched your search

@@ -2,7 +2,9 @@ package com.m57.hermescontrol.ui.model
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.m57.hermescontrol.data.model.ModelProvider
@@ -62,6 +64,34 @@ class ModelScreenTest {
composeTestRule.onNodeWithText("Pinned Models").assertDoesNotExist()
}
@Test
fun modelScreen_providerExpansionArrowMatchesState() {
uiStateFlow.value =
ModelUiState(
providers =
listOf(
ModelProvider(
slug = "openai",
name = "OpenAI",
models = listOf("gpt-4"),
),
),
)
composeTestRule.setContent {
ModelScreen(
onOpenDrawer = {},
viewModel = mockViewModel,
)
}
composeTestRule.onNodeWithText("gpt-4").assertDoesNotExist()
composeTestRule.onNodeWithContentDescription("Expand OpenAI models").performClick()
composeTestRule.onNodeWithText("gpt-4").assertIsDisplayed()
composeTestRule.onNodeWithContentDescription("Collapse OpenAI models").performClick()
composeTestRule.onNodeWithText("gpt-4").assertDoesNotExist()
}
@Test
fun modelScreen_rendersPinnedModels_whenPresent() {
uiStateFlow.value =
@@ -0,0 +1,151 @@
package com.m57.hermescontrol.ui.model.components
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.m57.hermescontrol.data.model.ModelCapabilities
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.theme.HermesControlTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class ModelPickerDialogTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun providers_startCollapsed_andOnlyOneProviderExpands() {
composeTestRule.setContent {
HermesControlTheme {
ModelPickerDialog(
providers =
listOf(
ModelProvider(slug = "openai", name = "OpenAI", models = listOf("gpt-4")),
ModelProvider(slug = "anthropic", name = "Anthropic", models = listOf("claude-3")),
),
title = "Choose model",
onSelect = { _, _ -> },
onDismiss = {},
)
}
}
composeTestRule.onNodeWithText("gpt-4").assertDoesNotExist()
composeTestRule.onNodeWithText("claude-3").assertDoesNotExist()
composeTestRule.onAllNodesWithText("1 model").assertCountEquals(2)
composeTestRule.onNodeWithContentDescription("Expand OpenAI models").assertIsDisplayed()
composeTestRule.onNodeWithContentDescription("Expand Anthropic models").assertIsDisplayed()
composeTestRule.onNodeWithContentDescription("Expand OpenAI models").performClick()
composeTestRule.onNodeWithText("gpt-4").assertIsDisplayed()
composeTestRule.onNodeWithContentDescription("Collapse OpenAI models").assertIsDisplayed()
composeTestRule.onNodeWithText("claude-3").assertDoesNotExist()
composeTestRule.onNodeWithContentDescription("Expand Anthropic models").performClick()
composeTestRule.onNodeWithText("gpt-4").assertDoesNotExist()
composeTestRule.onNodeWithText("claude-3").assertIsDisplayed()
composeTestRule.onNodeWithContentDescription("Collapse Anthropic models").assertIsDisplayed()
composeTestRule.onNodeWithContentDescription("Collapse Anthropic models").performClick()
composeTestRule.onNodeWithText("claude-3").assertDoesNotExist()
composeTestRule.onNodeWithContentDescription("Expand Anthropic models").assertIsDisplayed()
}
@Test
fun search_filtersModelsAndCountsMatches_beforeSelection() {
var selectedProvider = ""
var selectedModel = ""
composeTestRule.setContent {
HermesControlTheme {
ModelPickerDialog(
providers =
listOf(
ModelProvider(
slug = "openai",
name = "OpenAI",
models = listOf("gpt-4", "embedding-3"),
),
ModelProvider(slug = "anthropic", name = "Anthropic", models = listOf("claude-3")),
),
title = "Choose model",
onSelect = { provider, model ->
selectedProvider = provider
selectedModel = model
},
onDismiss = {},
)
}
}
composeTestRule.onNodeWithText("2 models").assertIsDisplayed()
composeTestRule.onNode(hasSetTextAction()).performTextInput("gpt-4")
composeTestRule.onNodeWithText("1 model").assertIsDisplayed()
composeTestRule.onNodeWithText("embedding-3").assertDoesNotExist()
composeTestRule.onAllNodesWithText("gpt-4").assertCountEquals(1)
composeTestRule.onNodeWithContentDescription("Expand OpenAI models").performClick()
composeTestRule.onAllNodesWithText("gpt-4").get(1).performClick()
assertEquals("openai", selectedProvider)
assertEquals("gpt-4", selectedModel)
}
@Test
fun pinnedModels_remainVisibleAndPreserveCapabilitiesAndCallbacks() {
var toggledProvider = ""
var toggledModel = ""
var selectedProvider = ""
var selectedModel = ""
composeTestRule.setContent {
HermesControlTheme {
ModelPickerDialog(
providers =
listOf(
ModelProvider(
slug = "openai",
name = "OpenAI",
models = listOf("gpt-4"),
capabilities = mapOf("gpt-4" to ModelCapabilities(reasoning = false)),
),
),
title = "Choose model",
pinnedModels = listOf(PinnedModel(providerSlug = "openai", modelName = "gpt-4")),
onPinToggle = { provider, model ->
toggledProvider = provider
toggledModel = model
},
onSelect = { provider, model ->
selectedProvider = provider
selectedModel = model
},
onDismiss = {},
)
}
}
composeTestRule.onNodeWithText("gpt-4").assertIsDisplayed()
composeTestRule.onNodeWithText("no reasoning").assertIsDisplayed()
composeTestRule.onNodeWithContentDescription("Unpin model").performClick()
composeTestRule.onNodeWithText("gpt-4").performClick()
assertEquals("openai", toggledProvider)
assertEquals("gpt-4", toggledModel)
assertEquals("openai", selectedProvider)
assertEquals("gpt-4", selectedModel)
}
}
@@ -720,7 +720,7 @@ private fun ProviderCard(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Column(modifier = Modifier.weight(1f)) {
Text(
text = provider.name,
style = MaterialTheme.typography.titleMedium,
@@ -733,12 +733,36 @@ private fun ProviderCard(
)
}
if (isCurrent) {
Text(
text = stringResource(R.string.model_status_current),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
if (isCurrent) {
Text(
text = stringResource(R.string.model_status_current),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
)
}
Icon(
imageVector =
if (isExpanded) {
Icons.Filled.KeyboardArrowUp
} else {
Icons.Filled.KeyboardArrowDown
},
contentDescription =
stringResource(
if (isExpanded) {
R.string.content_desc_collapse_provider
} else {
R.string.content_desc_expand_provider
},
provider.name,
),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp),
)
}
}
@@ -16,6 +16,8 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
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.AlertDialog
@@ -25,6 +27,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -32,6 +35,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -65,6 +69,7 @@ fun ModelPickerDialog(
onDismiss: () -> Unit,
) {
var pickerQuery by remember { mutableStateOf("") }
var expandedProviderSlug by remember { mutableStateOf<String?>(null) }
AlertDialog(
onDismissRequest = onDismiss,
@@ -149,6 +154,17 @@ fun ModelPickerDialog(
}
}
LaunchedEffect(filteredProvidersWithModels) {
if (
expandedProviderSlug != null &&
filteredProvidersWithModels.none { (provider, _) ->
provider.slug == expandedProviderSlug
}
) {
expandedProviderSlug = null
}
}
SearchBar(
query = pickerQuery,
onQueryChange = { pickerQuery = it },
@@ -205,45 +221,83 @@ fun ModelPickerDialog(
// ── All providers / models (lazy item per model) ──
filteredProvidersWithModels.forEach { (provider, models) ->
val isExpanded = expandedProviderSlug == provider.slug
item(key = "header:${provider.slug}") {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable {
expandedProviderSlug =
if (isExpanded) {
null
} else {
provider.slug
}
}.padding(top = 12.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 12.dp, bottom = 6.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = provider.name,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = "${models.size} models",
text =
pluralStringResource(
R.plurals.model_provider_count,
models.size,
models.size,
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Icon(
imageVector =
if (isExpanded) {
Icons.Filled.KeyboardArrowUp
} else {
Icons.Filled.KeyboardArrowDown
},
contentDescription =
stringResource(
if (isExpanded) {
R.string.content_desc_collapse_provider
} else {
R.string.content_desc_expand_provider
},
provider.name,
),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp),
)
}
}
items(
items = models,
key = { model -> "${provider.slug}:$model" },
) { model ->
val isPinned = "${provider.slug}:$model" in pinnedSet
val caps = provider.capabilities?.get(model)
ModelItemCard(
modelName = model,
isPinned = isPinned,
onPinToggle =
if (onPinToggle != null) {
{ onPinToggle(provider.slug, model) }
} else {
null
},
onClick = { onSelect(provider.slug, model) },
canDisableReasoning = caps?.can_disable_reasoning,
supportsReasoning = caps?.reasoning,
)
if (isExpanded) {
items(
items = models,
key = { model -> "${provider.slug}:$model" },
) { model ->
val isPinned = "${provider.slug}:$model" in pinnedSet
val caps = provider.capabilities?.get(model)
ModelItemCard(
modelName = model,
isPinned = isPinned,
onPinToggle =
if (onPinToggle != null) {
{ onPinToggle(provider.slug, model) }
} else {
null
},
onClick = { onSelect(provider.slug, model) },
canDisableReasoning = caps?.can_disable_reasoning,
supportsReasoning = caps?.reasoning,
)
}
}
}
}
+10
View File
@@ -503,9 +503,19 @@
<string name="model_label_available">النماذج المتاحة:</string>
<string name="model_no_models">لم يتم العثور على نماذج أو أن المزود يحتاج إلى مصادقة.</string>
<string name="content_desc_active_model">النموذج النشط</string>
<string name="content_desc_expand_provider">توسيع نماذج %1$s</string>
<string name="content_desc_collapse_provider">طي نماذج %1$s</string>
<string name="model_section_pinned">النماذج المثبتة</string>
<string name="content_desc_unpin_model">إلغاء تثبيت النموذج</string>
<string name="content_desc_pinned_section_toggle">تبديل ظهور قسم المثبتات</string>
<plurals name="model_provider_count">
<item quantity="zero">لا توجد نماذج</item>
<item quantity="one">%1$d نموذج</item>
<item quantity="two">%1$d نموذجان</item>
<item quantity="few">%1$d نماذج</item>
<item quantity="many">%1$d نموذجًا</item>
<item quantity="other">%1$d نموذج</item>
</plurals>
<string name="history_empty_title">لا يوجد سجل</string>
<string name="history_empty_desc">ستظهر جلسات المحادثة السابقة من Hermes هنا.</string>
<string name="history_untitled">بلا عنوان</string>
+6
View File
@@ -393,10 +393,16 @@
<string name="model_label_available">사용 가능한 모델:</string>
<string name="model_no_models">보고된 모델이 없거나 인증이 필요합니다.</string>
<string name="content_desc_active_model">활성 모델</string>
<string name="content_desc_expand_provider">%1$s 모델 펼치기</string>
<string name="content_desc_collapse_provider">%1$s 모델 접기</string>
<string name="model_section_pinned">고정된 모델</string>
<string name="model_confirm_continue">계속</string>
<string name="content_desc_unpin_model">모델 고정 해제</string>
<string name="content_desc_pinned_section_toggle">고정 섹션 표시 전환</string>
<plurals name="model_provider_count">
<item quantity="one">%1$d개 모델</item>
<item quantity="other">%1$d개 모델</item>
</plurals>
<!-- Sessions (History) Screen -->
<string name="history_empty_title">히스토리 없음</string>
+6
View File
@@ -494,9 +494,15 @@
<string name="model_label_available">可用模型:</string>
<string name="model_no_models">没有可用的模型,或提供商需要认证。</string>
<string name="content_desc_active_model">当前模型</string>
<string name="content_desc_expand_provider">展开 %1$s 模型</string>
<string name="content_desc_collapse_provider">折叠 %1$s 模型</string>
<string name="model_section_pinned">固定模型</string>
<string name="content_desc_unpin_model">取消固定模型</string>
<string name="content_desc_pinned_section_toggle">切换固定区显示</string>
<plurals name="model_provider_count">
<item quantity="one">%1$d 个模型</item>
<item quantity="other">%1$d 个模型</item>
</plurals>
<string name="history_empty_title">暂无历史</string>
<string name="history_empty_desc">之前的 Hermes 对话会话将显示在这里。</string>
<string name="history_untitled">未命名</string>
+6
View File
@@ -586,9 +586,15 @@
<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="content_desc_expand_provider">Expand %1$s models</string>
<string name="content_desc_collapse_provider">Collapse %1$s models</string>
<string name="model_section_pinned">Pinned Models</string>
<string name="content_desc_unpin_model">Unpin model</string>
<string name="content_desc_pinned_section_toggle">Toggle pinned section visibility</string>
<plurals name="model_provider_count">
<item quantity="one">%1$d model</item>
<item quantity="other">%1$d models</item>
</plurals>
<!-- Sessions (History) Screen -->
<string name="history_empty_title">No history</string>