fix(ui): collapse provider model pickers (#1126)
This commit is contained in:
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 =
|
||||
|
||||
+151
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user