refactor(ui): split monolithic screen files into focused components (#485)

* refactor(ui): extract SkillsStep to components package

Move the 252-LOC SkillsStep composable out of ProfilesScreen.kt into
ui/profiles/components/SkillsStep.kt. Body is verbatim (behavior-preserving);
only package + imports changed. Parent now imports and calls it directly.

Verified: ktlintCheck PASS, compileDebugKotlin PASS (local SDK).
Note: sibling screens resolve Modifier.weight() without an explicit import;
matching that convention (no spurious weight import) to avoid the
internal RowColumnParentData.weight shadowing error.

* refactor(ui): extract ProfileBuilderView cluster to components package

Move the Profile Builder wizard (ProfileBuilderView + validateProfileName +
IdentityStep/ModelStep/McpStep/AddMcpDialog/ReviewStep) out of ProfilesScreen.kt
into ui/profiles/components/ProfileBuilderView.kt. Body is verbatim
(behavior-preserving); only package + imports changed.

Notable wiring required:
- validateProfileName kept as internal fun: it is also used by the parent's
  clone dialog, so both the screen and the builder share it via import.
- ProfilesUiState/ProfilesViewModel imported from the ui.profiles package.
- SkillsStep call resolves to the already-extracted sibling file (same package).

Verified: ktlintCheck PASS, compileDebugKotlin PASS (local SDK).

* refactor(ui): extract MoaConfigDialog cluster to components package

Move the Mixture-of-Agents config dialog (MoaConfigDialog + MoaConfigEditor +
MoaSlotPicker + MoaPickerTarget sealed class) out of ModelScreen.kt into
ui/model/components/MoaConfigDialog.kt. Body is verbatim (behavior-preserving);
only package + imports + entry visibility changed.

- Entry MoaConfigDialog is internal (called by ModelScreen root); the editor,
  slot picker, and sealed class stay private to the new file.
- The @Composable annotation preceding the declaration is moved with the
  cluster (earlier attempt left it orphaned, causing a duplicate-annotation
  compile error at the next declaration).

Verified: ktlintCheck PASS, compileDebugKotlin PASS (local SDK).

* refactor(ui): extract PlatformCard + TelegramOnboardingDialog clusters

Move two self-contained clusters out of ChannelsScreen.kt:
- PlatformCard.kt: StateStyle (private data class) + platformStateStyle +
  StateBadge + PlatformCard. Entry PlatformCard is internal (called by
  ChannelsScreen root); StateStyle/StateBadge/platformStateStyle stay private.
- TelegramOnboardingDialog.kt: TelegramOnboardingDialog. Entry internal.

Wiring required:
- ConfigureForm kept in ChannelsScreen as internal (shared by PlatformCard
  and the screen root) and imported into PlatformCard.kt.
- ChannelsUiState/OnboardingPhase imported from the ui.channels package.
- formatTimestamp left in the screen (used by ConfigureForm).

Verified: ktlintCheck PASS, compileDebugKotlin PASS (local SDK).

* refactor(ui): extract SkillsScreen sub-composables to components package

Split the 914-line SkillsScreen.kt into the screen root plus 4 cohesive
component files under ui/skills/components/:
- SourceBadge.kt: shared SourceBadge (internal, used by both tabs)
- InstalledSkillsTab.kt: InstalledSkillsView + SkillCard + SkillPreviewDialog
- HubSkillsTab.kt: HubBrowseView + HubSkillCard
- SkillEditorDialog.kt: SkillEditorDialog

Wiring required:
- CATEGORY_ALL promoted private -> internal in SkillsScreen (shared by the
  screen root and InstalledSkillsView) and imported where needed.
- SkillFilter / SkillsUiState imported from the ui.skills package.
- HubBrowseView now reads isHubSearching/hubResults/hubSearchError off its
  SkillsUiState param (fields, not locals) - no behavior change.

Verified: ktlintCheck PASS, compileDebugKotlin PASS (local SDK).

* refactor(ui): extract SettingsScreen + SystemScreen sub-composables

SettingsScreen.kt (1238 -> ~340): move self-contained sections into
ui/settings/components/ - ConnectionSection (+TestResultCard/SaveIndicator/
TestConnectionButton), BehaviorSection, AppearanceSection (+ChatSection/
NavBarSection), AboutSection, ProfileEditorDialog (+DeleteProfileConfirmDialog).
Shared leaves SectionCard/InfoRow kept in the screen as internal and imported
by the extracted files.

SystemScreen.kt (1755 -> 1619): move CredentialEntryRow + HookCard into
ui/system/components/ (both internal). formatBytes/formatDuration helpers stay
in the screen (used by the root and remaining content).

Verified: ktlintCheck PASS, compileDebugKotlin PASS (local SDK).

* fix(#485): use LocalHermesStatusColors for PlatformCard state colors

Map platform connection-status badges to semantic LocalHermesStatusColors
slots (success/warning/error) + add neutral slot for not_configured/disabled.
Removes hardcoded hex (Color(0xFF...)) and MaterialTheme.colorScheme usage
flagged by Sourcery; neutral defaults to StatusGrey so existing theme
providers are untouched.
This commit is contained in:
M57 authored and GitHub committed 2026-07-07 23:00:24 +03:00
1 parent d178d7f69e
commit ca32e37e2f
24 files changed
+4153 -2840

No files matched your search

@@ -19,7 +19,6 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
@@ -27,21 +26,18 @@ import androidx.compose.material.icons.filled.Psychology
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.PushPin
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -59,8 +55,6 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.MoaConfigPreset
import com.m57.hermescontrol.data.model.MoaModelSlot
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
@@ -68,6 +62,7 @@ import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.model.components.MoaConfigDialog
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -543,450 +538,6 @@ private fun AuxTasksDialog(
// MOA Config Dialog
// ────────────────────────────────────────────────────────────────────
@Composable
private fun MoaConfigDialog(
config: com.m57.hermescontrol.data.model.MoaConfigResponse,
providers: List<com.m57.hermescontrol.data.model.ModelProvider>,
busy: Boolean,
onSave: (com.m57.hermescontrol.data.model.MoaConfigResponse) -> Unit,
onDismiss: () -> Unit,
) {
var draft by remember(config) { mutableStateOf(config) }
var selectedPreset by remember { mutableStateOf(config.default_preset.ifBlank { "default" }) }
var newPresetName by remember { mutableStateOf("") }
var pickerTarget by remember { mutableStateOf<MoaPickerTarget?>(null) }
val presetNames = draft.presets.keys.toList()
val effectivePreset = draft.presets[selectedPreset]
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Mixture of Agents") },
text = {
if (pickerTarget != null && effectivePreset != null) {
// Inline model picker within the dialog
MoaSlotPicker(
providers = providers,
draft = draft,
selectedPreset = selectedPreset,
pickerTarget = pickerTarget!!,
onSlotSelected = { updatedDraft ->
draft = updatedDraft
pickerTarget = null
},
onCancel = { pickerTarget = null },
)
} else {
MoaConfigEditor(
draft = draft,
presetNames = presetNames,
selectedPreset = selectedPreset,
effectivePreset = effectivePreset,
newPresetName = newPresetName,
busy = busy,
onSelectPreset = { selectedPreset = it },
onNewPresetNameChange = { newPresetName = it },
onAddPreset = {
val name = newPresetName.trim()
if (name.isNotBlank() && !draft.presets.containsKey(name)) {
val seed =
effectivePreset ?: MoaConfigPreset(
reference_models = draft.reference_models,
aggregator = draft.aggregator,
reference_temperature = draft.reference_temperature,
aggregator_temperature = draft.aggregator_temperature,
max_tokens = draft.max_tokens,
)
draft =
draft.copy(
default_preset = draft.default_preset.ifBlank { name },
presets = draft.presets + (name to seed),
)
selectedPreset = name
newPresetName = ""
}
},
onDeletePreset = {
if (presetNames.size > 1) {
val remaining = presetNames - selectedPreset
val nextSelected = remaining.first()
draft =
draft.copy(
presets = draft.presets - selectedPreset,
default_preset =
if (draft.default_preset == selectedPreset) {
nextSelected
} else {
draft.default_preset
},
)
selectedPreset = nextSelected
}
},
onUpdatePreset = { updater ->
if (effectivePreset != null) {
draft =
draft.copy(
presets = draft.presets + (selectedPreset to updater(effectivePreset)),
)
}
},
onPickReference = { index ->
pickerTarget = MoaPickerTarget.Reference(index)
},
onPickAggregator = {
pickerTarget = MoaPickerTarget.Aggregator
},
onAddReference = {
if (effectivePreset != null) {
draft =
draft.copy(
presets =
draft.presets + (
selectedPreset to
effectivePreset.copy(
reference_models =
effectivePreset.reference_models +
effectivePreset.aggregator,
)
),
)
}
},
onRemoveReference = { index ->
if (effectivePreset != null && effectivePreset.reference_models.size > 1) {
draft =
draft.copy(
presets =
draft.presets + (
selectedPreset to
effectivePreset.copy(
reference_models =
effectivePreset.reference_models.toMutableList().also {
it.removeAt(
index,
)
},
)
),
)
}
},
onSetDefault = {
draft = draft.copy(default_preset = selectedPreset)
},
)
}
},
confirmButton = {
if (pickerTarget == null) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextButton(onClick = onDismiss, enabled = !busy) { Text("Cancel") }
Button(
onClick = { onSave(draft) },
enabled = !busy,
) {
if (busy) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
} else {
Text("Save")
}
}
}
}
},
)
}
private sealed class MoaPickerTarget {
data class Reference(
val index: Int,
) : MoaPickerTarget()
data object Aggregator : MoaPickerTarget()
}
@Composable
private fun MoaConfigEditor(
draft: com.m57.hermescontrol.data.model.MoaConfigResponse,
presetNames: List<String>,
selectedPreset: String,
effectivePreset: com.m57.hermescontrol.data.model.MoaConfigPreset?,
newPresetName: String,
busy: Boolean,
onSelectPreset: (String) -> Unit,
onNewPresetNameChange: (String) -> Unit,
onAddPreset: () -> Unit,
onDeletePreset: () -> Unit,
onUpdatePreset: (
updater: (com.m57.hermescontrol.data.model.MoaConfigPreset) -> com.m57.hermescontrol.data.model.MoaConfigPreset,
) -> Unit,
onPickReference: (Int) -> Unit,
onPickAggregator: () -> Unit,
onAddReference: () -> Unit,
onRemoveReference: (Int) -> Unit,
onSetDefault: () -> Unit,
) {
Column {
Text(
text = "Presets appear as models under the Mixture of Agents provider.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (effectivePreset == null) {
Text("No presets configured", style = MaterialTheme.typography.bodyMedium)
return
}
Spacer(modifier = Modifier.height(12.dp))
// Preset selector
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
// Simplified preset dropdown with buttons
if (presetNames.isNotEmpty()) {
Text("Preset:", style = MaterialTheme.typography.bodySmall)
presetNames.forEach { name ->
TextButton(
onClick = { onSelectPreset(name) },
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
) {
Text(
name,
fontWeight = if (name == selectedPreset) FontWeight.Bold else FontWeight.Normal,
color =
if (name == selectedPreset) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
},
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
// Default preset indicator
Text(
text = "Default: ${draft.default_preset.ifBlank { "(none)" }}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(4.dp))
// Preset actions
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = onSetDefault,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
enabled = presetNames.size > 1,
) {
Text("Set default", style = MaterialTheme.typography.labelSmall)
}
OutlinedButton(
onClick = onDeletePreset,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
enabled = presetNames.size > 1,
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text("Delete", style = MaterialTheme.typography.labelSmall)
}
}
// New preset
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = newPresetName,
onValueChange = onNewPresetNameChange,
placeholder = { Text("new preset name", style = MaterialTheme.typography.bodySmall) },
singleLine = true,
modifier = Modifier.weight(1f).height(48.dp),
textStyle = MaterialTheme.typography.bodySmall,
)
IconButton(onClick = onAddPreset, enabled = newPresetName.isNotBlank()) {
Icon(Icons.Outlined.Add, contentDescription = "Add preset")
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
// Reference models
Text(
text = "Reference models",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.height(4.dp))
effectivePreset.reference_models.forEachIndexed { index, slot ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "${slot.provider} · ${slot.model}",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
)
OutlinedButton(
onClick = { onPickReference(index) },
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp),
) { Text("Change", style = MaterialTheme.typography.labelSmall) }
if (effectivePreset.reference_models.size > 1) {
IconButton(onClick = { onRemoveReference(index) }, modifier = Modifier.size(24.dp)) {
Icon(Icons.Filled.Close, contentDescription = "Remove", modifier = Modifier.size(14.dp))
}
}
}
}
OutlinedButton(
onClick = onAddReference,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
) {
Icon(Icons.Outlined.Add, contentDescription = null, modifier = Modifier.size(14.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Add reference model", style = MaterialTheme.typography.labelSmall)
}
Spacer(modifier = Modifier.height(8.dp))
// Aggregator
Text(
text = "Aggregator",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
)
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "${effectivePreset.aggregator.provider} · ${effectivePreset.aggregator.model}",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
)
OutlinedButton(
onClick = onPickAggregator,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp),
) { Text("Change", style = MaterialTheme.typography.labelSmall) }
}
}
}
@Composable
private fun MoaSlotPicker(
providers: List<com.m57.hermescontrol.data.model.ModelProvider>,
draft: com.m57.hermescontrol.data.model.MoaConfigResponse,
selectedPreset: String,
pickerTarget: MoaPickerTarget,
onSlotSelected: (com.m57.hermescontrol.data.model.MoaConfigResponse) -> Unit,
onCancel: () -> Unit,
) {
var pickerQuery by remember { mutableStateOf("") }
Column {
Text(
text =
"Select model for ${
if (pickerTarget is MoaPickerTarget.Aggregator) {
"aggregator"
} else {
"reference model"
}
}",
style = MaterialTheme.typography.titleSmall,
)
Spacer(modifier = Modifier.height(8.dp))
SearchBar(
query = pickerQuery,
onQueryChange = { pickerQuery = it },
placeholder = "Search providers...",
)
Spacer(modifier = Modifier.height(8.dp))
LazyColumn(modifier = Modifier.height(300.dp)) {
items(
providers.filter {
pickerQuery.isBlank() ||
it.name.contains(pickerQuery, ignoreCase = true) ||
it.slug.contains(pickerQuery, ignoreCase = true)
},
key = { it.slug },
) { provider ->
val models =
provider.models.orEmpty().filter {
pickerQuery.isBlank() ||
it.contains(pickerQuery, ignoreCase = true)
}
if (models.isNotEmpty()) {
Text(
text = provider.name,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 4.dp),
)
models.forEach { model ->
OutlinedButton(
onClick = {
val slot = MoaModelSlot(provider.slug, model)
val update: (
com.m57.hermescontrol.data.model.MoaConfigPreset,
) -> com.m57.hermescontrol.data.model.MoaConfigPreset = { preset ->
when (pickerTarget) {
is MoaPickerTarget.Aggregator -> {
preset.copy(aggregator = slot)
}
is MoaPickerTarget.Reference -> {
preset.copy(
reference_models =
preset.reference_models.toMutableList().also {
it[pickerTarget.index] = slot
},
)
}
}
}
val preset = draft.presets[selectedPreset]
if (preset != null) {
onSlotSelected(
draft.copy(presets = draft.presets + (selectedPreset to update(preset))),
)
}
},
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
) {
Text(text = model, style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
TextButton(onClick = onCancel) {
Text("Back")
}
}
}
// ────────────────────────────────────────────────────────────────────
// Model Picker Dialog
// ────────────────────────────────────────────────────────────────────
@@ -0,0 +1,485 @@
package com.m57.hermescontrol.ui.model.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
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
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.data.model.MoaConfigPreset
import com.m57.hermescontrol.data.model.MoaModelSlot
import com.m57.hermescontrol.ui.common.SearchBar
@Composable
internal fun MoaConfigDialog(
config: com.m57.hermescontrol.data.model.MoaConfigResponse,
providers: List<com.m57.hermescontrol.data.model.ModelProvider>,
busy: Boolean,
onSave: (com.m57.hermescontrol.data.model.MoaConfigResponse) -> Unit,
onDismiss: () -> Unit,
) {
var draft by remember(config) { mutableStateOf(config) }
var selectedPreset by remember { mutableStateOf(config.default_preset.ifBlank { "default" }) }
var newPresetName by remember { mutableStateOf("") }
var pickerTarget by remember { mutableStateOf<MoaPickerTarget?>(null) }
val presetNames = draft.presets.keys.toList()
val effectivePreset = draft.presets[selectedPreset]
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Mixture of Agents") },
text = {
if (pickerTarget != null && effectivePreset != null) {
// Inline model picker within the dialog
MoaSlotPicker(
providers = providers,
draft = draft,
selectedPreset = selectedPreset,
pickerTarget = pickerTarget!!,
onSlotSelected = { updatedDraft ->
draft = updatedDraft
pickerTarget = null
},
onCancel = { pickerTarget = null },
)
} else {
MoaConfigEditor(
draft = draft,
presetNames = presetNames,
selectedPreset = selectedPreset,
effectivePreset = effectivePreset,
newPresetName = newPresetName,
busy = busy,
onSelectPreset = { selectedPreset = it },
onNewPresetNameChange = { newPresetName = it },
onAddPreset = {
val name = newPresetName.trim()
if (name.isNotBlank() && !draft.presets.containsKey(name)) {
val seed =
effectivePreset ?: MoaConfigPreset(
reference_models = draft.reference_models,
aggregator = draft.aggregator,
reference_temperature = draft.reference_temperature,
aggregator_temperature = draft.aggregator_temperature,
max_tokens = draft.max_tokens,
)
draft =
draft.copy(
default_preset = draft.default_preset.ifBlank { name },
presets = draft.presets + (name to seed),
)
selectedPreset = name
newPresetName = ""
}
},
onDeletePreset = {
if (presetNames.size > 1) {
val remaining = presetNames - selectedPreset
val nextSelected = remaining.first()
draft =
draft.copy(
presets = draft.presets - selectedPreset,
default_preset =
if (draft.default_preset == selectedPreset) {
nextSelected
} else {
draft.default_preset
},
)
selectedPreset = nextSelected
}
},
onUpdatePreset = { updater ->
if (effectivePreset != null) {
draft =
draft.copy(
presets = draft.presets + (selectedPreset to updater(effectivePreset)),
)
}
},
onPickReference = { index ->
pickerTarget = MoaPickerTarget.Reference(index)
},
onPickAggregator = {
pickerTarget = MoaPickerTarget.Aggregator
},
onAddReference = {
if (effectivePreset != null) {
draft =
draft.copy(
presets =
draft.presets + (
selectedPreset to
effectivePreset.copy(
reference_models =
effectivePreset.reference_models +
effectivePreset.aggregator,
)
),
)
}
},
onRemoveReference = { index ->
if (effectivePreset != null && effectivePreset.reference_models.size > 1) {
draft =
draft.copy(
presets =
draft.presets + (
selectedPreset to
effectivePreset.copy(
reference_models =
effectivePreset.reference_models.toMutableList().also {
it.removeAt(
index,
)
},
)
),
)
}
},
onSetDefault = {
draft = draft.copy(default_preset = selectedPreset)
},
)
}
},
confirmButton = {
if (pickerTarget == null) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextButton(onClick = onDismiss, enabled = !busy) { Text("Cancel") }
Button(
onClick = { onSave(draft) },
enabled = !busy,
) {
if (busy) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
} else {
Text("Save")
}
}
}
}
},
)
}
private sealed class MoaPickerTarget {
data class Reference(
val index: Int,
) : MoaPickerTarget()
data object Aggregator : MoaPickerTarget()
}
@Composable
private fun MoaConfigEditor(
draft: com.m57.hermescontrol.data.model.MoaConfigResponse,
presetNames: List<String>,
selectedPreset: String,
effectivePreset: com.m57.hermescontrol.data.model.MoaConfigPreset?,
newPresetName: String,
busy: Boolean,
onSelectPreset: (String) -> Unit,
onNewPresetNameChange: (String) -> Unit,
onAddPreset: () -> Unit,
onDeletePreset: () -> Unit,
onUpdatePreset: (
updater: (com.m57.hermescontrol.data.model.MoaConfigPreset) -> com.m57.hermescontrol.data.model.MoaConfigPreset,
) -> Unit,
onPickReference: (Int) -> Unit,
onPickAggregator: () -> Unit,
onAddReference: () -> Unit,
onRemoveReference: (Int) -> Unit,
onSetDefault: () -> Unit,
) {
Column {
Text(
text = "Presets appear as models under the Mixture of Agents provider.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (effectivePreset == null) {
Text("No presets configured", style = MaterialTheme.typography.bodyMedium)
return
}
Spacer(modifier = Modifier.height(12.dp))
// Preset selector
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
// Simplified preset dropdown with buttons
if (presetNames.isNotEmpty()) {
Text("Preset:", style = MaterialTheme.typography.bodySmall)
presetNames.forEach { name ->
TextButton(
onClick = { onSelectPreset(name) },
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
) {
Text(
name,
fontWeight = if (name == selectedPreset) FontWeight.Bold else FontWeight.Normal,
color =
if (name == selectedPreset) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
},
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
// Default preset indicator
Text(
text = "Default: ${draft.default_preset.ifBlank { "(none)" }}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(4.dp))
// Preset actions
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = onSetDefault,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
enabled = presetNames.size > 1,
) {
Text("Set default", style = MaterialTheme.typography.labelSmall)
}
OutlinedButton(
onClick = onDeletePreset,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
enabled = presetNames.size > 1,
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text("Delete", style = MaterialTheme.typography.labelSmall)
}
}
// New preset
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = newPresetName,
onValueChange = onNewPresetNameChange,
placeholder = { Text("new preset name", style = MaterialTheme.typography.bodySmall) },
singleLine = true,
modifier = Modifier.weight(1f).height(48.dp),
textStyle = MaterialTheme.typography.bodySmall,
)
IconButton(onClick = onAddPreset, enabled = newPresetName.isNotBlank()) {
Icon(Icons.Outlined.Add, contentDescription = "Add preset")
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
// Reference models
Text(
text = "Reference models",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.height(4.dp))
effectivePreset.reference_models.forEachIndexed { index, slot ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "${slot.provider} · ${slot.model}",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
)
OutlinedButton(
onClick = { onPickReference(index) },
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp),
) { Text("Change", style = MaterialTheme.typography.labelSmall) }
if (effectivePreset.reference_models.size > 1) {
IconButton(onClick = { onRemoveReference(index) }, modifier = Modifier.size(24.dp)) {
Icon(Icons.Filled.Close, contentDescription = "Remove", modifier = Modifier.size(14.dp))
}
}
}
}
OutlinedButton(
onClick = onAddReference,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
) {
Icon(Icons.Outlined.Add, contentDescription = null, modifier = Modifier.size(14.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Add reference model", style = MaterialTheme.typography.labelSmall)
}
Spacer(modifier = Modifier.height(8.dp))
// Aggregator
Text(
text = "Aggregator",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
)
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "${effectivePreset.aggregator.provider} · ${effectivePreset.aggregator.model}",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
)
OutlinedButton(
onClick = onPickAggregator,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp),
) { Text("Change", style = MaterialTheme.typography.labelSmall) }
}
}
}
@Composable
private fun MoaSlotPicker(
providers: List<com.m57.hermescontrol.data.model.ModelProvider>,
draft: com.m57.hermescontrol.data.model.MoaConfigResponse,
selectedPreset: String,
pickerTarget: MoaPickerTarget,
onSlotSelected: (com.m57.hermescontrol.data.model.MoaConfigResponse) -> Unit,
onCancel: () -> Unit,
) {
var pickerQuery by remember { mutableStateOf("") }
Column {
Text(
text =
"Select model for ${
if (pickerTarget is MoaPickerTarget.Aggregator) {
"aggregator"
} else {
"reference model"
}
}",
style = MaterialTheme.typography.titleSmall,
)
Spacer(modifier = Modifier.height(8.dp))
SearchBar(
query = pickerQuery,
onQueryChange = { pickerQuery = it },
placeholder = "Search providers...",
)
Spacer(modifier = Modifier.height(8.dp))
LazyColumn(modifier = Modifier.height(300.dp)) {
items(
providers.filter {
pickerQuery.isBlank() ||
it.name.contains(pickerQuery, ignoreCase = true) ||
it.slug.contains(pickerQuery, ignoreCase = true)
},
key = { it.slug },
) { provider ->
val models =
provider.models.orEmpty().filter {
pickerQuery.isBlank() ||
it.contains(pickerQuery, ignoreCase = true)
}
if (models.isNotEmpty()) {
Text(
text = provider.name,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 4.dp),
)
models.forEach { model ->
OutlinedButton(
onClick = {
val slot = MoaModelSlot(provider.slug, model)
val update: (
com.m57.hermescontrol.data.model.MoaConfigPreset,
) -> com.m57.hermescontrol.data.model.MoaConfigPreset = { preset ->
when (pickerTarget) {
is MoaPickerTarget.Aggregator -> {
preset.copy(aggregator = slot)
}
is MoaPickerTarget.Reference -> {
preset.copy(
reference_models =
preset.reference_models.toMutableList().also {
it[pickerTarget.index] = slot
},
)
}
}
}
val preset = draft.presets[selectedPreset]
if (preset != null) {
onSlotSelected(
draft.copy(presets = draft.presets + (selectedPreset to update(preset))),
)
}
},
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
) {
Text(text = model, style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
TextButton(onClick = onCancel) {
Text("Back")
}
}
}