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

This commit is contained in:
M57
2026-09-15 13:10:13 +03:00
committed by GitHub
parent e4be8b146e
commit fe465ad93c
8 changed files with 316 additions and 29 deletions
@@ -2,7 +2,9 @@ package com.m57.hermescontrol.ui.model
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest import androidx.test.filters.MediumTest
import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.ModelProvider
@@ -62,6 +64,34 @@ class ModelScreenTest {
composeTestRule.onNodeWithText("Pinned Models").assertDoesNotExist() 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 @Test
fun modelScreen_rendersPinnedModels_whenPresent() { fun modelScreen_rendersPinnedModels_whenPresent() {
uiStateFlow.value = 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, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Column { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = provider.name, text = provider.name,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@@ -733,12 +733,36 @@ private fun ProviderCard(
) )
} }
if (isCurrent) { Row(
Text( verticalAlignment = Alignment.CenterVertically,
text = stringResource(R.string.model_status_current), horizontalArrangement = Arrangement.spacedBy(4.dp),
style = MaterialTheme.typography.labelMedium, ) {
color = MaterialTheme.colorScheme.primary, if (isCurrent) {
fontWeight = FontWeight.Bold, 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.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close 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.filled.PushPin
import androidx.compose.material.icons.outlined.PushPin import androidx.compose.material.icons.outlined.PushPin
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@@ -25,6 +27,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -32,6 +35,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -65,6 +69,7 @@ fun ModelPickerDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
var pickerQuery by remember { mutableStateOf("") } var pickerQuery by remember { mutableStateOf("") }
var expandedProviderSlug by remember { mutableStateOf<String?>(null) }
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@@ -149,6 +154,17 @@ fun ModelPickerDialog(
} }
} }
LaunchedEffect(filteredProvidersWithModels) {
if (
expandedProviderSlug != null &&
filteredProvidersWithModels.none { (provider, _) ->
provider.slug == expandedProviderSlug
}
) {
expandedProviderSlug = null
}
}
SearchBar( SearchBar(
query = pickerQuery, query = pickerQuery,
onQueryChange = { pickerQuery = it }, onQueryChange = { pickerQuery = it },
@@ -205,45 +221,83 @@ fun ModelPickerDialog(
// ── All providers / models (lazy item per model) ── // ── All providers / models (lazy item per model) ──
filteredProvidersWithModels.forEach { (provider, models) -> filteredProvidersWithModels.forEach { (provider, models) ->
val isExpanded = expandedProviderSlug == provider.slug
item(key = "header:${provider.slug}") { item(key = "header:${provider.slug}") {
Row( Row(
modifier =
Modifier
.fillMaxWidth()
.clickable {
expandedProviderSlug =
if (isExpanded) {
null
} else {
provider.slug
}
}.padding(top = 12.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 12.dp, bottom = 6.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Text( Text(
text = provider.name, text = provider.name,
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
) )
Spacer(modifier = Modifier.weight(1f))
Text( Text(
text = "${models.size} models", text =
pluralStringResource(
R.plurals.model_provider_count,
models.size,
models.size,
),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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( if (isExpanded) {
items = models, items(
key = { model -> "${provider.slug}:$model" }, items = models,
) { model -> key = { model -> "${provider.slug}:$model" },
val isPinned = "${provider.slug}:$model" in pinnedSet ) { model ->
val caps = provider.capabilities?.get(model) val isPinned = "${provider.slug}:$model" in pinnedSet
ModelItemCard( val caps = provider.capabilities?.get(model)
modelName = model, ModelItemCard(
isPinned = isPinned, modelName = model,
onPinToggle = isPinned = isPinned,
if (onPinToggle != null) { onPinToggle =
{ onPinToggle(provider.slug, model) } if (onPinToggle != null) {
} else { { onPinToggle(provider.slug, model) }
null } else {
}, null
onClick = { onSelect(provider.slug, model) }, },
canDisableReasoning = caps?.can_disable_reasoning, onClick = { onSelect(provider.slug, model) },
supportsReasoning = caps?.reasoning, canDisableReasoning = caps?.can_disable_reasoning,
) supportsReasoning = caps?.reasoning,
)
}
} }
} }
} }
+10
View File
@@ -503,9 +503,19 @@
<string name="model_label_available">النماذج المتاحة:</string> <string name="model_label_available">النماذج المتاحة:</string>
<string name="model_no_models">لم يتم العثور على نماذج أو أن المزود يحتاج إلى مصادقة.</string> <string name="model_no_models">لم يتم العثور على نماذج أو أن المزود يحتاج إلى مصادقة.</string>
<string name="content_desc_active_model">النموذج النشط</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_section_pinned">النماذج المثبتة</string>
<string name="content_desc_unpin_model">إلغاء تثبيت النموذج</string> <string name="content_desc_unpin_model">إلغاء تثبيت النموذج</string>
<string name="content_desc_pinned_section_toggle">تبديل ظهور قسم المثبتات</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_title">لا يوجد سجل</string>
<string name="history_empty_desc">ستظهر جلسات المحادثة السابقة من Hermes هنا.</string> <string name="history_empty_desc">ستظهر جلسات المحادثة السابقة من Hermes هنا.</string>
<string name="history_untitled">بلا عنوان</string> <string name="history_untitled">بلا عنوان</string>
+6
View File
@@ -393,10 +393,16 @@
<string name="model_label_available">사용 가능한 모델:</string> <string name="model_label_available">사용 가능한 모델:</string>
<string name="model_no_models">보고된 모델이 없거나 인증이 필요합니다.</string> <string name="model_no_models">보고된 모델이 없거나 인증이 필요합니다.</string>
<string name="content_desc_active_model">활성 모델</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_section_pinned">고정된 모델</string>
<string name="model_confirm_continue">계속</string> <string name="model_confirm_continue">계속</string>
<string name="content_desc_unpin_model">모델 고정 해제</string> <string name="content_desc_unpin_model">모델 고정 해제</string>
<string name="content_desc_pinned_section_toggle">고정 섹션 표시 전환</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 --> <!-- Sessions (History) Screen -->
<string name="history_empty_title">히스토리 없음</string> <string name="history_empty_title">히스토리 없음</string>
+6
View File
@@ -494,9 +494,15 @@
<string name="model_label_available">可用模型:</string> <string name="model_label_available">可用模型:</string>
<string name="model_no_models">没有可用的模型,或提供商需要认证。</string> <string name="model_no_models">没有可用的模型,或提供商需要认证。</string>
<string name="content_desc_active_model">当前模型</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_section_pinned">固定模型</string>
<string name="content_desc_unpin_model">取消固定模型</string> <string name="content_desc_unpin_model">取消固定模型</string>
<string name="content_desc_pinned_section_toggle">切换固定区显示</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_title">暂无历史</string>
<string name="history_empty_desc">之前的 Hermes 对话会话将显示在这里。</string> <string name="history_empty_desc">之前的 Hermes 对话会话将显示在这里。</string>
<string name="history_untitled">未命名</string> <string name="history_untitled">未命名</string>
+6
View File
@@ -586,9 +586,15 @@
<string name="model_label_available">Available Models:</string> <string name="model_label_available">Available Models:</string>
<string name="model_no_models">No models reported or provider needs authentication.</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_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="model_section_pinned">Pinned Models</string>
<string name="content_desc_unpin_model">Unpin model</string> <string name="content_desc_unpin_model">Unpin model</string>
<string name="content_desc_pinned_section_toggle">Toggle pinned section visibility</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 --> <!-- Sessions (History) Screen -->
<string name="history_empty_title">No history</string> <string name="history_empty_title">No history</string>