diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/model/ModelScreenTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/model/ModelScreenTest.kt index c2b9a66e..1ddf87c3 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/model/ModelScreenTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/model/ModelScreenTest.kt @@ -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 = diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/model/components/ModelPickerDialogTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/model/components/ModelPickerDialogTest.kt new file mode 100644 index 00000000..cfffcad7 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/model/components/ModelPickerDialogTest.kt @@ -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) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt index a8baf000..b9790328 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt @@ -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), ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/components/ModelPickerDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/components/ModelPickerDialog.kt index 942f056a..0b576cb1 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/model/components/ModelPickerDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/components/ModelPickerDialog.kt @@ -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(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, + ) + } } } } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index dc295d82..3b45c33a 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -503,9 +503,19 @@ النماذج المتاحة: لم يتم العثور على نماذج أو أن المزود يحتاج إلى مصادقة. النموذج النشط +توسيع نماذج %1$s +طي نماذج %1$s النماذج المثبتة إلغاء تثبيت النموذج تبديل ظهور قسم المثبتات + + لا توجد نماذج + %1$d نموذج + %1$d نموذجان + %1$d نماذج + %1$d نموذجًا + %1$d نموذج + لا يوجد سجل ستظهر جلسات المحادثة السابقة من Hermes هنا. بلا عنوان diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 40b7a9ba..2de852ca 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -393,10 +393,16 @@ 사용 가능한 모델: 보고된 모델이 없거나 인증이 필요합니다. 활성 모델 + %1$s 모델 펼치기 + %1$s 모델 접기 고정된 모델 계속 모델 고정 해제 고정 섹션 표시 전환 + + %1$d개 모델 + %1$d개 모델 + 히스토리 없음 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 9cc43ed8..fa1c1d3c 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -494,9 +494,15 @@ 可用模型: 没有可用的模型,或提供商需要认证。 当前模型 + 展开 %1$s 模型 + 折叠 %1$s 模型 固定模型 取消固定模型 切换固定区显示 + + %1$d 个模型 + %1$d 个模型 + 暂无历史 之前的 Hermes 对话会话将显示在这里。 未命名 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 338bf909..a1b0d0ca 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -586,9 +586,15 @@ Available Models: No models reported or provider needs authentication. Active Model + Expand %1$s models + Collapse %1$s models Pinned Models Unpin model Toggle pinned section visibility + + %1$d model + %1$d models + No history