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

@@ -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),
)
}
}
}
}
}