feat(ui): add Bots and Groups tab strip with GroupCard and disband action (#987)

This commit is contained in:
M57 authored and GitHub committed 2026-08-27 01:53:54 +03:00
1 parent 6ca248d28b
commit 2fed891d78
9 files changed
+497 -92

No files matched your search

@@ -69,8 +69,8 @@ data class CanonicalSessionInfo(
val root_title: String? = null,
val title: String? = null,
val preview: String? = null,
val started_at: Long? = null,
val last_active: Long? = null,
val started_at: Double? = null,
val last_active: Double? = null,
val message_count: Int? = null,
)
@@ -79,8 +79,8 @@ data class ProfileSessionSummary(
val id: String,
val title: String? = null,
val preview: String? = null,
val started_at: Long? = null,
val last_active: Long? = null,
val started_at: Double? = null,
val last_active: Double? = null,
val message_count: Int? = null,
)
@@ -89,7 +89,7 @@ data class ProfileWorkerSummary(
val id: String,
val source: String? = null,
val title: String? = null,
val last_active: Long? = null,
val last_active: Double? = null,
)
@Serializable
@@ -100,7 +100,7 @@ data class BotRosterMeta(
val hidden: Boolean? = null,
val groups: List<String>? = null,
val group: String? = null,
val created: Long? = null,
val created: Double? = null,
)
@Serializable
@@ -939,6 +939,16 @@ object HermesWsClient {
val event =
try {
val rpc = OkHttpProvider.json.decodeFromString<JsonRpcResponse>(text)
// Resolve pending request() calls with the RAW JsonElement from
// rpc.result, BEFORE EventParser.parse() converts it via toAny().
// This preserves integer/float type fidelity needed by callers
// that re-deserialize the result into typed data classes.
val rpcId = rpc.id
if (rpcId != null && rpc.error == null && rpc.result != null) {
synchronized(outboundLock) { pendingPromptSubmits.remove(rpcId) }
removeQueuedMessage(rpcId)
resolvePending(rpcId, rpc.result, null)
}
val parsed = EventParser.parse(rpc, text)
if (parsed is WsEvent.MessageComplete) {
parsed.copy(
@@ -8,6 +8,7 @@ import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
/**
@@ -46,6 +47,19 @@ fun Any?.toJsonElement(): JsonElement =
null -> JsonNull
is JsonElement -> this
is Boolean -> JsonPrimitive(this)
is Int -> JsonPrimitive(this)
is Long -> JsonPrimitive(this)
is Float -> JsonPrimitive(this)
is Double -> {
// Preserve integer semantics: 177.0 → JsonPrimitive(177) not "177.0"
if (this % 1.0 == 0.0 && this >= Int.MIN_VALUE.toDouble() && this <= Int.MAX_VALUE.toDouble()) {
JsonPrimitive(this.toInt())
} else if (this % 1.0 == 0.0 && this >= Long.MIN_VALUE.toDouble() && this <= Long.MAX_VALUE.toDouble()) {
JsonPrimitive(this.toLong())
} else {
JsonPrimitive(this)
}
}
is Number -> JsonPrimitive(this)
is String -> JsonPrimitive(this)
is Map<*, *> -> JsonObject(this.map { it.key.toString() to it.value.toJsonElement() }.toMap())
@@ -63,7 +77,7 @@ fun JsonElement.toAny(): Any? =
if (isString) {
content
} else {
booleanOrNull ?: doubleOrNull ?: longOrNull ?: content
booleanOrNull ?: intOrNull ?: longOrNull ?: doubleOrNull ?: content
}
}
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.bots
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@@ -21,11 +22,17 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.DeleteOutline
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Group
import androidx.compose.material.icons.filled.GroupAdd
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SmartToy
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
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.ExperimentalMaterial3Api
@@ -33,9 +40,13 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -75,8 +86,49 @@ fun BotsScreen(
var showCreateDialog by remember { mutableStateOf(false) }
var showCreateGroupDialog by remember { mutableStateOf(false) }
var editingBot by remember { mutableStateOf<ProfileInfo?>(null) }
var disbandingGroup by remember { mutableStateOf<GroupInfo?>(null) }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
viewModel.loadBots()
}
disbandingGroup?.let { group ->
AlertDialog(
onDismissRequest = { disbandingGroup = null },
title = {
Text(
text = stringResource(R.string.bots_group_disband_confirm_title, group.name),
fontWeight = FontWeight.Bold,
)
},
text = {
Text(stringResource(R.string.bots_group_disband_confirm_msg))
},
confirmButton = {
Button(
onClick = {
viewModel.disbandGroupChat(group.name) {
disbandingGroup = null
}
},
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
) {
Text(stringResource(R.string.bots_group_disband))
}
},
dismissButton = {
TextButton(onClick = { disbandingGroup = null }) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
editingBot?.let { bot ->
EditBotBottomSheet(
bot = bot,
@@ -216,77 +268,161 @@ fun BotsScreen(
isRefreshing = state.isRefreshing,
onRefresh = { viewModel.loadBots(isRefresh = true) },
) {
when {
state.isLoading && state.profiles.isEmpty() -> {
SkeletonListState()
}
state.errorMessage != null && state.profiles.isEmpty() -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadBots() },
Column(modifier = Modifier.fillMaxSize()) {
PrimaryTabRow(
selectedTabIndex = state.selectedTab.ordinal,
modifier = Modifier.fillMaxWidth(),
) {
Tab(
selected = state.selectedTab == BotsTab.BOTS,
onClick = { viewModel.setSelectedTab(BotsTab.BOTS) },
text = { Text(stringResource(R.string.bots_tab_all)) },
icon = { Icon(Icons.Filled.SmartToy, contentDescription = null) },
)
}
state.displayProfiles.isEmpty() -> {
EmptyState(
title = stringResource(R.string.bots_empty_title),
subtitle = stringResource(R.string.bots_empty_description),
modifier = Modifier.fillMaxSize(),
)
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (state.activeNowBots.isNotEmpty() && !isSearchActive) {
item(key = "active_now_strip") {
ActiveNowStrip(
activeBots = state.activeNowBots,
onSelectBot = { bot ->
scope.launch {
viewModel.selectBot(bot)
val canonicalId =
bot.canonical_session?.resolved_id
?: bot.canonical_session?.id
if (!canonicalId.isNullOrBlank()) {
NavigationController.openChatSession(canonicalId)
} else {
NavigationController.navigateTo(com.m57.hermescontrol.ChatScreen)
}
}
},
)
}
}
items(
items = state.displayProfiles,
key = { it.name },
) { profile ->
BotCard(
profile = profile,
isActiveProfile = profile.name == state.activeProfileName,
onClick = {
scope.launch {
viewModel.selectBot(profile)
val canonicalId =
profile.canonical_session?.resolved_id
?: profile.canonical_session?.id
if (!canonicalId.isNullOrBlank()) {
NavigationController.openChatSession(canonicalId)
} else {
NavigationController.navigateTo(com.m57.hermescontrol.ChatScreen)
}
}
},
onEditClick = {
editingBot = profile
Tab(
selected = state.selectedTab == BotsTab.GROUPS,
onClick = { viewModel.setSelectedTab(BotsTab.GROUPS) },
text = {
Text(
if (state.allGroups.isNotEmpty()) {
"${stringResource(R.string.bots_tab_groups)} (${state.allGroups.size})"
} else {
stringResource(R.string.bots_tab_groups)
},
)
},
icon = { Icon(Icons.Filled.Group, contentDescription = null) },
)
}
when (state.selectedTab) {
BotsTab.BOTS -> {
when {
state.isLoading && state.profiles.isEmpty() -> {
SkeletonListState()
}
state.errorMessage != null && state.profiles.isEmpty() -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadBots() },
)
}
state.displayProfiles.isEmpty() -> {
EmptyState(
title = stringResource(R.string.bots_empty_title),
subtitle = stringResource(R.string.bots_empty_description),
modifier = Modifier.fillMaxSize(),
)
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (state.activeNowBots.isNotEmpty() && !isSearchActive) {
item(key = "active_now_strip") {
ActiveNowStrip(
activeBots = state.activeNowBots,
onSelectBot = { bot ->
scope.launch {
viewModel.selectBot(bot)
val canonicalId =
bot.canonical_session?.resolved_id
?: bot.canonical_session?.id
if (!canonicalId.isNullOrBlank()) {
NavigationController.openChatSession(canonicalId)
} else {
NavigationController.navigateTo(
com.m57.hermescontrol.ChatScreen,
)
}
}
},
)
}
}
items(
items = state.displayProfiles,
key = { it.name },
) { profile ->
BotCard(
profile = profile,
isActiveProfile = profile.name == state.activeProfileName,
onClick = {
scope.launch {
viewModel.selectBot(profile)
val canonicalId =
profile.canonical_session?.resolved_id
?: profile.canonical_session?.id
if (!canonicalId.isNullOrBlank()) {
NavigationController.openChatSession(canonicalId)
} else {
NavigationController.navigateTo(com.m57.hermescontrol.ChatScreen)
}
}
},
onEditClick = {
editingBot = profile
},
)
}
}
}
}
}
BotsTab.GROUPS -> {
when {
state.displayGroups.isEmpty() -> {
EmptyState(
title = stringResource(R.string.bots_groups_empty_title),
subtitle = stringResource(R.string.bots_groups_empty_desc),
modifier = Modifier.fillMaxSize(),
)
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(
items = state.displayGroups,
key = { it.name },
) { group ->
GroupCard(
group = group,
onClick = {
if (group.members.isNotEmpty()) {
scope.launch {
val primary = group.members.first()
viewModel.selectBot(primary)
val canonicalId =
primary.canonical_session?.resolved_id
?: primary.canonical_session?.id
if (!canonicalId.isNullOrBlank()) {
NavigationController.openChatSession(canonicalId)
} else {
NavigationController.navigateTo(
com.m57.hermescontrol.ChatScreen,
)
}
}
}
},
onDisbandClick = {
disbandingGroup = group
},
)
}
}
}
}
}
}
@@ -464,3 +600,95 @@ private fun BotCard(
}
}
}
@Composable
private fun GroupCard(
group: GroupInfo,
onClick: () -> Unit,
onDisbandClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(
shape = RoundedCornerShape(12.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
modifier =
modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.testTag("group_card_${group.name}"),
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.size(44.dp),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Filled.Group,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(24.dp),
)
}
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = group.name,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(R.string.bots_group_members_count, group.members.size),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
if (group.members.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
group.members.take(4).forEach { member ->
BotAvatar(
name = member.name,
avatar = member.botMeta()?.avatar,
size = 20.dp,
showPresence = false,
)
}
if (group.members.size > 4) {
Text(
text = "+${group.members.size - 4}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
IconButton(
onClick = onDisbandClick,
modifier = Modifier.testTag("group_disband_button_${group.name}"),
) {
Icon(
imageVector = Icons.Filled.DeleteOutline,
contentDescription = stringResource(R.string.bots_group_disband),
tint = MaterialTheme.colorScheme.error,
)
}
}
}
}
@@ -6,12 +6,15 @@ import com.m57.hermescontrol.data.model.BotAvatarMeta
import com.m57.hermescontrol.data.model.BotRosterMeta
import com.m57.hermescontrol.data.model.CreateProfileRequest
import com.m57.hermescontrol.data.model.ProfileInfo
import com.m57.hermescontrol.data.model.ProfilesResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.data.ws.toJsonElement
import com.m57.hermescontrol.ui.common.ToastHost
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
@@ -20,6 +23,18 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.decodeFromJsonElement
enum class BotsTab {
BOTS,
GROUPS,
}
data class GroupInfo(
val name: String,
val members: List<ProfileInfo>,
)
data class BotsUiState(
val isLoading: Boolean = false,
@@ -28,27 +43,49 @@ data class BotsUiState(
val activeProfileName: String? = null,
val searchQuery: String = "",
val showHidden: Boolean = false,
val selectedTab: BotsTab = BotsTab.BOTS,
val errorMessage: String? = null,
val toastMessage: String? = null,
) {
val hasHiddenBots: Boolean
get() = profiles.any { it.isHidden }
val allGroups: List<String>
get() =
profiles
.flatMap { it.botMeta()?.groups.orEmpty() }
.distinct()
.sorted()
val allGroups: List<GroupInfo>
get() {
val groupNames =
profiles
.flatMap { it.botMeta()?.groups.orEmpty() }
.distinct()
.sorted()
return groupNames.map { gName ->
GroupInfo(
name = gName,
members = profiles.filter { it.botMeta()?.groups.orEmpty().contains(gName) },
)
}
}
val displayGroups: List<GroupInfo>
get() {
val query = searchQuery.trim().lowercase()
return allGroups.filter { group ->
if (query.isBlank()) return@filter true
group.name.lowercase().contains(query) ||
group.members.any {
it.name.lowercase().contains(query) ||
it.effectiveTitle.lowercase().contains(query)
}
}
}
val activeNowBots: List<ProfileInfo>
get() {
val nowSeconds = System.currentTimeMillis() / 1000
val nowSeconds = System.currentTimeMillis() / 1000.0
return profiles.filter { profile ->
profile.worker_session != null ||
profile.name == activeProfileName ||
((profile.canonical_session?.last_active ?: 0L) > nowSeconds - 90) ||
((profile.last_session?.last_active ?: 0L) > nowSeconds - 90)
((profile.canonical_session?.last_active ?: 0.0) > nowSeconds - 90) ||
((profile.last_session?.last_active ?: 0.0) > nowSeconds - 90)
}
}
@@ -68,7 +105,7 @@ data class BotsUiState(
.thenByDescending {
it.canonical_session?.last_active
?: it.last_session?.last_active
?: 0L
?: 0.0
}
.thenBy { it.name },
)
@@ -98,10 +135,46 @@ class BotsViewModel(
}
}
viewModelScope.launch(ioDispatcher) {
val profilesResult = safeApiCall { ApiClient.hermesApi.getProfiles() }
// First try fetching profiles via WebSocket RPC (profiles.list) which includes ui_meta (groups, custom avatars).
var profilesWithMeta: List<ProfileInfo>? = null
try {
val rpcResult = HermesWsClient.request(WsMethods.PROFILES_LIST).await()
val jsonElement =
when (rpcResult) {
is JsonElement -> rpcResult
null -> null
else -> rpcResult.toJsonElement()
}
if (jsonElement != null) {
val resp = OkHttpProvider.json.decodeFromJsonElement<ProfilesResponse>(jsonElement)
if (!resp.profiles.isNullOrEmpty()) {
profilesWithMeta = resp.profiles
}
}
} catch (_: Exception) {
// Fallback to REST API below
}
val profilesResult =
if (profilesWithMeta != null) {
null
} else {
safeApiCall { ApiClient.hermesApi.getProfiles() }
}
val activeResult = safeApiCall { ApiClient.hermesApi.getActiveProfile() }
if (profilesResult is NetworkResult.Success) {
if (profilesWithMeta != null) {
val activeName = (activeResult as? NetworkResult.Success)?.data?.active
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
profiles = profilesWithMeta,
activeProfileName = activeName ?: it.activeProfileName,
errorMessage = null,
)
}
} else if (profilesResult is NetworkResult.Success) {
val activeName = (activeResult as? NetworkResult.Success)?.data?.active
_uiState.update {
it.copy(
@@ -131,6 +204,10 @@ class BotsViewModel(
_uiState.update { it.copy(searchQuery = query) }
}
fun setSelectedTab(tab: BotsTab) {
_uiState.update { it.copy(selectedTab = tab) }
}
fun toggleShowHidden() {
_uiState.update { it.copy(showHidden = !it.showHidden) }
}
@@ -209,7 +286,10 @@ class BotsViewModel(
color = color,
),
)
wsClientConfigureBot(name, updatedMeta)
try {
wsClientConfigureBot(name, updatedMeta).await()
} catch (_: Exception) {
}
loadBots()
onSuccess()
}
@@ -248,7 +328,34 @@ class BotsViewModel(
(bot.botMeta() ?: BotRosterMeta()).copy(
groups = existingGroups + groupName,
)
wsClientConfigureBot(name, updatedMeta)
try {
wsClientConfigureBot(name, updatedMeta).await()
} catch (_: Exception) {
}
}
}
loadBots()
onSuccess()
}
}
fun disbandGroupChat(
groupName: String,
onSuccess: () -> Unit,
) {
viewModelScope.launch(ioDispatcher) {
val currentProfiles = _uiState.value.profiles
for (bot in currentProfiles) {
val existingGroups = bot.botMeta()?.groups.orEmpty()
if (existingGroups.contains(groupName)) {
val updatedMeta =
(bot.botMeta() ?: BotRosterMeta()).copy(
groups = existingGroups - groupName,
)
try {
wsClientConfigureBot(bot.name, updatedMeta).await()
} catch (_: Exception) {
}
}
}
loadBots()
@@ -259,7 +366,7 @@ class BotsViewModel(
private fun wsClientConfigureBot(
name: String,
meta: BotRosterMeta,
) {
): kotlinx.coroutines.CompletableDeferred<Any?> {
val metaMap =
buildMap<String, Any?> {
put("title", meta.title)
@@ -279,7 +386,7 @@ class BotsViewModel(
}
}
HermesWsClient.send(
return HermesWsClient.request(
WsMethods.PROFILES_CONFIGURE,
mapOf(
"name" to name,