feat(profiles): delete, rename, auto-describe and setup command (issue #781) (#818)

* feat(profiles): delete, rename, auto-describe and setup command (issue #781)

* feat(profiles): use shared ModelPickerDialog for set model (issue #781)

* fix(profiles): move overflow menu to card header so it can't be clipped (issue #781)

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-05 17:59:30 +03:00
1 parent 19c053b3fa
commit 80be168801
7 files changed
+868 -100

No files matched your search

@@ -28,6 +28,7 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
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
@@ -44,7 +45,10 @@ 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.platform.LocalClipboardManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -57,6 +61,7 @@ import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.SkeletonListState
import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
import com.m57.hermescontrol.ui.profiles.components.ProfileBuilderView
import com.m57.hermescontrol.ui.profiles.components.validateProfileName
@@ -71,8 +76,6 @@ fun ProfilesScreen(
var soulEditProfileName by remember { mutableStateOf<String?>(null) }
var modelEditProfileName by remember { mutableStateOf<String?>(null) }
var tempModelProvider by remember { mutableStateOf("") }
var tempModelName by remember { mutableStateOf("") }
var cloneProfileName by remember { mutableStateOf<String?>(null) }
var newCloneName by remember { mutableStateOf("") }
@@ -80,6 +83,16 @@ fun ProfilesScreen(
var descEditProfileName by remember { mutableStateOf<String?>(null) }
var tempDescription by remember { mutableStateOf("") }
var renameProfileName by remember { mutableStateOf<String?>(null) }
var newRenameName by remember { mutableStateOf("") }
var deleteProfileName by remember { mutableStateOf<String?>(null) }
var setupCmdProfileName by remember { mutableStateOf<String?>(null) }
var copiedSetupCmd by remember { mutableStateOf(false) }
val clipboardManager = LocalClipboardManager.current
var isBuildingProfile by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
@@ -271,6 +284,107 @@ fun ProfilesScreen(
modifier = Modifier.padding(start = 8.dp),
)
}
var showMenu by remember { mutableStateOf(false) }
Box {
IconButton(onClick = { showMenu = true }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "More options",
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
) {
if (profile.is_default != true) {
DropdownMenuItem(
text = {
Text(
stringResource(
R.string.profiles_action_rename,
),
)
},
onClick = {
showMenu = false
renameProfileName = profile.name
newRenameName = profile.name
},
)
}
DropdownMenuItem(
text = {
Text(
stringResource(
R.string.profiles_action_auto_describe,
),
)
},
enabled = !state.isAutoDescribing,
onClick = {
showMenu = false
viewModel.autoDescribeProfile(profile.name)
},
)
DropdownMenuItem(
text = {
Text(
stringResource(
R.string.profiles_action_edit_description,
),
)
},
onClick = {
showMenu = false
descEditProfileName = profile.name
tempDescription = profile.description ?: ""
},
)
DropdownMenuItem(
text = {
Text(
stringResource(R.string.profiles_action_clone),
)
},
onClick = {
showMenu = false
cloneProfileName = profile.name
newCloneName = ""
},
)
DropdownMenuItem(
text = {
Text(
stringResource(
R.string.profiles_action_setup_command,
),
)
},
onClick = {
showMenu = false
setupCmdProfileName = profile.name
},
)
if (profile.is_default != true) {
HorizontalDivider()
DropdownMenuItem(
text = {
Text(
stringResource(R.string.action_delete),
color = MaterialTheme.colorScheme.error,
)
},
onClick = {
showMenu = false
deleteProfileName = profile.name
},
)
}
}
}
}
Spacer(modifier = Modifier.height(12.dp))
@@ -358,56 +472,12 @@ fun ProfilesScreen(
Button(
onClick = {
modelEditProfileName = profile.name
tempModelProvider = profile.provider ?: ""
tempModelName = profile.model ?: ""
viewModel.loadModelOptions()
},
modifier = Modifier.padding(end = 8.dp),
) {
Text(stringResource(R.string.profiles_action_set_model))
}
var showMenu by remember { mutableStateOf(false) }
Box {
IconButton(onClick = { showMenu = true }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "More options",
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
) {
DropdownMenuItem(
text = {
Text(
stringResource(
R.string.profiles_action_edit_description,
),
)
},
onClick = {
showMenu = false
descEditProfileName = profile.name
tempDescription = profile.description ?: ""
},
)
DropdownMenuItem(
text = {
Text(
stringResource(R.string.profiles_action_clone),
)
},
onClick = {
showMenu = false
cloneProfileName = profile.name
newCloneName = ""
},
)
}
}
}
}
}
@@ -486,57 +556,20 @@ fun ProfilesScreen(
}
if (modelEditProfileName != null) {
AlertDialog(
onDismissRequest = { modelEditProfileName = null },
title = {
Text(
text =
stringResource(
R.string.profiles_title_set_model,
modelEditProfileName.orEmpty(),
),
)
},
text = {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = tempModelProvider,
onValueChange = { tempModelProvider = it },
label = { Text(stringResource(R.string.profiles_label_provider_input)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = tempModelName,
onValueChange = { tempModelName = it },
label = { Text(stringResource(R.string.profiles_label_model_input)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
},
confirmButton = {
TextButton(
onClick = {
val profileName = modelEditProfileName
if (profileName != null) {
viewModel.updateModel(profileName, tempModelProvider, tempModelName)
}
modelEditProfileName = null
},
) {
Text(stringResource(R.string.action_ok))
}
},
dismissButton = {
TextButton(
onClick = { modelEditProfileName = null },
) {
Text(stringResource(R.string.action_cancel))
ModelPickerDialog(
providers = state.modelProviders,
title = stringResource(R.string.profiles_title_set_model, modelEditProfileName.orEmpty()),
isLoading = state.isLoadingBuilderData && state.modelProviders.isEmpty(),
pinnedModels = state.modelPickerPinned,
onPinToggle = viewModel::togglePinModel,
onSelect = { provider, model ->
val profileName = modelEditProfileName
if (profileName != null) {
viewModel.updateModel(profileName, provider, model)
}
modelEditProfileName = null
},
onDismiss = { modelEditProfileName = null },
)
}
@@ -633,6 +666,205 @@ fun ProfilesScreen(
},
)
}
if (renameProfileName != null) {
AlertDialog(
onDismissRequest = { renameProfileName = null },
title = {
Text(
text =
stringResource(
R.string.profiles_title_rename,
renameProfileName.orEmpty(),
),
)
},
text = {
val errorMsg = if (newRenameName.isNotEmpty()) validateProfileName(newRenameName) else null
OutlinedTextField(
value = newRenameName,
onValueChange = { newRenameName = it },
label = { Text(stringResource(R.string.profiles_label_new_name_input)) },
isError = errorMsg != null,
supportingText = errorMsg?.let { { Text(it) } },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
},
confirmButton = {
TextButton(
onClick = {
val oldName = renameProfileName
if (oldName != null) {
viewModel.renameProfile(oldName, newRenameName.trim())
}
renameProfileName = null
},
enabled =
newRenameName.isNotBlank() &&
newRenameName != renameProfileName &&
validateProfileName(newRenameName) == null,
) {
Text(stringResource(R.string.profiles_action_rename))
}
},
dismissButton = {
TextButton(
onClick = { renameProfileName = null },
) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
if (deleteProfileName != null) {
val profileToDelete = state.profiles.firstOrNull { it.name == deleteProfileName }
AlertDialog(
onDismissRequest = { deleteProfileName = null },
title = {
Text(
text =
stringResource(
R.string.profiles_title_delete,
deleteProfileName.orEmpty(),
),
)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text =
stringResource(
R.string.profiles_delete_warning,
deleteProfileName.orEmpty(),
),
)
profileToDelete?.path?.let {
Text(
text = stringResource(R.string.profiles_delete_path, it),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (profileToDelete?.gateway_running == true) {
Text(
text = stringResource(R.string.profiles_delete_gateway_warning),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
},
confirmButton = {
TextButton(
onClick = {
val name = deleteProfileName
if (name != null) {
viewModel.deleteProfile(name)
}
deleteProfileName = null
},
) {
Text(
text = stringResource(R.string.action_delete),
color = MaterialTheme.colorScheme.error,
)
}
},
dismissButton = {
TextButton(
onClick = { deleteProfileName = null },
) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
if (setupCmdProfileName != null) {
LaunchedEffect(setupCmdProfileName) {
copiedSetupCmd = false
setupCmdProfileName?.let { viewModel.fetchSetupCommand(it) }
}
AlertDialog(
onDismissRequest = { setupCmdProfileName = null },
title = {
Text(
text =
stringResource(
R.string.profiles_title_setup_command,
setupCmdProfileName.orEmpty(),
),
)
},
text = {
if (state.isLoadingSetupCommand) {
Box(
modifier =
Modifier
.fillMaxWidth()
.height(80.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
} else {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = stringResource(R.string.profiles_setup_command_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Box(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(12.dp),
) {
Text(
text = state.setupCommand.orEmpty(),
style =
MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
),
)
}
}
}
},
confirmButton = {
if (!state.isLoadingSetupCommand && state.setupCommand != null) {
Button(
onClick = {
clipboardManager.setText(AnnotatedString(state.setupCommand.orEmpty()))
copiedSetupCmd = true
},
) {
Text(
text =
stringResource(
if (copiedSetupCmd) {
R.string.profiles_action_copied
} else {
R.string.profiles_action_copy
},
),
)
}
}
},
dismissButton = {
TextButton(
onClick = { setupCmdProfileName = null },
) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
}
// ---------------------------------------------------------------------
@@ -2,11 +2,15 @@ package com.m57.hermescontrol.ui.profiles
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.CloneProfileRequest
import com.m57.hermescontrol.data.model.CreateProfileRequest
import com.m57.hermescontrol.data.model.HubSkill
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.model.ProfileDescribeAutoRequest
import com.m57.hermescontrol.data.model.ProfileInfo
import com.m57.hermescontrol.data.model.RenameProfileRequest
import com.m57.hermescontrol.data.model.SetActiveProfileRequest
import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.data.model.UpdateProfileDescriptionRequest
@@ -34,6 +38,12 @@ data class ProfilesUiState(
val isLoadingSoul: Boolean = false,
val errorMessage: String? = null,
val toastMessage: String? = null,
// Rename / delete / auto-describe / setup-command states
val isAutoDescribing: Boolean = false,
val setupCommand: String? = null,
val isLoadingSetupCommand: Boolean = false,
// Model picker (issue #781 — Set Model uses the shared ModelPickerDialog)
val modelPickerPinned: List<PinnedModel> = emptyList(),
// Profile Builder states
val modelProviders: List<ModelProvider> = emptyList(),
val isLoadingBuilderData: Boolean = false,
@@ -296,6 +306,198 @@ class ProfilesViewModel :
}
}
fun renameProfile(
oldName: String,
newName: String,
) {
if (oldName == newName) return
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
val result =
withContext(Dispatchers.IO) {
safeApiCall {
ApiClient.hermesApi.renameProfile(
oldName,
RenameProfileRequest(newName),
)
}
}
when (result) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
toastMessage = "Profile '$oldName' renamed to '$newName'",
)
}
loadProfiles()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
toastMessage = "Failed to rename profile '$oldName': ${result.error.message}",
)
}
}
}
}
}
fun deleteProfile(name: String) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.deleteProfile(name) }
}
when (result) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
toastMessage = "Profile '$name' deleted",
)
}
loadProfiles()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
toastMessage = "Failed to delete profile '$name': ${result.error.message}",
)
}
}
}
}
}
fun autoDescribeProfile(name: String) {
_uiState.update { it.copy(isAutoDescribing = true) }
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall {
ApiClient.hermesApi.describeProfileAuto(
name,
ProfileDescribeAutoRequest(overwrite = true),
)
}
}
when (result) {
is NetworkResult.Success -> {
val body = result.data
if (body.ok) {
_uiState.update {
it.copy(
isAutoDescribing = false,
toastMessage = "Auto-described profile '$name'",
)
}
loadProfiles()
} else {
// Backend returns generation failures as ok:false + reason,
// NOT an HTTP error — surface the reason to the user.
_uiState.update {
it.copy(
isAutoDescribing = false,
toastMessage =
"Auto-describe failed for '$name': " +
(body.reason ?: "unknown reason"),
)
}
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isAutoDescribing = false,
toastMessage = "Failed to auto-describe profile '$name': ${result.error.message}",
)
}
}
}
}
}
fun fetchSetupCommand(name: String) {
_uiState.update { it.copy(isLoadingSetupCommand = true, setupCommand = null) }
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getProfileSetupCommand(name) }
}
when (result) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isLoadingSetupCommand = false,
setupCommand = result.data.command,
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoadingSetupCommand = false,
toastMessage = "Failed to fetch setup command: ${result.error.message}",
)
}
}
}
}
}
fun loadModelOptions() {
_uiState.update { it.copy(isLoadingBuilderData = true, errorMessage = null) }
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getModelOptions() }
}
when (result) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isLoadingBuilderData = false,
modelProviders = result.data.providers,
modelPickerPinned = AuthManager.getPinnedModels(),
)
}
}
is NetworkResult.Failure -> {
// Toast, not errorMessage — a picker-load failure must NOT
// blank the profiles list with the ErrorState branch.
_uiState.update {
it.copy(
isLoadingBuilderData = false,
toastMessage = "Failed to load models: ${result.error.message}",
)
}
}
}
}
}
fun togglePinModel(
providerSlug: String,
modelName: String,
) {
val target = PinnedModel(providerSlug, modelName)
val current = AuthManager.getPinnedModels().toMutableList()
if (target in current) {
current.remove(target)
} else {
current.add(target)
}
AuthManager.savePinnedModels(current)
_uiState.update { it.copy(modelPickerPinned = current) }
}
fun loadBuilderData() {
_uiState.update { it.copy(isLoadingBuilderData = true, errorMessage = null) }
viewModelScope.launch {