From 0bb418b4a77e1a48949454cf11656812046b1ba6 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:21:06 +0300 Subject: [PATCH] ui: input bar reasoning chip tweaks (#673) * ui: input bar reasoning chip tweaks - Remove emoji from reasoning level labels (dropdown + chip text) - Reduce reasoning chip horizontal padding (8dp -> 4dp) - Move reasoning chip to right side (after spacer, next to mic) - Increase model chip minimum width (120dp -> 140dp) - Remove unused width import * fix(ui): add missing widthIn import in ComposerToolbar Resolves 'Unresolved reference widthIn' compile error at ComposerToolbar.kt:95 that broke CI (unit-tests, android-lint, instrumented-tests all fail on compileDebugKotlin). * ui(chat): enhance input bar layout, reasoning menu header, and card shadow Give ChatInputBar a floating rounded container with shadow contrast. Make model chip take available space and add header to reasoning menu. * fix(ui): remove trailing blank line in ComposerToolbar for ktlint Fix standard:no-consecutive-blank-lines ktlint violation. * ui(model): enhance ModelPickerDialog with rounded cards and provider badges Apply card styling, rounded corners (24dp dialog, 12dp cards), provider badges, and shadow contrast to ModelPickerDialog. * feat(model): add in-dialog model pinning to ModelPickerDialog Add pin/unpin icon buttons to model cards in ModelPickerDialog across both ModelScreen and ChatScreen. * ui(model): remove provider badge chip and eliminate bottom whitespace in ModelPickerDialog Replace bottom Cancel button with top-right Close icon to eliminate bottom whitespace. Remove inline provider badge chip from model cards. * perf(model): optimize ModelPickerDialog load time with lazy items and O(1) set lookup Lazy-register individual model items in LazyColumn and memoize pinnedSet to prevent synchronous rendering lag when opening dialog. --------- Co-authored-by: M57 --- .../m57/hermescontrol/ui/chat/ChatScreen.kt | 1 + .../hermescontrol/ui/chat/ChatViewModel.kt | 15 ++ .../ui/chat/components/ChatComposer.kt | 15 +- .../ui/chat/components/ComposerToolbar.kt | 65 ++--- .../m57/hermescontrol/ui/model/ModelScreen.kt | 4 + .../hermescontrol/ui/model/ModelViewModel.kt | 12 + .../ui/model/components/ModelPickerDialog.kt | 229 +++++++++++++----- 7 files changed, 255 insertions(+), 86 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 6fd7d5c8..db7f10dd 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -563,6 +563,7 @@ fun ChatScreen( title = "Switch model (this chat)", isLoading = state.modelPickerLoading && state.modelPickerProviders.isEmpty(), pinnedModels = state.modelPickerPinned, + onPinToggle = { provider, model -> viewModel.togglePinModel(provider, model) }, onSelect = { provider, model -> viewModel.sendSlashModel(provider, model) }, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 0d642e72..d2f92d7e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -1286,6 +1286,21 @@ class ChatViewModel( _uiState.update { it.copy(showModelPicker = false, modelPickerLoading = false) } } + fun togglePinModel( + providerSlug: String, + modelName: String, + ) { + val currentPinned = AuthManager.getPinnedModels().toMutableList() + val target = PinnedModel(providerSlug, modelName) + if (currentPinned.contains(target)) { + currentPinned.remove(target) + } else { + currentPinned.add(target) + } + AuthManager.savePinnedModels(currentPinned) + _uiState.update { it.copy(modelPickerPinned = currentPinned) } + } + /** * Hot-swap the CURRENT session's model via the /model slash command. * diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt index 6ce60c3d..3e30bdce 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt @@ -105,10 +105,19 @@ fun ChatInputBar( exit = slideOutVertically(targetOffsetY = { it }), ) { Surface( - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 6.dp), + shape = RoundedCornerShape(20.dp), color = MaterialTheme.colorScheme.surface, - tonalElevation = 3.dp, - shadowElevation = 0.dp, + border = + BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), + ), + tonalElevation = 2.dp, + shadowElevation = 4.dp, ) { Column { // Commands hidden from the suggestion menu β€” desktop/CLI-only and diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt index 5ff2c5c6..ab067fa2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt @@ -4,11 +4,10 @@ import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer 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.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AttachFile @@ -17,6 +16,7 @@ import androidx.compose.material.icons.filled.Stop import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults @@ -36,7 +36,7 @@ import androidx.compose.ui.unit.dp /** * Bottom toolbar row for the chat composer. * - * Layout: [πŸ“Ž attach] [model chip] [reasoning chip] [←spacerβ†’] [πŸŽ™ mic] + * Layout: [πŸ“Ž attach] [model chip] [←spacerβ†’] [🧠 reasoning] [πŸŽ™ mic] * * The reasoning chip opens a dropdown menu to pick a level (instead of cycling). */ @@ -76,7 +76,7 @@ fun ComposerToolbar( ) } - // Model chip β€” fixed width, text scrolls if too long + // Model chip β€” takes available space, fixed height FilterChip( selected = currentSessionModel != null, onClick = onModelTap, @@ -93,11 +93,12 @@ fun ComposerToolbar( }, modifier = Modifier - .width(120.dp) + .weight(1f) + .height(28.dp) .testTag("model_chip"), ) - // Reasoning chip with dropdown menu + // Reasoning chip with dropdown menu (right side, next to mic) Box { FilterChip( selected = reasoningLevel != null, @@ -110,23 +111,33 @@ fun ComposerToolbar( overflow = TextOverflow.Ellipsis, ) }, - modifier = Modifier.testTag("reasoning_chip"), + modifier = + Modifier + .height(28.dp) + .testTag("reasoning_chip"), ) DropdownMenu( expanded = showReasoningMenu, onDismissRequest = { showReasoningMenu = false }, ) { + Text( + text = "Reasoning", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + ) + HorizontalDivider() val allLevels = listOf( - "none" to "🧠 None", - "minimal" to "🧠 Minimal", - "low" to "🧠 Low", - "medium" to "🧠 Med", - "high" to "🧠 High", - "xhigh" to "🧠 XHigh", - "max" to "🧠 Max", - "ultra" to "🧠 Ultra", + "none" to "None", + "minimal" to "Minimal", + "low" to "Low", + "medium" to "Med", + "high" to "High", + "xhigh" to "XHigh", + "max" to "Max", + "ultra" to "Ultra", ) allLevels.forEach { (level, label) -> DropdownMenuItem( @@ -156,8 +167,6 @@ fun ComposerToolbar( } } - Spacer(modifier = Modifier.weight(1f)) - // Mic / Stop button IconButton( onClick = onMicTap, @@ -189,19 +198,19 @@ fun ComposerToolbar( * * @param level One of: "none", "minimal", "low", "medium", "high", * "xhigh", "max", "ultra", or null for model default. - * @return Display string such as "🧠 None", "🧠 Low", "🧠 XHigh", "🧠 Ultra", etc. + * @return Display string such as "None", "Low", "XHigh", "Ultra", etc. */ private fun buildReasoningLabel(level: String?): String { return when (level) { - null -> "🧠 Med" - "none" -> "🧠 None" - "minimal" -> "🧠 Minimal" - "low" -> "🧠 Low" - "medium" -> "🧠 Med" - "high" -> "🧠 High" - "xhigh" -> "🧠 XHigh" - "max" -> "🧠 Max" - "ultra" -> "🧠 Ultra" - else -> "🧠 $level" + null -> "Med" + "none" -> "None" + "minimal" -> "Minimal" + "low" -> "Low" + "medium" -> "Med" + "high" -> "High" + "xhigh" -> "XHigh" + "max" -> "Max" + "ultra" -> "Ultra" + else -> level } } 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 baec759c..48ae1c5f 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 @@ -242,6 +242,8 @@ fun ModelScreen( providers = state.providers, title = "Set Main Model", isLoading = state.isLoading && state.providers.isEmpty(), + pinnedModels = state.pinnedModels, + onPinToggle = { provider, model -> viewModel.togglePinModel(provider, model) }, onSelect = { provider, model -> viewModel.setMainModel(provider, model) }, @@ -266,6 +268,8 @@ fun ModelScreen( providers = state.providers, title = "Set Aux: ${state.auxPickerTask}", isLoading = state.isLoading && state.providers.isEmpty(), + pinnedModels = state.pinnedModels, + onPinToggle = { provider, model -> viewModel.togglePinModel(provider, model) }, onSelect = { provider, model -> viewModel.setAuxTask(state.auxPickerTask, provider, model) }, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt index 0e4e414d..503500cd 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt @@ -524,4 +524,16 @@ class ModelViewModel : _uiState.update { it.copy(pinnedModels = currentPinned) } } } + + fun togglePinModel( + providerSlug: String, + modelName: String, + ) { + val target = PinnedModel(providerSlug, modelName) + if (_uiState.value.pinnedModels.contains(target)) { + unpinModel(providerSlug, modelName) + } else { + pinModel(providerSlug, modelName) + } + } } 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 6babb9ef..f55a0a24 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 @@ -1,23 +1,29 @@ package com.m57.hermescontrol.ui.model.components +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn 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.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.PushPin +import androidx.compose.material.icons.outlined.PushPin import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -25,7 +31,9 @@ import androidx.compose.runtime.remember 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.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.PinnedModel @@ -40,6 +48,7 @@ import com.m57.hermescontrol.ui.common.SearchBar * * [pinnedModels] renders a pinned section at the top (mirrors the global model * screen's pin section) so frequently-used models are one tap away. + * [onPinToggle] allows pinning and unpinning models directly inside the dialog. */ @Composable fun ModelPickerDialog( @@ -47,6 +56,7 @@ fun ModelPickerDialog( title: String, isLoading: Boolean = false, pinnedModels: List = emptyList(), + onPinToggle: ((provider: String, model: String) -> Unit)? = null, onSelect: (provider: String, model: String) -> Unit, onDismiss: () -> Unit, ) { @@ -54,7 +64,33 @@ fun ModelPickerDialog( AlertDialog( onDismissRequest = onDismiss, - title = { Text(title) }, + shape = RoundedCornerShape(24.dp), + containerColor = MaterialTheme.colorScheme.surface, + tonalElevation = 6.dp, + title = { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + IconButton( + onClick = onDismiss, + modifier = Modifier.size(32.dp), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } + }, text = { Column { if (isLoading) { @@ -81,21 +117,47 @@ fun ModelPickerDialog( } } + val pinnedSet = + remember(pinnedModels) { + pinnedModels.map { "${it.providerSlug}:${it.modelName}" }.toSet() + } + + val filteredProvidersWithModels = + remember(pickerQuery, providers) { + providers.mapNotNull { provider -> + val matchingModels = + provider.models.orEmpty().filter { model -> + pickerQuery.isBlank() || + provider.name.contains(pickerQuery, ignoreCase = true) || + provider.slug.contains(pickerQuery, ignoreCase = true) || + model.contains(pickerQuery, ignoreCase = true) + } + if (matchingModels.isNotEmpty()) { + provider to matchingModels + } else { + null + } + } + } + SearchBar( query = pickerQuery, onQueryChange = { pickerQuery = it }, placeholder = "Search models and providers...", ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(12.dp)) - LazyColumn(modifier = Modifier.height(400.dp)) { + LazyColumn( + modifier = Modifier.heightIn(max = 420.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { // ── Pinned section ── if (filteredPinned.isNotEmpty()) { item(key = "pinned-header") { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.padding(top = 4.dp, bottom = 4.dp), + modifier = Modifier.padding(top = 4.dp, bottom = 6.dp), ) { Icon( imageVector = Icons.Filled.PushPin, @@ -105,8 +167,9 @@ fun ModelPickerDialog( ) Text( text = "Pinned", - style = MaterialTheme.typography.bodySmall, + style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, ) } } @@ -114,66 +177,122 @@ fun ModelPickerDialog( filteredPinned, key = { "pinned:${it.providerSlug}:${it.modelName}" }, ) { pinned -> - OutlinedButton( + ModelItemCard( + modelName = pinned.modelName, + isPinned = true, + onPinToggle = + if (onPinToggle != null) { + { onPinToggle(pinned.providerSlug, pinned.modelName) } + } else { + null + }, onClick = { onSelect(pinned.providerSlug, pinned.modelName) }, - modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), - contentPadding = - androidx.compose.foundation.layout - .PaddingValues(horizontal = 12.dp, vertical = 6.dp), - ) { - Text( - text = "${pinned.modelName} Β· ${pinned.providerSlug}", - style = MaterialTheme.typography.bodySmall, - ) - } + ) } } - // ── All providers / models ── - items( - providers.filter { provider -> - pickerQuery.isBlank() || - provider.name.contains(pickerQuery, ignoreCase = true) || - provider.slug.contains(pickerQuery, ignoreCase = true) || - provider.models.orEmpty().any { - it.contains(pickerQuery, ignoreCase = true) - } - }, - key = { it.slug }, - ) { provider -> - val models = - provider.models.orEmpty().filter { - pickerQuery.isBlank() || - it.contains(pickerQuery, ignoreCase = true) + // ── All providers / models (lazy item per model) ── + filteredProvidersWithModels.forEach { (provider, models) -> + item(key = "header:${provider.slug}") { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 12.dp, bottom = 6.dp), + ) { + Text( + text = provider.name, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = "${models.size} models", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } + } - if (models.isNotEmpty()) { - Text( - text = provider.name, - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(top = 8.dp, bottom = 4.dp), + items( + items = models, + key = { model -> "${provider.slug}:$model" }, + ) { model -> + val isPinned = "${provider.slug}:$model" in pinnedSet + ModelItemCard( + modelName = model, + isPinned = isPinned, + onPinToggle = + if (onPinToggle != null) { + { onPinToggle(provider.slug, model) } + } else { + null + }, + onClick = { onSelect(provider.slug, model) }, ) - - models.forEach { model -> - OutlinedButton( - onClick = { onSelect(provider.slug, model) }, - modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), - contentPadding = - androidx.compose.foundation.layout - .PaddingValues(horizontal = 12.dp, vertical = 6.dp), - ) { - Text(text = model, style = MaterialTheme.typography.bodySmall) - } - } } } } } } }, - confirmButton = { - TextButton(onClick = onDismiss) { Text("Cancel") } - }, + confirmButton = {}, ) } + +@Composable +private fun ModelItemCard( + modelName: String, + isPinned: Boolean, + onPinToggle: (() -> Unit)?, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = + modifier + .fillMaxWidth() + .padding(vertical = 2.dp) + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = + BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f), + ), + ) { + Row( + modifier = Modifier.padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = modelName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (onPinToggle != null) { + IconButton( + onClick = onPinToggle, + modifier = Modifier.size(32.dp), + ) { + Icon( + imageVector = if (isPinned) Icons.Filled.PushPin else Icons.Outlined.PushPin, + contentDescription = if (isPinned) "Unpin model" else "Pin model", + tint = + if (isPinned) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + }, + modifier = Modifier.size(16.dp), + ) + } + } + } + } +}