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)",
isLoading = state.modelPickerLoading && state.modelPickerProviders.isEmpty(),
pinnedModels = state.modelPickerPinned,
onPinToggle = { provider, model -> viewModel.togglePinModel(provider, model) },
onSelect = { provider, model ->
viewModel.sendSlashModel(provider, model)
},
@@ -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.
*
@@ -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
@@ -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
}
}
@@ -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)
},
@@ -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)
}
}
}
@@ -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<PinnedModel> = 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),
)
}
}
}
}
}