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 <Hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-22 14:21:06 +03:00
1 parent 56de3323f0
commit 0bb418b4a7
7 files changed
+255 -86

No files matched your search

@@ -563,6 +563,7 @@ fun ChatScreen(
title = "Switch model (this chat)", title = "Switch model (this chat)",
isLoading = state.modelPickerLoading && state.modelPickerProviders.isEmpty(), isLoading = state.modelPickerLoading && state.modelPickerProviders.isEmpty(),
pinnedModels = state.modelPickerPinned, pinnedModels = state.modelPickerPinned,
onPinToggle = { provider, model -> viewModel.togglePinModel(provider, model) },
onSelect = { provider, model -> onSelect = { provider, model ->
viewModel.sendSlashModel(provider, model) viewModel.sendSlashModel(provider, model)
}, },
@@ -1286,6 +1286,21 @@ class ChatViewModel(
_uiState.update { it.copy(showModelPicker = false, modelPickerLoading = false) } _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. * Hot-swap the CURRENT session's model via the /model slash command.
* *
@@ -105,10 +105,19 @@ fun ChatInputBar(
exit = slideOutVertically(targetOffsetY = { it }), exit = slideOutVertically(targetOffsetY = { it }),
) { ) {
Surface( Surface(
modifier = Modifier.fillMaxWidth(), modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 6.dp),
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.surface,
tonalElevation = 3.dp, border =
shadowElevation = 0.dp, BorderStroke(
width = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f),
),
tonalElevation = 2.dp,
shadowElevation = 4.dp,
) { ) {
Column { Column {
// Commands hidden from the suggestion menu — desktop/CLI-only and // Commands hidden from the suggestion menu — desktop/CLI-only and
@@ -4,11 +4,10 @@ import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachFile 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.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.IconButtonDefaults
@@ -36,7 +36,7 @@ import androidx.compose.ui.unit.dp
/** /**
* Bottom toolbar row for the chat composer. * 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). * 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( FilterChip(
selected = currentSessionModel != null, selected = currentSessionModel != null,
onClick = onModelTap, onClick = onModelTap,
@@ -93,11 +93,12 @@ fun ComposerToolbar(
}, },
modifier = modifier =
Modifier Modifier
.width(120.dp) .weight(1f)
.height(28.dp)
.testTag("model_chip"), .testTag("model_chip"),
) )
// Reasoning chip with dropdown menu // Reasoning chip with dropdown menu (right side, next to mic)
Box { Box {
FilterChip( FilterChip(
selected = reasoningLevel != null, selected = reasoningLevel != null,
@@ -110,23 +111,33 @@ fun ComposerToolbar(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
}, },
modifier = Modifier.testTag("reasoning_chip"), modifier =
Modifier
.height(28.dp)
.testTag("reasoning_chip"),
) )
DropdownMenu( DropdownMenu(
expanded = showReasoningMenu, expanded = showReasoningMenu,
onDismissRequest = { showReasoningMenu = false }, 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 = val allLevels =
listOf( listOf(
"none" to "🧠 None", "none" to "None",
"minimal" to "🧠 Minimal", "minimal" to "Minimal",
"low" to "🧠 Low", "low" to "Low",
"medium" to "🧠 Med", "medium" to "Med",
"high" to "🧠 High", "high" to "High",
"xhigh" to "🧠 XHigh", "xhigh" to "XHigh",
"max" to "🧠 Max", "max" to "Max",
"ultra" to "🧠 Ultra", "ultra" to "Ultra",
) )
allLevels.forEach { (level, label) -> allLevels.forEach { (level, label) ->
DropdownMenuItem( DropdownMenuItem(
@@ -156,8 +167,6 @@ fun ComposerToolbar(
} }
} }
Spacer(modifier = Modifier.weight(1f))
// Mic / Stop button // Mic / Stop button
IconButton( IconButton(
onClick = onMicTap, onClick = onMicTap,
@@ -189,19 +198,19 @@ fun ComposerToolbar(
* *
* @param level One of: "none", "minimal", "low", "medium", "high", * @param level One of: "none", "minimal", "low", "medium", "high",
* "xhigh", "max", "ultra", or null for model default. * "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 { private fun buildReasoningLabel(level: String?): String {
return when (level) { return when (level) {
null -> "🧠 Med" null -> "Med"
"none" -> "🧠 None" "none" -> "None"
"minimal" -> "🧠 Minimal" "minimal" -> "Minimal"
"low" -> "🧠 Low" "low" -> "Low"
"medium" -> "🧠 Med" "medium" -> "Med"
"high" -> "🧠 High" "high" -> "High"
"xhigh" -> "🧠 XHigh" "xhigh" -> "XHigh"
"max" -> "🧠 Max" "max" -> "Max"
"ultra" -> "🧠 Ultra" "ultra" -> "Ultra"
else -> "🧠 $level" else -> level
} }
} }
@@ -242,6 +242,8 @@ fun ModelScreen(
providers = state.providers, providers = state.providers,
title = "Set Main Model", title = "Set Main Model",
isLoading = state.isLoading && state.providers.isEmpty(), isLoading = state.isLoading && state.providers.isEmpty(),
pinnedModels = state.pinnedModels,
onPinToggle = { provider, model -> viewModel.togglePinModel(provider, model) },
onSelect = { provider, model -> onSelect = { provider, model ->
viewModel.setMainModel(provider, model) viewModel.setMainModel(provider, model)
}, },
@@ -266,6 +268,8 @@ fun ModelScreen(
providers = state.providers, providers = state.providers,
title = "Set Aux: ${state.auxPickerTask}", title = "Set Aux: ${state.auxPickerTask}",
isLoading = state.isLoading && state.providers.isEmpty(), isLoading = state.isLoading && state.providers.isEmpty(),
pinnedModels = state.pinnedModels,
onPinToggle = { provider, model -> viewModel.togglePinModel(provider, model) },
onSelect = { provider, model -> onSelect = { provider, model ->
viewModel.setAuxTask(state.auxPickerTask, provider, model) viewModel.setAuxTask(state.auxPickerTask, provider, model)
}, },
@@ -524,4 +524,16 @@ class ModelViewModel :
_uiState.update { it.copy(pinnedModels = currentPinned) } _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)
}
}
} }
@@ -1,23 +1,29 @@
package com.m57.hermescontrol.ui.model.components 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
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.PushPin import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.outlined.PushPin
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -25,7 +31,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue 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.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel 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 * [pinnedModels] renders a pinned section at the top (mirrors the global model
* screen's pin section) so frequently-used models are one tap away. * screen's pin section) so frequently-used models are one tap away.
* [onPinToggle] allows pinning and unpinning models directly inside the dialog.
*/ */
@Composable @Composable
fun ModelPickerDialog( fun ModelPickerDialog(
@@ -47,6 +56,7 @@ fun ModelPickerDialog(
title: String, title: String,
isLoading: Boolean = false, isLoading: Boolean = false,
pinnedModels: List<PinnedModel> = emptyList(), pinnedModels: List<PinnedModel> = emptyList(),
onPinToggle: ((provider: String, model: String) -> Unit)? = null,
onSelect: (provider: String, model: String) -> Unit, onSelect: (provider: String, model: String) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
@@ -54,7 +64,33 @@ fun ModelPickerDialog(
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, 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 = { text = {
Column { Column {
if (isLoading) { 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( SearchBar(
query = pickerQuery, query = pickerQuery,
onQueryChange = { pickerQuery = it }, onQueryChange = { pickerQuery = it },
placeholder = "Search models and providers...", 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 ── // ── Pinned section ──
if (filteredPinned.isNotEmpty()) { if (filteredPinned.isNotEmpty()) {
item(key = "pinned-header") { item(key = "pinned-header") {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(top = 4.dp, bottom = 4.dp), modifier = Modifier.padding(top = 4.dp, bottom = 6.dp),
) { ) {
Icon( Icon(
imageVector = Icons.Filled.PushPin, imageVector = Icons.Filled.PushPin,
@@ -105,8 +167,9 @@ fun ModelPickerDialog(
) )
Text( Text(
text = "Pinned", text = "Pinned",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
) )
} }
} }
@@ -114,66 +177,122 @@ fun ModelPickerDialog(
filteredPinned, filteredPinned,
key = { "pinned:${it.providerSlug}:${it.modelName}" }, key = { "pinned:${it.providerSlug}:${it.modelName}" },
) { pinned -> ) { 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) }, 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 ── // ── All providers / models (lazy item per model) ──
items( filteredProvidersWithModels.forEach { (provider, models) ->
providers.filter { provider -> item(key = "header:${provider.slug}") {
pickerQuery.isBlank() || Row(
provider.name.contains(pickerQuery, ignoreCase = true) || verticalAlignment = Alignment.CenterVertically,
provider.slug.contains(pickerQuery, ignoreCase = true) || modifier = Modifier.padding(top = 12.dp, bottom = 6.dp),
provider.models.orEmpty().any { ) {
it.contains(pickerQuery, ignoreCase = true) Text(
} text = provider.name,
}, style = MaterialTheme.typography.labelLarge,
key = { it.slug }, fontWeight = FontWeight.Bold,
) { provider -> color = MaterialTheme.colorScheme.onSurface,
val models = )
provider.models.orEmpty().filter { Spacer(modifier = Modifier.weight(1f))
pickerQuery.isBlank() || Text(
it.contains(pickerQuery, ignoreCase = true) text = "${models.size} models",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
}
if (models.isNotEmpty()) { items(
Text( items = models,
text = provider.name, key = { model -> "${provider.slug}:$model" },
style = MaterialTheme.typography.bodySmall, ) { model ->
fontWeight = FontWeight.Bold, val isPinned = "${provider.slug}:$model" in pinnedSet
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp), 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 = { confirmButton = {},
TextButton(onClick = onDismiss) { Text("Cancel") }
},
) )
} }
@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),
)
}
}
}
}
}