chore: upgrade ktlint to 1.8.0 and format codebase (#1024)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-31 12:27:40 +03:00
1 parent 3f3b853758
commit cfe3e736fd
66 files changed
+1287 -541

No files matched your search

@@ -21,7 +21,9 @@ import com.m57.hermescontrol.MainActivity
* the launch works on every supported API level (the framework's
* [AssistState] default forwards to it below API 29).
*/
class AssistantSession(context: Context) : VoiceInteractionSession(context) {
class AssistantSession(
context: Context,
) : VoiceInteractionSession(context) {
private var launchHandled = false
override fun onShow(
@@ -239,13 +239,14 @@ data class GroupChatRoomMeta(
) {
val memberNames: List<String>
get() =
members?.mapNotNull { el ->
when (el) {
is JsonPrimitive -> el.content
is JsonObject -> el["name"]?.jsonPrimitive?.content ?: el["handle"]?.jsonPrimitive?.content
else -> null
}
}.orEmpty()
members
?.mapNotNull { el ->
when (el) {
is JsonPrimitive -> el.content
is JsonObject -> el["name"]?.jsonPrimitive?.content ?: el["handle"]?.jsonPrimitive?.content
else -> null
}
}.orEmpty()
}
@Serializable
@@ -76,7 +76,9 @@ object GatewayFileClient {
token: String,
path: String,
): String? {
val kind = com.m57.hermescontrol.ui.chat.mediaKindForPath(path)
val kind =
com.m57.hermescontrol.ui.chat
.mediaKindForPath(path)
val endpoint =
if (kind == com.m57.hermescontrol.ui.chat.MediaKind.AUDIO ||
kind == com.m57.hermescontrol.ui.chat.MediaKind.VIDEO
@@ -23,11 +23,21 @@ fun isLocalNetworkHost(host: String): Boolean {
val a = raw[0].toInt() and 0xFF
val b = raw[1].toInt() and 0xFF
return when {
a == 10 -> true // 10.0.0.0/8
a == 172 && b in 16..31 -> true // 172.16.0.0/12
a == 192 && b == 168 -> true // 192.168.0.0/16
a == 169 && b == 254 -> true // 169.254.0.0/16 link-local
a == 100 && b in 64..127 -> true // 100.64.0.0/10 CGNAT
a == 10 -> true
// 10.0.0.0/8
a == 172 && b in 16..31 -> true
// 172.16.0.0/12
a == 192 && b == 168 -> true
// 192.168.0.0/16
a == 169 && b == 254 -> true
// 169.254.0.0/16 link-local
a == 100 && b in 64..127 -> true
// 100.64.0.0/10 CGNAT
else -> false
}
}
@@ -13,7 +13,9 @@ sealed interface AppUpdateState {
data object Checking : AppUpdateState
/** Latest release equals the installed version. */
data class UpToDate(val latestTag: String) : AppUpdateState
data class UpToDate(
val latestTag: String,
) : AppUpdateState
/** A newer release with an APK asset exists. */
data class UpdateAvailable(
@@ -24,15 +26,21 @@ sealed interface AppUpdateState {
) : AppUpdateState
/** APK download in progress; [progress] is 0..1. */
data class Downloading(val progress: Float) : AppUpdateState
data class Downloading(
val progress: Float,
) : AppUpdateState
/** Download finished and the system package installer was launched. */
data class Installing(val latestTag: String) : AppUpdateState
data class Installing(
val latestTag: String,
) : AppUpdateState
/** Install-from-unknown-sources not granted for this app yet. */
data object NeedsUnknownSourcesPermission : AppUpdateState
data class Error(val message: String) : AppUpdateState
data class Error(
val message: String,
) : AppUpdateState
}
/** The release tag a state carries, when it carries one. */
@@ -256,12 +256,15 @@ object HermesWsClient {
is WsEvent.ThinkingDelta,
is WsEvent.ReasoningDelta,
is WsEvent.ToolStart,
-> pendingReply = true
-> {
pendingReply = true
}
is WsEvent.MessageComplete -> {
pendingReply = false
disconnectIfIdleInBackground()
}
else -> {}
}
}
@@ -992,7 +995,9 @@ object HermesWsClient {
disconnectIfIdleInBackground()
}
else -> Unit
else -> {
Unit
}
}
// tryEmit on a DROP_OLDEST flow only returns false when the
// buffer is full AND no subscriber is draining; with extraBuffer=512
@@ -44,12 +44,30 @@ data class JsonRpcError(
fun Any?.toJsonElement(): JsonElement =
when (this) {
null -> JsonNull
is JsonElement -> this
is Boolean -> JsonPrimitive(this)
is Int -> JsonPrimitive(this)
is Long -> JsonPrimitive(this)
is Float -> JsonPrimitive(this)
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()) {
@@ -60,11 +78,26 @@ fun Any?.toJsonElement(): JsonElement =
JsonPrimitive(this)
}
}
is Number -> JsonPrimitive(this)
is String -> JsonPrimitive(this)
is Map<*, *> -> JsonObject(this.map { it.key.toString() to it.value.toJsonElement() }.toMap())
is List<*> -> JsonArray(this.map { it.toJsonElement() })
else -> JsonPrimitive(this.toString())
is Number -> {
JsonPrimitive(this)
}
is String -> {
JsonPrimitive(this)
}
is Map<*, *> -> {
JsonObject(this.map { it.key.toString() to it.value.toJsonElement() }.toMap())
}
is List<*> -> {
JsonArray(this.map { it.toJsonElement() })
}
else -> {
JsonPrimitive(this.toString())
}
}
fun JsonElement.toAny(): Any? =
@@ -117,7 +117,7 @@ active preset via `LocalHermesStatusColors`.
## Conventions
- ktlint 1.2.1 is enforced in CI. Run `./gradlew ktlintCheck` before committing
- ktlint 1.8.0 is enforced in CI. Run `./gradlew ktlintCheck` before committing
(the gradle task is the gate, not the standalone `ktlint` binary).
- Import order is ASCII-lexicographic (uppercase before lowercase).
- Every change goes through a PR — never push directly to `main`.
@@ -62,7 +62,12 @@ data class BotsUiState(
profiles.find { it.is_default == true || it.name == "default" }
?: profiles.firstOrNull()
val syncSnapshot = defaultProfile?.groupChatSyncSnapshot()
val deletedKeys = syncSnapshot?.deleted?.keys.orEmpty().map { it.lowercase() }
val deletedKeys =
syncSnapshot
?.deleted
?.keys
.orEmpty()
.map { it.lowercase() }
val groupNameMap = linkedMapOf<String, String>()
@@ -101,61 +106,63 @@ data class BotsUiState(
}
}
return groupNameMap.values.map { gName ->
val lower = gName.lowercase()
val matchingRoom =
snapshotRooms[gName]
?: snapshotRooms["name:$gName"]
?: snapshotRooms["id:$gName"]
?: snapshotRooms.values.find { it.name.equals(gName, ignoreCase = true) }
return groupNameMap.values
.map { gName ->
val lower = gName.lowercase()
val matchingRoom =
snapshotRooms[gName]
?: snapshotRooms["name:$gName"]
?: snapshotRooms["id:$gName"]
?: snapshotRooms.values.find { it.name.equals(gName, ignoreCase = true) }
val roomMemberNames = matchingRoom?.memberNames.orEmpty().map { it.lowercase() }
val roomMemberNames = matchingRoom?.memberNames.orEmpty().map { it.lowercase() }
val matchedProfiles =
profiles.filter { profile ->
val botGroups = profile.botMeta()?.allGroups.orEmpty()
botGroups.any { it.equals(gName, ignoreCase = true) } ||
roomMemberNames.contains(profile.name.lowercase()) ||
roomMemberNames.contains(profile.effectiveTitle.lowercase())
}.toMutableList()
val matchedProfiles =
profiles
.filter { profile ->
val botGroups = profile.botMeta()?.allGroups.orEmpty()
botGroups.any { it.equals(gName, ignoreCase = true) } ||
roomMemberNames.contains(profile.name.lowercase()) ||
roomMemberNames.contains(profile.effectiveTitle.lowercase())
}.toMutableList()
// If room listed members (e.g. remote bots or bots not in local profiles), ensure they are seated
if (matchingRoom != null && roomMemberNames.isNotEmpty()) {
for (mName in matchingRoom.memberNames) {
val exists =
matchedProfiles.any {
it.name.equals(mName, ignoreCase = true) ||
it.effectiveTitle.equals(mName, ignoreCase = true)
// If room listed members (e.g. remote bots or bots not in local profiles), ensure they are seated
if (matchingRoom != null && roomMemberNames.isNotEmpty()) {
for (mName in matchingRoom.memberNames) {
val exists =
matchedProfiles.any {
it.name.equals(mName, ignoreCase = true) ||
it.effectiveTitle.equals(mName, ignoreCase = true)
}
if (!exists) {
val local = profiles.find { it.name.equals(mName, ignoreCase = true) }
matchedProfiles.add(local ?: ProfileInfo(name = mName))
}
if (!exists) {
val local = profiles.find { it.name.equals(mName, ignoreCase = true) }
matchedProfiles.add(local ?: ProfileInfo(name = mName))
}
}
}
var groupMembers: List<ProfileInfo> = matchedProfiles
var groupMembers: List<ProfileInfo> = matchedProfiles
// Fallback: If group name was composed from bot names (e.g. "default, scoutbot")
if (groupMembers.isEmpty() && gName.contains(",")) {
val targetNames = gName.split(",").map { it.trim().lowercase() }
groupMembers = profiles.filter { targetNames.contains(it.name.lowercase()) }
}
// Fallback: If group name was composed from bot names (e.g. "default, scoutbot")
if (groupMembers.isEmpty() && gName.contains(",")) {
val targetNames = gName.split(",").map { it.trim().lowercase() }
groupMembers = profiles.filter { targetNames.contains(it.name.lowercase()) }
}
val lastAt =
matchingRoom?.log?.lastOrNull()?.at
?: matchingRoom?.updatedAt
?: 0L
val lastAt =
matchingRoom?.log?.lastOrNull()?.at
?: matchingRoom?.updatedAt
?: 0L
GroupInfo(
name = gName,
members = groupMembers,
lastActivity = lastAt,
GroupInfo(
name = gName,
members = groupMembers,
lastActivity = lastAt,
)
}.sortedWith(
compareByDescending<GroupInfo> { it.lastActivity }
.thenBy { it.name },
)
}.sortedWith(
compareByDescending<GroupInfo> { it.lastActivity }
.thenBy { it.name },
)
}
val displayGroups: List<GroupInfo>
@@ -193,15 +200,13 @@ data class BotsUiState(
profile.name.lowercase().contains(query) ||
profile.effectiveTitle.lowercase().contains(query) ||
profile.effectiveDescription.lowercase().contains(query)
}
.sortedWith(
}.sortedWith(
compareByDescending<ProfileInfo> { it.name == activeProfileName }
.thenByDescending {
it.canonical_session?.last_active
?: it.last_session?.last_active
?: 0.0
}
.thenBy { it.name },
}.thenBy { it.name },
)
}
}
@@ -464,13 +469,14 @@ class BotsViewModel(
rooms = updatedRooms,
deleted = updatedDeleted,
)
HermesWsClient.request(
WsMethods.PROFILES_CONFIGURE,
mapOf(
"name" to defaultProfile.name,
"ui_meta" to mapOf("hermes-bots-groups" to newSnapshot.toMap()),
),
).await()
HermesWsClient
.request(
WsMethods.PROFILES_CONFIGURE,
mapOf(
"name" to defaultProfile.name,
"ui_meta" to mapOf("hermes-bots-groups" to newSnapshot.toMap()),
),
).await()
}
} catch (_: Exception) {
}
@@ -528,13 +534,14 @@ class BotsViewModel(
rooms = updatedRooms,
deleted = deletedMap,
)
HermesWsClient.request(
WsMethods.PROFILES_CONFIGURE,
mapOf(
"name" to defaultProfile.name,
"ui_meta" to mapOf("hermes-bots-groups" to newSnapshot.toMap()),
),
).await()
HermesWsClient
.request(
WsMethods.PROFILES_CONFIGURE,
mapOf(
"name" to defaultProfile.name,
"ui_meta" to mapOf("hermes-bots-groups" to newSnapshot.toMap()),
),
).await()
}
}
} catch (_: Exception) {
@@ -100,8 +100,7 @@ fun CreateGroupChatDialog(
} else {
selectedBots + bot.name
}
}
.padding(vertical = 4.dp),
}.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
@@ -303,12 +303,13 @@ class GroupChatViewModel(
val roomMemberNames = matchingRoom?.memberNames.orEmpty().map { it.lowercase() }
val matchedProfiles =
allProfiles.filter {
val botGroups = it.botMeta()?.allGroups.orEmpty()
botGroups.any { g -> g.equals(groupName, ignoreCase = true) } ||
roomMemberNames.contains(it.name.lowercase()) ||
roomMemberNames.contains(it.effectiveTitle.lowercase())
}.toMutableList()
allProfiles
.filter {
val botGroups = it.botMeta()?.allGroups.orEmpty()
botGroups.any { g -> g.equals(groupName, ignoreCase = true) } ||
roomMemberNames.contains(it.name.lowercase()) ||
roomMemberNames.contains(it.effectiveTitle.lowercase())
}.toMutableList()
// If room listed members (e.g. remote bots or bots not in local profiles), ensure they are seated
if (matchingRoom != null && roomMemberNames.isNotEmpty()) {
@@ -350,7 +351,11 @@ class GroupChatViewModel(
initialMessages =
syncedLog.map { entry ->
val isUser = entry.from?.kind != "member"
val senderName = entry.from?.name.orEmpty().ifBlank { if (isUser) "user" else "bot" }
val senderName =
entry.from
?.name
.orEmpty()
.ifBlank { if (isUser) "user" else "bot" }
val memberProfile =
allProfiles.find {
it.name.equals(senderName, ignoreCase = true) ||
@@ -465,13 +470,14 @@ class GroupChatViewModel(
val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap())
HermesWsClient.request(
WsMethods.PROFILES_CONFIGURE,
mapOf(
"name" to defaultProfile.name,
"ui_meta" to uiMetaPayload,
),
).await()
HermesWsClient
.request(
WsMethods.PROFILES_CONFIGURE,
mapOf(
"name" to defaultProfile.name,
"ui_meta" to uiMetaPayload,
),
).await()
} catch (e: Exception) {
Log.w("GroupChatViewModel", "updateGroupLimits failed: ${e.message}")
}
@@ -559,13 +565,14 @@ class GroupChatViewModel(
val newSnapshot = existingSnapshot.copy(version = 3, updatedAt = now, rooms = updatedRooms)
val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap())
HermesWsClient.request(
WsMethods.PROFILES_CONFIGURE,
mapOf(
"name" to defaultProfile.name,
"ui_meta" to uiMetaPayload,
),
).await()
HermesWsClient
.request(
WsMethods.PROFILES_CONFIGURE,
mapOf(
"name" to defaultProfile.name,
"ui_meta" to uiMetaPayload,
),
).await()
return true
} catch (e: Exception) {
@@ -629,10 +636,11 @@ class GroupChatViewModel(
storedId?.let { inFlightTurns[it] = turnDeferred }
try {
HermesWsClient.request(
WsMethods.PROMPT_SUBMIT,
mapOf("session_id" to runtimeId, "text" to prompt),
).await()
HermesWsClient
.request(
WsMethods.PROMPT_SUBMIT,
mapOf("session_id" to runtimeId, "text" to prompt),
).await()
} catch (submitErr: Exception) {
Log.w(
"GroupChatViewModel",
@@ -662,10 +670,11 @@ class GroupChatViewModel(
storedId?.let { inFlightTurns[it] = turnDeferred }
try {
HermesWsClient.request(
WsMethods.PROMPT_SUBMIT,
mapOf("session_id" to runtimeId, "text" to prompt),
).await()
HermesWsClient
.request(
WsMethods.PROMPT_SUBMIT,
mapOf("session_id" to runtimeId, "text" to prompt),
).await()
} catch (retryErr: Exception) {
Log.e("GroupChatViewModel", "Retry prompt.submit failed for ${bot.name}: ${retryErr.message}")
return null
@@ -880,7 +889,10 @@ class GroupChatViewModel(
val bot = remainingInitial.removeAt(0)
val reply = executeBotTurn(bot, epoch, members)
val nonSystemSize = _uiState.value.messages.filter { !it.isSystem }.size
val nonSystemSize =
_uiState.value.messages
.filter { !it.isSystem }
.size
memberWatermarks[bot.name] = nonSystemSize
if (reply != null) {
@@ -931,7 +943,10 @@ class GroupChatViewModel(
val bot = continuationResponders.removeAt(0)
val reply = executeBotTurn(bot, epoch, members)
val nonSystemSize = _uiState.value.messages.filter { !it.isSystem }.size
val nonSystemSize =
_uiState.value.messages
.filter { !it.isSystem }
.size
memberWatermarks[bot.name] = nonSystemSize
if (reply != null) {
@@ -1055,13 +1070,14 @@ class GroupChatViewModel(
val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap())
HermesWsClient.request(
WsMethods.PROFILES_CONFIGURE,
mapOf(
"name" to defaultProfile.name,
"ui_meta" to uiMetaPayload,
),
).await()
HermesWsClient
.request(
WsMethods.PROFILES_CONFIGURE,
mapOf(
"name" to defaultProfile.name,
"ui_meta" to uiMetaPayload,
),
).await()
} catch (e: Exception) {
Log.w("GroupChatViewModel", "persistSyncSnapshot failed: ${e.message}")
}
@@ -38,14 +38,20 @@ fun toolCallMilestones(messages: List<ChatMessage>): Map<Int, Int> {
var toolCount = 0
messages.forEachIndexed { index, message ->
when (message.role) {
MessageRole.USER -> toolCount = 0
MessageRole.USER -> {
toolCount = 0
}
MessageRole.TOOL -> {
toolCount += 1
if (toolCount % TOOL_CALL_DIVIDER_INTERVAL == 0) {
milestones[index] = toolCount
}
}
else -> Unit
else -> {
Unit
}
}
}
return milestones
@@ -180,15 +180,27 @@ class ChatMediaDelegate(
}
}
is GatewayFileResult.NotFound -> showOpenError("File not found on gateway: ${attachment.name}")
is GatewayFileResult.Forbidden -> showOpenError("Access denied: ${attachment.name}")
is GatewayFileResult.TooLarge -> showOpenError("File too large to save: ${attachment.name}")
is GatewayFileResult.Unauthorized ->
is GatewayFileResult.NotFound -> {
showOpenError("File not found on gateway: ${attachment.name}")
}
is GatewayFileResult.Forbidden -> {
showOpenError("Access denied: ${attachment.name}")
}
is GatewayFileResult.TooLarge -> {
showOpenError("File too large to save: ${attachment.name}")
}
is GatewayFileResult.Unauthorized -> {
showOpenError(
"Session expired — reconnect to save: ${attachment.name}",
)
is GatewayFileResult.Failure ->
}
is GatewayFileResult.Failure -> {
showOpenError("Could not save ${attachment.name}: ${result.throwable.message}")
}
}
} finally {
uiState.update {
@@ -611,12 +611,13 @@ fun ChatScreen(
onNeverAskAgain = { appUpdateViewModel.dismissCurrentUpdate() },
onOpenSettings = {
val intent =
android.content.Intent(
android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
android.net.Uri.parse("package:${context.packageName}"),
).apply {
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
android.content
.Intent(
android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
android.net.Uri.parse("package:${context.packageName}"),
).apply {
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
try {
context.startActivity(intent)
} catch (e: Exception) {
@@ -79,10 +79,14 @@ internal fun canonicalToolResultKey(content: String): String? {
fun canon(e: kotlinx.serialization.json.JsonElement): String =
when (e) {
is kotlinx.serialization.json.JsonObject ->
is kotlinx.serialization.json.JsonObject -> {
e.entries.sortedBy { it.key }.joinToString("|") { "${it.key}=${canon(it.value)}" }
is kotlinx.serialization.json.JsonArray ->
}
is kotlinx.serialization.json.JsonArray -> {
e.joinToString(",") { canon(it) }
}
is kotlinx.serialization.json.JsonPrimitive -> {
// Canonicalize ALL numbers through double, collapsing int/float
// spellings of the same value (0 vs 0.0 → "i0", 0.5 → "d0.5").
@@ -96,9 +100,13 @@ internal fun canonicalToolResultKey(content: String): String? {
}
}
return when (element) {
is kotlinx.serialization.json.JsonObject ->
is kotlinx.serialization.json.JsonObject -> {
element["result"]?.let { canon(it) } ?: canon(element)
else -> canon(element)
}
else -> {
canon(element)
}
}
}
@@ -178,8 +186,7 @@ internal fun stripAttachmentRefLines(content: String): String =
line.startsWith("@image:") ||
line.startsWith("@file:") ||
line == "[screenshot]"
}
.joinToString("\n")
}.joinToString("\n")
.trim()
/**
@@ -764,7 +771,9 @@ class ChatViewModel(
streamingController.flushPendingTokens()
}
else -> Unit
else -> {
Unit
}
}
// First, let the reducer compute the new state and any effects
@@ -2168,7 +2177,10 @@ class ChatViewModel(
sessionHasServerPresence = true
sessionGoneRecoveryInFlight = false
pendingGoneSessionNotice = false
val title = _uiState.value.sessions.find { it.id == sessionId }?.title ?: "Hermes"
val title =
_uiState.value.sessions
.find { it.id == sessionId }
?.title ?: "Hermes"
val generation = resetSessionState(sessionId, title, isLoading = true)
viewModelScope.launch {
// Warm-cache fast-path (desktop parity): paint the cached Room
@@ -9,9 +9,7 @@ import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
private fun List<ChatMessage>.upsertById(message: ChatMessage): List<ChatMessage> {
return (this + message).dedupeById()
}
private fun List<ChatMessage>.upsertById(message: ChatMessage): List<ChatMessage> = (this + message).dedupeById()
/**
* Strips this turn's sealed-orphan prefix from the final message text.
@@ -943,15 +941,16 @@ fun extractTodosFromJson(content: String): List<TodoItem>? {
?: ((obj["parameters"] as? JsonObject)?.get("todos") as? JsonArray)
?: return null
todosArray.mapNotNull { item ->
val itemObj = item as? JsonObject ?: return@mapNotNull null
val id = (itemObj["id"] as? JsonPrimitive)?.content ?: return@mapNotNull null
val contentStr =
(itemObj["content"] as? JsonPrimitive)?.content
?: (itemObj["text"] as? JsonPrimitive)?.content ?: ""
val status = (itemObj["status"] as? JsonPrimitive)?.content ?: "pending"
TodoItem(id = id, content = contentStr, status = status)
}.takeIf { it.isNotEmpty() }
todosArray
.mapNotNull { item ->
val itemObj = item as? JsonObject ?: return@mapNotNull null
val id = (itemObj["id"] as? JsonPrimitive)?.content ?: return@mapNotNull null
val contentStr =
(itemObj["content"] as? JsonPrimitive)?.content
?: (itemObj["text"] as? JsonPrimitive)?.content ?: ""
val status = (itemObj["status"] as? JsonPrimitive)?.content ?: "pending"
TodoItem(id = id, content = contentStr, status = status)
}.takeIf { it.isNotEmpty() }
} catch (_: Exception) {
null
}
@@ -580,9 +580,13 @@ private fun tableTextAlign(align: TableAlign?): TextAlign? =
}
internal sealed interface InlineMathSegment {
data class Text(val value: String) : InlineMathSegment
data class Text(
val value: String,
) : InlineMathSegment
data class Math(val latex: String) : InlineMathSegment
data class Math(
val latex: String,
) : InlineMathSegment
}
/** Splits `$…$` without treating escaped dollars or inline-code contents as math. */
@@ -26,11 +26,26 @@ class SlashCommandDispatcher {
val cmd = parts[0].lowercase()
return when (cmd) {
"/stop", "/interrupt" -> SlashResult.Interrupt
"/new" -> SlashResult.NewSession
"/fork", "/branch" -> SlashResult.SessionBranch
"/model" -> SlashResult.ModelSwitch
"/update" -> SlashResult.Update
"/stop", "/interrupt" -> {
SlashResult.Interrupt
}
"/new" -> {
SlashResult.NewSession
}
"/fork", "/branch" -> {
SlashResult.SessionBranch
}
"/model" -> {
SlashResult.ModelSwitch
}
"/update" -> {
SlashResult.Update
}
"/queue", "/q" -> {
val arg = command.split(" ", limit = 2).getOrElse(1) { "" }.trim()
// Bubble shows the queued TEXT (prefix stripped) so the
@@ -41,8 +56,14 @@ class SlashCommandDispatcher {
// text so the usage message has something to sit under.
SlashResult.QueuePrompt(displayContent = arg.ifBlank { command })
}
"/resume", "/history" -> SlashResult.OpenHistory
else -> SlashResult.RpcDispatch
"/resume", "/history" -> {
SlashResult.OpenHistory
}
else -> {
SlashResult.RpcDispatch
}
}
}
}
@@ -105,5 +126,7 @@ sealed class SlashResult {
* argument). Matching the server echo verbatim lets the transcript sync
* dedupe the two copies of the same logical message.
*/
data class QueuePrompt(val displayContent: String) : SlashResult()
data class QueuePrompt(
val displayContent: String,
) : SlashResult()
}
@@ -236,8 +236,7 @@ fun ChatInputBar(
onInputChange(
ChatInputPolicy.applyMention(inputFieldValue, bot.name),
)
}
.padding(horizontal = 12.dp, vertical = 8.dp),
}.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
BotAvatar(
@@ -107,7 +107,8 @@ fun parseDiffText(
line.startsWith("--- ") || line.startsWith("+++ ") -> {
if (extractedPath == null || extractedPath == defaultPath) {
val candidate =
line.drop(4)
line
.drop(4)
.trim()
.removePrefix("a/")
.removePrefix("b/")
@@ -137,8 +137,7 @@ fun ReasoningCard(
clipboard.setPrimaryClip(ClipData.newPlainText(null, reasoningText))
copied = true
},
)
.testTag("reasoning_card"),
).testTag("reasoning_card"),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
@@ -194,15 +194,22 @@ private fun TodoInspectionCard(todo: TodoItem) {
val statusColors = LocalHermesStatusColors.current
val (statusIcon, statusTint) =
when {
todo.isCompleted ->
todo.isCompleted -> {
Icons.Filled.CheckCircle to statusColors.success
todo.isInProgress ->
}
todo.isInProgress -> {
Icons.Filled.Autorenew to MaterialTheme.colorScheme.primary
todo.isCancelled ->
}
todo.isCancelled -> {
Icons.Filled.Cancel to statusColors.warning
else ->
}
else -> {
Icons.Filled.RadioButtonUnchecked to
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
}
}
Icon(
imageVector = statusIcon,
@@ -242,12 +249,17 @@ private fun InspectionItemCard(indicator: SubagentIndicator) {
val statusColors = LocalHermesStatusColors.current
val (statusIcon, statusTint) =
when {
indicator.isComplete ->
indicator.isComplete -> {
Icons.Filled.CheckCircle to statusColors.success
indicator.isFailed ->
}
indicator.isFailed -> {
Icons.Filled.Cancel to statusColors.error
else ->
}
else -> {
Icons.Filled.Autorenew to MaterialTheme.colorScheme.primary
}
}
Icon(
imageVector = statusIcon,
@@ -13,18 +13,28 @@ import com.m57.hermescontrol.ui.chat.MessageRole
*/
sealed interface ChatTurn {
/** User message — always its own turn (bubble anchor). */
data class User(val message: ChatMessage) : ChatTurn
data class User(
val message: ChatMessage,
) : ChatTurn
/** One agent turn: prose, tool rows, and system events in order. */
data class Agent(val entries: List<AgentEntry>) : ChatTurn
data class Agent(
val entries: List<AgentEntry>,
) : ChatTurn
}
sealed interface AgentEntry {
data class Prose(val message: ChatMessage) : AgentEntry
data class Prose(
val message: ChatMessage,
) : AgentEntry
data class ToolRow(val message: ChatMessage) : AgentEntry
data class ToolRow(
val message: ChatMessage,
) : AgentEntry
data class SystemEvent(val message: ChatMessage) : AgentEntry
data class SystemEvent(
val message: ChatMessage,
) : AgentEntry
}
/**
@@ -71,21 +81,31 @@ fun groupIntoTurns(messages: List<ChatMessage>): List<ChatTurn> {
// display_kind (it's stripped on persistence); detect it by its
// stable content prefix and route it as a system event too, tagging
// it so the timeline chip can name it.
message.displayKind != null ->
message.displayKind != null -> {
agentEntries += AgentEntry.SystemEvent(message)
}
message.isSyntheticSystemRow() ->
message.isSyntheticSystemRow() -> {
agentEntries +=
AgentEntry.SystemEvent(message.copy(displayKind = "max_iterations_reached"))
}
message.role == MessageRole.USER -> {
flushAgent()
turns += ChatTurn.User(message)
}
MessageRole.ASSISTANT == message.role -> agentEntries += AgentEntry.Prose(message)
MessageRole.TOOL == message.role -> agentEntries += AgentEntry.ToolRow(message)
MessageRole.SYSTEM == message.role -> agentEntries += AgentEntry.SystemEvent(message)
MessageRole.ASSISTANT == message.role -> {
agentEntries += AgentEntry.Prose(message)
}
MessageRole.TOOL == message.role -> {
agentEntries += AgentEntry.ToolRow(message)
}
MessageRole.SYSTEM == message.role -> {
agentEntries += AgentEntry.SystemEvent(message)
}
}
}
flushAgent()
@@ -167,8 +187,13 @@ fun messageIdToLazyIndex(
itemIndex++
}
is AgentEntry.ToolRow -> itemIndex++
is AgentEntry.SystemEvent -> itemIndex++
is AgentEntry.ToolRow -> {
itemIndex++
}
is AgentEntry.SystemEvent -> {
itemIndex++
}
}
}
}
@@ -218,8 +218,7 @@ object ToolResultSummary {
.mapNotNull { k ->
val s = summarizeScalar(record[k])
if (s.isEmpty()) null else "${titleCase(k)}: $s"
}
.take(2)
}.take(2)
return if (pairs.isNotEmpty()) pairs.joinToString(" · ") else pluralize(record.keys.size, "field")
}
@@ -474,12 +473,13 @@ object ToolResultSummary {
if (v is JsonArray) {
return clipBlock(
v.mapNotNull {
valueErrorText(it).takeIf {
s ->
s.isNotEmpty()
}
}.take(3).joinToString("; "),
v
.mapNotNull {
valueErrorText(it).takeIf { s ->
s.isNotEmpty()
}
}.take(3)
.joinToString("; "),
700,
12,
)
@@ -110,7 +110,10 @@ object ToolViewBuilder {
private fun parseMaybeObject(value: JsonElement?): JsonObject? =
when (value) {
is JsonObject -> value
is JsonObject -> {
value
}
is JsonPrimitive -> {
if (!value.isString || value.content.trim().isEmpty()) {
null
@@ -122,7 +125,10 @@ object ToolViewBuilder {
}
}
}
else -> null
else -> {
null
}
}
private fun unwrapToolPayload(value: JsonElement?): JsonElement? {
@@ -459,7 +465,13 @@ object ToolViewBuilder {
val lines =
content
.split("\n")
.mapNotNull { line -> Regex("^(\\d+)\\|").find(line)?.groupValues?.get(1)?.toIntOrNull() }
.mapNotNull { line ->
Regex("^(\\d+)\\|")
.find(line)
?.groupValues
?.get(1)
?.toIntOrNull()
}
if (lines.isNotEmpty()) {
lineLabel =
@@ -659,13 +671,11 @@ object ToolViewBuilder {
Regex(
"""\b(\d+)\s+(results?|items?|files?|matches?|documents?|sources?|searches?|steps?|rows?)\b""",
RegexOption.IGNORE_CASE,
)
.find(t)
).find(t)
?: Regex(
"""\b(?:did|found|returned|listed|searched|matched|updated|created|deleted|processed)\s+(\d+)\b""",
RegexOption.IGNORE_CASE,
)
.find(t)
).find(t)
val n = unitMatch?.groupValues?.get(1)?.toIntOrNull() ?: return null
val noun = unitMatch.groupValues.getOrNull(2)?.takeIf { it.isNotEmpty() } ?: fallbackNoun
@@ -715,12 +725,11 @@ object ToolViewBuilder {
return if (payload === record) emptyList() else collectResultItems(payload)
}
private fun cleanVisibleText(text: String): String {
return text
private fun cleanVisibleText(text: String): String =
text
.replace(Regex("`{3,}"), "")
.replace(Regex("(?<=[\\p{L}\\p{N})\\].,!?:;\"'”’])\\[(?:\\d+(?:\\s*,\\s*\\d+)*)\\](?!\\()"), "")
.replace(Regex("\\[([^\\]]+)\\]\\(([^)\\s]+)\\)")) { m -> "${m.groupValues[1]} ${m.groupValues[2]}" }
}
private fun extractSearchResults(
result: JsonElement?,
@@ -735,8 +744,7 @@ object ToolViewBuilder {
url = firstString(r, listOf("url", "href", "link")),
snippet = cleanVisibleText(firstString(r, listOf("snippet", "description", "body"))),
)
}
.filter { it.title.isNotEmpty() || it.url.isNotEmpty() }
}.filter { it.title.isNotEmpty() || it.url.isNotEmpty() }
.take(limit)
}
@@ -913,12 +921,24 @@ object ToolViewBuilder {
private fun pendingTitle(
toolName: String,
args: JsonObject?,
): String {
return when (toolName) {
"web_extract" -> "Reading ${targetHostname(toolName, args, null)}"
"browser_navigate" -> "Opening ${targetHostname(toolName, args, null)}"
"web_search" -> "Searching \"${compactPreview(searchQueryFor(args), 48)}\""
"read_file" -> "Reading ${readFileDisplayTarget(args, null)}"
): String =
when (toolName) {
"web_extract" -> {
"Reading ${targetHostname(toolName, args, null)}"
}
"browser_navigate" -> {
"Opening ${targetHostname(toolName, args, null)}"
}
"web_search" -> {
"Searching \"${compactPreview(searchQueryFor(args), 48)}\""
}
"read_file" -> {
"Reading ${readFileDisplayTarget(args, null)}"
}
"terminal", "execute_code" -> {
val command = shellCommand(args)
val verb = if (toolName == "execute_code") "Running code" else "Running"
@@ -931,21 +951,38 @@ object ToolViewBuilder {
titleForTool(toolName)
}
}
"memory" -> "Saving"
else -> titleForTool(toolName)
"memory" -> {
"Saving"
}
else -> {
titleForTool(toolName)
}
}
}
private fun doneTitle(
toolName: String,
args: JsonObject?,
result: JsonObject?,
): String {
return when (toolName) {
"web_extract" -> "Read ${targetHostname(toolName, args, result)}"
"browser_navigate" -> "Opened ${targetHostname(toolName, args, result)}"
"web_search" -> "Searched \"${compactPreview(searchQueryFor(args), 48)}\""
"read_file" -> "Read ${readFileDisplayTarget(args, result)}"
): String =
when (toolName) {
"web_extract" -> {
"Read ${targetHostname(toolName, args, result)}"
}
"browser_navigate" -> {
"Opened ${targetHostname(toolName, args, result)}"
}
"web_search" -> {
"Searched \"${compactPreview(searchQueryFor(args), 48)}\""
}
"read_file" -> {
"Read ${readFileDisplayTarget(args, result)}"
}
"terminal", "execute_code" -> {
val command = shellCommand(args)
val verb = if (toolName == "execute_code") "Ran code" else "Ran"
@@ -958,6 +995,7 @@ object ToolViewBuilder {
titleForTool(toolName)
}
}
"memory" -> {
val action = firstString(args, listOf("action")).lowercase()
val target = firstString(args, listOf("target"))
@@ -973,27 +1011,35 @@ object ToolViewBuilder {
// pre-engine mobile summary did.
if (target.isNotEmpty()) "$base ($target)" else base
}
"cronjob" -> "Cron ${firstString(args, listOf("action")).ifEmpty { "manage" }}"
"cronjob" -> {
"Cron ${firstString(args, listOf("action")).ifEmpty { "manage" }}"
}
"edit_file", "patch", "write_file" -> {
val path = fileEditPath(args, result)
if (path.isNotEmpty()) fileEditBasename(path) else titleForTool(toolName)
}
else -> titleForTool(toolName)
else -> {
titleForTool(toolName)
}
}
}
private fun errorTitle(
toolName: String,
result: JsonObject?,
): String {
return when (toolName) {
): String =
when (toolName) {
"browser_navigate" -> {
val url = findFirstUrl(result).ifEmpty { result?.get("url")?.toString() ?: "" }
if (url.isNotEmpty()) "Failed to open ${hostnameOf(url)}" else titleForTool(toolName)
}
else -> titleForTool(toolName)
else -> {
titleForTool(toolName)
}
}
}
private fun targetHostname(
toolName: String,
@@ -1033,6 +1079,7 @@ object ToolViewBuilder {
if (url.isNotEmpty()) hostnameOf(url) else "Navigated in browser"
}
"browser_snapshot" -> {
val snapshot = firstString(result, listOf("snapshot"))
if (snapshot.isNotEmpty()) {
@@ -1043,6 +1090,7 @@ object ToolViewBuilder {
"Captured a browser accessibility snapshot"
}
}
"browser_click" -> {
val clicked =
firstString(
@@ -1058,21 +1106,24 @@ object ToolViewBuilder {
"Clicked $clicked"
}
}
"browser_fill", "browser_type" -> {
val field = firstString(args, listOf("label", "field", "ref", "target"))
val value = firstString(args, listOf("value", "text"))
listOf(
field.takeIf { it.isNotEmpty() }?.let { "Field: $it" },
value.takeIf { it.isNotEmpty() }?.let { "Value: ${compactPreview(it, 42)}" },
)
.filterNotNull()
).filterNotNull()
.joinToString(" · ")
.ifEmpty { "Filled page input" }
}
"web_search" ->
"web_search" -> {
searchQueryFor(
args,
).takeIf { it.isNotEmpty() }?.let { "Query: $it" } ?: "Queried web sources"
}
"terminal", "execute_code" -> {
val output = shellOutput(result)
val firstMeaningfulLine =
@@ -1089,6 +1140,7 @@ object ToolViewBuilder {
""
}
}
"read_file", "edit_file", "patch", "write_file" -> {
val path =
if (isFileEditTool(toolName)) {
@@ -1103,6 +1155,7 @@ object ToolViewBuilder {
fallbackDetailText(args, rawResult)
}
}
"web_extract" -> {
val url =
firstString(args, listOf("url"))
@@ -1111,11 +1164,27 @@ object ToolViewBuilder {
if (url.isNotEmpty()) hostnameOf(url) else "Fetched webpage"
}
"memory" -> firstString(result, listOf("message", "error"))
"cronjob" -> cronjobSubtitle(args, result)
"todo" -> todoSubtitle(result)
"fact_store" -> factStoreSubtitle(args, result)
"session_search" -> sessionSearchSubtitle(result)
"memory" -> {
firstString(result, listOf("message", "error"))
}
"cronjob" -> {
cronjobSubtitle(args, result)
}
"todo" -> {
todoSubtitle(result)
}
"fact_store" -> {
factStoreSubtitle(args, result)
}
"session_search" -> {
sessionSearchSubtitle(result)
}
"skills_list" -> {
val category = firstString(args, listOf("category"))
val count =
@@ -1128,17 +1197,23 @@ object ToolViewBuilder {
"No skills found"
}
}
"skill_view" -> firstString(args, listOf("name"))
"skill_view" -> {
firstString(args, listOf("name"))
}
"skill_manage" -> {
val action = firstString(args, listOf("action"))
val name = firstString(args, listOf("name"))
if (name.isNotEmpty()) "Skill $action: $name" else "Skill $action"
}
"process" -> {
val action = firstString(args, listOf("action"))
val procId = firstString(args, listOf("session_id"))
if (procId.isNotEmpty()) "$action: $procId" else action
}
"x_search" -> {
val query = firstString(args, listOf("query"))
val degraded =
@@ -1149,12 +1224,17 @@ object ToolViewBuilder {
)?.let { !it.isString && it.content == "true" } == true
if (query.isNotEmpty()) "$query${if (degraded) " (no citations)" else ""}" else "Queried X"
}
"vision_analyze" -> firstString(args, listOf("image_url")).take(60)
"vision_analyze" -> {
firstString(args, listOf("image_url")).take(60)
}
"tool_search" -> {
val query = firstString(args, listOf("query"))
val matches = (result?.get("matches") as? JsonArray)?.size
if (matches != null) "$query ($matches matches)" else query
}
"image_generate" -> {
val imageUrl = firstString(result, listOf("image")).ifEmpty { firstString(args, listOf("image_url")) }
val modality = firstString(result, listOf("modality"))
@@ -1162,6 +1242,7 @@ object ToolViewBuilder {
.filterNotNull()
.joinToString(" ")
}
"project_list" -> {
val projects = (result?.get("projects") as? JsonArray)?.size
val active = firstString(result, listOf("active_id"))
@@ -1171,11 +1252,13 @@ object ToolViewBuilder {
"Projects"
}
}
"project_create", "project_switch" -> {
val actionLabel = if (toolName == "project_create") "Created" else "Switched to"
val name = firstString(result, listOf("name"))
if (name.isNotEmpty()) "$actionLabel $name" else actionLabel
}
"read_terminal" -> {
val error = firstString(result, listOf("error"))
if (error.isNotEmpty()) {
@@ -1193,7 +1276,11 @@ object ToolViewBuilder {
}
}
}
"computer_use" -> computerUseSubtitle(args, result)
"computer_use" -> {
computerUseSubtitle(args, result)
}
else -> {
val summary = ToolResultSummary.formatToolResultSummary(rawResult)
val firstLine = summary.split("\n").firstOrNull()?.takeIf { it.isNotEmpty() } ?: ""
@@ -1304,9 +1391,13 @@ object ToolViewBuilder {
60,
)}"
} ?: ""}"
"scroll" -> "${messages?.size ?: 0} messages (scroll)"
"read" -> "${msgCount ?: messages?.size ?: 0} messages${if (truncated) " (truncated)" else ""}"
"browse" -> "${count ?: 0} recent sessions"
else -> "session_search"
}
}
@@ -1369,6 +1460,7 @@ object ToolViewBuilder {
fallbackDetailText(rawArgs, rawResult)
}
}
"terminal", "execute_code" -> {
val output = shellOutput(result)
if (output.isNotEmpty()) {
@@ -1381,6 +1473,7 @@ object ToolViewBuilder {
""
}
}
"web_extract" -> {
val direct = firstString(result, listOf("content", "text", "markdown", "body", "summary", "message"))
if (direct.isNotEmpty()) {
@@ -1393,20 +1486,24 @@ object ToolViewBuilder {
.mapNotNull { item ->
val row = parseMaybeObject(item) ?: return@mapNotNull null
firstString(row, listOf("content", "text", "markdown", "body"))
}
.filter { it.isNotEmpty() }
}.filter { it.isNotEmpty() }
.joinToString("\n\n---\n\n")
}
fallbackDetailText(rawArgs, rawResult)
}
"read_file" -> {
if (rawResult == null) {
return ""
}
firstString(result, listOf("content", "text", "data", "body"))
}
"memory" -> firstString(result, listOf("message", "error"))
"memory" -> {
firstString(result, listOf("message", "error"))
}
"edit_file", "patch", "write_file" -> {
if (inlineDiffFromResult(rawResult).isNotEmpty()) {
""
@@ -1416,11 +1513,27 @@ object ToolViewBuilder {
}
}
}
"web_search" -> fallbackDetailText(rawArgs, rawResult)
"cronjob" -> cronjobDetail(args, result)
"todo" -> todoDetail(result) ?: ""
"fact_store" -> factStoreDetail(result) ?: ""
"session_search" -> sessionSearchDetail(result) ?: ""
"web_search" -> {
fallbackDetailText(rawArgs, rawResult)
}
"cronjob" -> {
cronjobDetail(args, result)
}
"todo" -> {
todoDetail(result) ?: ""
}
"fact_store" -> {
factStoreDetail(result) ?: ""
}
"session_search" -> {
sessionSearchDetail(result) ?: ""
}
"skills_list" -> {
val skills =
(result?.get("skills") as? JsonArray)
@@ -1435,10 +1548,10 @@ object ToolViewBuilder {
if (desc.isNotEmpty()) lines += " $desc"
if (cat.isNotEmpty()) lines += " [$cat]"
lines.joinToString("\n")
}
?.joinToString("\n\n")
}?.joinToString("\n\n")
?: "No skills found"
}
"skill_view" -> {
val content = firstString(result, listOf("content"))
val linkedFiles = result?.get("linked_files") as? JsonObject
@@ -1456,12 +1569,19 @@ object ToolViewBuilder {
?.joinToString("\n") { (k, v) ->
val paths =
when (v) {
is JsonArray ->
is JsonArray -> {
v.joinToString(
", ",
) { (it as? JsonPrimitive)?.content ?: it.toString() }
is JsonPrimitive -> v.content
else -> v.toString()
}
is JsonPrimitive -> {
v.content
}
else -> {
v.toString()
}
}
" 📎 $k: $paths"
}
@@ -1475,6 +1595,7 @@ object ToolViewBuilder {
if (content.isEmpty() && linkedFiles == null) append("No content available")
}
}
"skill_manage" -> {
val error = firstString(result, listOf("error"))
if (error.isNotEmpty()) {
@@ -1494,8 +1615,15 @@ object ToolViewBuilder {
}
}
}
"process" -> processDetail(args, result)
"x_search" -> xSearchDetail(result)
"process" -> {
processDetail(args, result)
}
"x_search" -> {
xSearchDetail(result)
}
"vision_analyze" -> {
val error = firstString(result, listOf("error"))
val description = firstString(result, listOf("description"))
@@ -1507,6 +1635,7 @@ object ToolViewBuilder {
else -> "No description available"
}
}
"tool_search" -> {
val matches = result?.get("matches") as? JsonArray
matches
@@ -1517,10 +1646,10 @@ object ToolViewBuilder {
val lines = mutableListOf("🔧 $name")
if (desc.isNotEmpty()) lines += " $desc"
lines.joinToString("\n")
}
?.joinToString("\n\n")
}?.joinToString("\n\n")
?: "No matching tools"
}
"image_generate" -> {
val error = firstString(result, listOf("error"))
val imageUrl = firstString(result, listOf("image"))
@@ -1539,6 +1668,7 @@ object ToolViewBuilder {
}
}
}
"project_list" -> {
val projects = result?.get("projects") as? JsonArray
projects
@@ -1555,10 +1685,10 @@ object ToolViewBuilder {
if (path.isNotEmpty()) lines += " 📍 $path"
if (isActive) lines += " ✅ ACTIVE PROJECT"
lines.joinToString("\n")
}
?.joinToString("\n\n")
}?.joinToString("\n\n")
?: ""
}
"project_create", "project_switch" -> {
val error = firstString(result, listOf("error"))
val actionLabel = if (toolName == "project_create") "Created" else "Switched to"
@@ -1577,9 +1707,18 @@ object ToolViewBuilder {
}
}
}
"read_terminal" -> readTerminalDetail(result)
"computer_use" -> computerUseDetail(args, result)
else -> fallbackDetailText(rawArgs, rawResult)
"read_terminal" -> {
readTerminalDetail(result)
}
"computer_use" -> {
computerUseDetail(args, result)
}
else -> {
fallbackDetailText(rawArgs, rawResult)
}
}
}
@@ -1601,8 +1740,7 @@ object ToolViewBuilder {
val name = firstString(row, listOf("name", "id")).ifEmpty { "job" }
val sched = firstString(row, listOf("schedule_display", "schedule"))
if (sched.isNotEmpty()) "- $name · $sched" else "- $name"
}
.joinToString("\n")
}.joinToString("\n")
}
val rows =
@@ -1639,8 +1777,7 @@ object ToolViewBuilder {
else -> "[ ]"
}
"$marker $id. $content"
}
.joinToString("\n")
}.joinToString("\n")
.takeIf { it.isNotEmpty() }
}
@@ -1669,8 +1806,7 @@ object ToolViewBuilder {
} else {
"#$fid $content"
}
}
.joinToString("\n")
}.joinToString("\n")
.takeIf { it.isNotEmpty() }
}
@@ -1700,8 +1836,7 @@ object ToolViewBuilder {
snippet.takeIf { it.isNotEmpty() }?.let { "\n ┃ ${it.take(200).replace("\n", " ")}" } ?: ""
"━━━ #${idx + 1} $header$sourceTag$titleLine$modelLine$matchedLine$countLine$snippetLine"
}
?.joinToString("\n")
}?.joinToString("\n")
?.takeIf { it.isNotEmpty() }
val anchorId = intValue(result?.get("around_message_id"))
@@ -1727,8 +1862,7 @@ object ToolViewBuilder {
val cleanContent = content.take(300).replace("\n", " ")
"[$msgId] $roleEmoji $role$namePart$anchor\n $cleanContent"
}
?.joinToString("\n")
}?.joinToString("\n")
?.takeIf { it.isNotEmpty() }
return formattedResults ?: formattedMessages
@@ -1756,8 +1890,7 @@ object ToolViewBuilder {
if (pStatus.isNotEmpty()) parts += " Status: $pStatus"
if (running != null) parts += " Running: $running"
parts.joinToString("\n")
}
.joinToString("\n\n")
}.joinToString("\n\n")
}
val output = firstString(result, listOf("output"))
@@ -1782,7 +1915,10 @@ object ToolViewBuilder {
val degraded = (result?.get("degraded") as? JsonPrimitive)?.let { !it.isString && it.content == "true" } == true
return when {
error.isNotEmpty() -> "❌ $error"
error.isNotEmpty() -> {
"❌ $error"
}
answer.isNotEmpty() -> {
val lines = mutableListOf(answer)
if (citations != null && citations.isNotEmpty()) {
@@ -1798,7 +1934,10 @@ object ToolViewBuilder {
if (degraded) lines += "\n⚠️ No citations — answer based on model's knowledge"
lines.joinToString("\n")
}
else -> "No results"
else -> {
"No results"
}
}
}
@@ -1845,8 +1984,7 @@ object ToolViewBuilder {
.mapNotNull { el ->
val e = parseMaybeObject(el) ?: return@mapNotNull null
if (firstString(e, listOf("type")) == "text") firstString(e, listOf("text")) else null
}
.joinToString("\n")
}.joinToString("\n")
} else {
""
}
@@ -1866,12 +2004,13 @@ object ToolViewBuilder {
val body =
buildString {
val textPart =
multimodalText.ifEmpty {
firstString(
result,
listOf("text_summary"),
)
}.ifEmpty { textSummary }
multimodalText
.ifEmpty {
firstString(
result,
listOf("text_summary"),
)
}.ifEmpty { textSummary }
if (textPart.isNotEmpty()) {
append(textPart)
if (textPart == multimodalText && textSummary.isNotEmpty() && multimodalText != textSummary) {
@@ -107,21 +107,25 @@ fun ActionProgressDialog(
Text(
text =
when (state.phase) {
ActionProgressPhase.STARTING ->
ActionProgressPhase.STARTING -> {
stringResource(R.string.action_progress_starting)
}
ActionProgressPhase.RUNNING ->
ActionProgressPhase.RUNNING -> {
stringResource(R.string.action_progress_running)
}
ActionProgressPhase.SUCCEEDED ->
ActionProgressPhase.SUCCEEDED -> {
stringResource(R.string.action_progress_succeeded)
}
ActionProgressPhase.FAILED ->
ActionProgressPhase.FAILED -> {
if (exitCode != null) {
stringResource(R.string.action_progress_failed_exit, exitCode)
} else {
stringResource(R.string.action_progress_failed)
}
}
},
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
@@ -87,7 +87,8 @@ fun BotAvatar(
if (imageUrl != null) {
AsyncImage(
model =
ImageRequest.Builder(LocalContext.current)
ImageRequest
.Builder(LocalContext.current)
.data(imageUrl)
.crossfade(true)
.build(),
@@ -137,21 +138,20 @@ fun BotAvatar(
fun resolveAvatarShape(
shapeKey: String?,
size: Dp,
): Shape {
return when (shapeKey?.lowercase()?.trim()) {
): Shape =
when (shapeKey?.lowercase()?.trim()) {
"square", "box", "boxy" -> RoundedCornerShape(size * 0.15f)
"rounded", "nub", "organic" -> RoundedCornerShape(size * 0.32f)
"hexagon", "cut", "diamond", "cloud", "sun" -> CutCornerShape(size * 0.25f)
"circle", "round" -> CircleShape
else -> CircleShape
}
}
/**
* Maps known icon keys to Material [ImageVector] icons.
*/
fun resolveAvatarIcon(iconKey: String?): ImageVector? {
return when (iconKey?.lowercase()?.trim()) {
fun resolveAvatarIcon(iconKey: String?): ImageVector? =
when (iconKey?.lowercase()?.trim()) {
"code", "dev", "terminal" -> Icons.Filled.Code
"build", "tool", "tools" -> Icons.Filled.Build
"psychology", "brain", "ai", "research", "intel" -> Icons.Filled.Psychology
@@ -162,7 +162,6 @@ fun resolveAvatarIcon(iconKey: String?): ImageVector? {
"robot", "bot", "smart_toy" -> Icons.Filled.SmartToy
else -> null
}
}
/**
* Extract 1-2 uppercase letters from name for initials fallback.
@@ -16,41 +16,42 @@ import com.m57.hermescontrol.theme.IconDefaultFill
* original path data (viewBox 0 -960 960 960, translated into compose space).
*/
val NeurologyIcon: ImageVector by lazy {
ImageVector.Builder(
name = "Neurology",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 960f,
viewportHeight = 960f,
).apply {
addGroup(
name = "translate",
rotate = 0f,
pivotX = 0f,
pivotY = 0f,
scaleX = 1f,
scaleY = 1f,
translationX = 0f,
translationY = 960f,
clipPathData = emptyList(),
)
addPath(
pathData = addPathNodes(PATH),
pathFillType = PathFillType.NonZero,
name = "NeurologyPath",
fill = SolidColor(IconDefaultFill),
fillAlpha = 1f,
stroke = null,
strokeAlpha = 1f,
strokeLineWidth = 1f,
strokeLineCap = StrokeCap.Butt,
strokeLineJoin = StrokeJoin.Miter,
strokeLineMiter = 4f,
trimPathStart = 0f,
trimPathEnd = 1f,
trimPathOffset = 0f,
)
}.build()
ImageVector
.Builder(
name = "Neurology",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 960f,
viewportHeight = 960f,
).apply {
addGroup(
name = "translate",
rotate = 0f,
pivotX = 0f,
pivotY = 0f,
scaleX = 1f,
scaleY = 1f,
translationX = 0f,
translationY = 960f,
clipPathData = emptyList(),
)
addPath(
pathData = addPathNodes(PATH),
pathFillType = PathFillType.NonZero,
name = "NeurologyPath",
fill = SolidColor(IconDefaultFill),
fillAlpha = 1f,
stroke = null,
strokeAlpha = 1f,
strokeLineWidth = 1f,
strokeLineCap = StrokeCap.Butt,
strokeLineJoin = StrokeJoin.Miter,
strokeLineMiter = 4f,
trimPathStart = 0f,
trimPathEnd = 1f,
trimPathOffset = 0f,
)
}.build()
}
private const val PATH =
@@ -82,22 +82,27 @@ fun PressureBanner(
val message =
when (trigger) {
PressureTrigger.DISK_CRITICAL ->
PressureTrigger.DISK_CRITICAL -> {
stringResource(R.string.pressure_banner_disk_critical) +
diskFreeSuffix(disk)
}
PressureTrigger.DISK_ELEVATED ->
PressureTrigger.DISK_ELEVATED -> {
stringResource(R.string.pressure_banner_disk_elevated) +
diskFreeSuffix(disk)
}
PressureTrigger.MEMORY_CRITICAL ->
PressureTrigger.MEMORY_CRITICAL -> {
stringResource(R.string.pressure_banner_memory_critical)
}
PressureTrigger.OOM_RESTART ->
PressureTrigger.OOM_RESTART -> {
stringResource(R.string.pressure_banner_memory_oom)
}
PressureTrigger.MEMORY_ELEVATED ->
PressureTrigger.MEMORY_ELEVATED -> {
stringResource(R.string.pressure_banner_memory_elevated)
}
}
Surface(
@@ -18,7 +18,11 @@ data class ConfigRow(
) {
/** Human label: last path segment, underscores → spaces. */
val label: String =
key.split(".").last().replace("_", " ").replaceFirstChar { it.uppercase() }
key
.split(".")
.last()
.replace("_", " ")
.replaceFirstChar { it.uppercase() }
/** Search/display text of the current value. */
val valueText: String = value?.let(::jsonText) ?: ""
@@ -578,7 +578,10 @@ private fun ConfigFieldCard(
val field = row.field
val description =
field?.description
?: row.key.replace(".", " → ").replace("_", " ").replaceFirstChar { it.uppercase() }
?: row.key
.replace(".", " → ")
.replace("_", " ")
.replaceFirstChar { it.uppercase() }
Card(
modifier =
@@ -552,19 +552,32 @@ class CronJobsViewModel :
): CronJobEditorState =
when (name) {
"name" -> copy(name = value)
"schedule" -> copy(schedule = value)
"prompt" -> copy(prompt = value)
"deliver" -> copy(deliver = value)
"skills" -> copy(skills = value)
"model" -> copy(model = value)
"provider" -> copy(provider = value)
"base_url" -> copy(base_url = value)
"script" -> copy(script = value)
"workdir" -> copy(workdir = value)
"run_continuity" -> copy(runContinuity = value.toBooleanStrictOrNull() ?: false)
// Monitor fields are mutually exclusive: entering one clears the other.
"monitor_script" -> copy(monitor_script = value, monitor_url = if (value.isNotBlank()) "" else monitor_url)
"monitor_url" -> copy(monitor_url = value, monitor_script = if (value.isNotBlank()) "" else monitor_script)
else -> this
}
@@ -618,8 +631,8 @@ class CronJobsViewModel :
}
/** Monitor fields configured in the editor (at most one is non-blank). */
private fun editorMonitorUpdates(editor: CronJobEditorState): Map<String, String> {
return buildMap {
private fun editorMonitorUpdates(editor: CronJobEditorState): Map<String, String> =
buildMap {
if (editor.monitor_script.isNotBlank()) {
put("monitor_script", editor.monitor_script.trim())
}
@@ -627,5 +640,4 @@ class CronJobsViewModel :
put("monitor_url", editor.monitor_url.trim())
}
}
}
}
@@ -55,17 +55,28 @@ enum class KanbanTaskAction(
/** Desktop parity: which actions are valid from a given status. */
fun kanbanActionsForStatus(status: String): List<KanbanTaskAction> =
when (status) {
"triage" -> listOf(KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
"todo" -> listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
"scheduled" -> listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
"ready" ->
"triage" -> {
listOf(KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
}
"todo" -> {
listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
}
"scheduled" -> {
listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
}
"ready" -> {
listOf(
KanbanTaskAction.TRIAGE,
KanbanTaskAction.BLOCK,
KanbanTaskAction.COMPLETE,
KanbanTaskAction.ARCHIVE,
)
"running" ->
}
"running" -> {
listOf(
KanbanTaskAction.TRIAGE,
KanbanTaskAction.READY,
@@ -73,22 +84,38 @@ fun kanbanActionsForStatus(status: String): List<KanbanTaskAction> =
KanbanTaskAction.COMPLETE,
KanbanTaskAction.ARCHIVE,
)
"blocked" ->
}
"blocked" -> {
listOf(
KanbanTaskAction.TRIAGE,
KanbanTaskAction.UNBLOCK,
KanbanTaskAction.COMPLETE,
KanbanTaskAction.ARCHIVE,
)
"review" -> listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
"done" -> listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
"archived" -> listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY)
else -> emptyList()
}
"review" -> {
listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
}
"done" -> {
listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
}
"archived" -> {
listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY)
}
else -> {
emptyList()
}
}
class KanbanViewModel(
private val eventsClientProvider: () -> KanbanEventsClient = { KanbanEventsClient() },
) : ViewModel(), ToastHost {
) : ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(KanbanUiState())
val uiState: StateFlow<KanbanUiState> = _uiState.asStateFlow()
@@ -456,6 +456,7 @@ class McpServersViewModel :
}
}
}
is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to start OAuth: ${result.error.message}") }
}
@@ -489,6 +490,7 @@ class McpServersViewModel :
}
loadServers()
}
"error" -> {
polling = false
_uiState.update {
@@ -498,15 +500,18 @@ class McpServersViewModel :
)
}
}
"authorization_required" -> {
// Keep polling
}
else -> {
// Check if worker is done or expired
// Continue polling until status transitions
}
}
}
is NetworkResult.Failure -> {
// Keep polling or stop after too many failures? Let's just log/toast n retry a few times
}
@@ -31,7 +31,9 @@ data class MemoryUiState(
* Retrofit suspend calls already run off the caller thread, keeping tests
* free of the static Dispatchers mock that bleeds across test classes.
*/
class MemoryViewModel : ViewModel(), ToastHost {
class MemoryViewModel :
ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(MemoryUiState())
val uiState: StateFlow<MemoryUiState> = _uiState.asStateFlow()
@@ -41,15 +43,18 @@ class MemoryViewModel : ViewModel(), ToastHost {
viewModelScope.launch {
val result = safeApiCall { ApiClient.hermesApi.getMemory() }
when (result) {
is NetworkResult.Success ->
is NetworkResult.Success -> {
_uiState.update { it.copy(isLoading = false, memory = result.data) }
is NetworkResult.Failure ->
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = result.error.message,
)
}
}
}
loadLearningGraph()
}
@@ -70,20 +75,23 @@ class MemoryViewModel : ViewModel(), ToastHost {
viewModelScope.launch {
val result = safeApiCall { ApiClient.hermesApi.resetMemory(MemoryResetRequest(target = target)) }
when (result) {
is NetworkResult.Success ->
is NetworkResult.Success -> {
_uiState.update {
it.copy(
resetting = null,
toastMessage = "Memory ($target) reset successfully",
)
}
is NetworkResult.Failure ->
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
resetting = null,
toastMessage = "Failed to reset memory: ${result.error.message}",
)
}
}
}
load()
}
@@ -328,10 +328,9 @@ private fun ApprovedCard(
}
@Composable
private fun formatAge(ageMinutes: Int): String {
return if (ageMinutes < 60) {
private fun formatAge(ageMinutes: Int): String =
if (ageMinutes < 60) {
stringResource(R.string.pairing_label_request_age_m, ageMinutes)
} else {
stringResource(R.string.pairing_label_request_age_hm, ageMinutes / 60, ageMinutes % 60)
}
}
@@ -334,15 +334,16 @@ private fun ProviderFieldRow(
Spacer(modifier = Modifier.height(8.dp))
when (field.kind) {
"select" ->
"select" -> {
ExposedDropdownField(
label = field.label.ifEmpty { field.key },
options = field.options.map { it.value }.ifEmpty { listOf(value) },
selectedValue = value,
onOptionSelected = onChange,
)
}
"bool" ->
"bool" -> {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
@@ -365,8 +366,9 @@ private fun ProviderFieldRow(
style = MaterialTheme.typography.bodyMedium,
)
}
}
else ->
else -> {
OutlinedTextField(
value = value,
onValueChange = onChange,
@@ -400,6 +402,7 @@ private fun ProviderFieldRow(
KeyboardOptions.Default
},
)
}
}
}
}
@@ -47,7 +47,9 @@ data class MemoryProviderDetailUiState(
* [setProvider] + [load] per entry instead (same bug/fix as #782's
* ToolsetDetailViewModel).
*/
class MemoryProviderDetailViewModel : ViewModel(), ToastHost {
class MemoryProviderDetailViewModel :
ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(MemoryProviderDetailUiState())
val uiState: StateFlow<MemoryProviderDetailUiState> = _uiState.asStateFlow()
@@ -74,13 +76,17 @@ class MemoryProviderDetailViewModel : ViewModel(), ToastHost {
val statusResult = safeApiCall { ApiClient.hermesApi.getMemory() }
val status =
when (statusResult) {
is NetworkResult.Success ->
is NetworkResult.Success -> {
statusResult.data.providers.firstOrNull { it.name == name }
else -> null
}
else -> {
null
}
}
val configResult = safeApiCall { ApiClient.hermesApi.getMemoryProviderConfig(name) }
when (configResult) {
is NetworkResult.Success ->
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isLoading = false,
@@ -90,7 +96,9 @@ class MemoryProviderDetailViewModel : ViewModel(), ToastHost {
edits = seedEdits(configResult.data),
)
}
is NetworkResult.Failure ->
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
@@ -99,6 +107,7 @@ class MemoryProviderDetailViewModel : ViewModel(), ToastHost {
errorMessage = configResult.error.message,
)
}
}
}
}
}
@@ -143,7 +152,7 @@ class MemoryProviderDetailViewModel : ViewModel(), ToastHost {
)
}
when (result) {
is NetworkResult.Success ->
is NetworkResult.Success -> {
_uiState.update {
it.copy(
saving = false,
@@ -154,13 +163,16 @@ class MemoryProviderDetailViewModel : ViewModel(), ToastHost {
toastMessage = "Memory provider settings saved",
)
}
is NetworkResult.Failure ->
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
saving = false,
toastMessage = "Failed to save settings: ${result.error.message}",
)
}
}
}
refreshStatus(name)
}
@@ -180,7 +192,7 @@ class MemoryProviderDetailViewModel : ViewModel(), ToastHost {
ApiClient.hermesApi.setupMemoryProvider(name, MemoryProviderSetupRequest())
}
when (result) {
is NetworkResult.Success ->
is NetworkResult.Success -> {
_uiState.update {
it.copy(
runningSetup = false,
@@ -193,13 +205,16 @@ class MemoryProviderDetailViewModel : ViewModel(), ToastHost {
},
)
}
is NetworkResult.Failure ->
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
runningSetup = false,
toastMessage = "Setup failed: ${result.error.message}",
)
}
}
}
refreshStatus(name)
}
@@ -207,7 +207,8 @@ internal fun ChatSection(
) {
val fontScaleOptions = listOf(0.85f, 1.0f, 1.15f, 1.30f, 1.50f)
val currentIndex =
fontScaleOptions.indexOfFirst { abs(it - chatFontScale) < 0.05f }
fontScaleOptions
.indexOfFirst { abs(it - chatFontScale) < 0.05f }
.takeIf { it >= 0 } ?: 1
SectionCard {
@@ -884,7 +884,9 @@ private fun SkillScanSection(
}
when {
isScanning -> Unit
isScanning -> {
Unit
}
scanError != null -> {
Surface(
@@ -86,8 +86,9 @@ data class SystemUiState(
val updateConfirmOpen: Boolean = false,
)
class SystemViewModel(application: Application) :
AndroidViewModel(application),
class SystemViewModel(
application: Application,
) : AndroidViewModel(application),
ToastHost {
companion object {
private const val TAG = "SystemViewModel"
@@ -680,11 +680,23 @@ private fun PostSetupRunner(
private fun ToolsetProvider.statusTextRes(): Int =
when (status) {
"ready" -> R.string.toolset_status_ready
"needs_keys" -> R.string.toolset_status_needs_keys
"needs_auth" -> R.string.toolset_status_needs_auth
"needs_setup" -> R.string.toolset_status_needs_setup
else ->
"ready" -> {
R.string.toolset_status_ready
}
"needs_keys" -> {
R.string.toolset_status_needs_keys
}
"needs_auth" -> {
R.string.toolset_status_needs_auth
}
"needs_setup" -> {
R.string.toolset_status_needs_setup
}
else -> {
// Older backends may omit `status` — fall back to the legacy
// env-var heuristic the desktop used before server-computed
// readiness landed.
@@ -693,16 +705,24 @@ private fun ToolsetProvider.statusTextRes(): Int =
} else {
R.string.toolset_status_needs_keys
}
}
}
private fun ToolsetProvider.statusType(): StatusBadgeType =
when (status) {
"ready" -> StatusBadgeType.SUCCESS
"needs_keys", "needs_auth", "needs_setup" -> StatusBadgeType.WARNING
else ->
"ready" -> {
StatusBadgeType.SUCCESS
}
"needs_keys", "needs_auth", "needs_setup" -> {
StatusBadgeType.WARNING
}
else -> {
if (envVars.isEmpty() || envVars.all { it.isSet }) {
StatusBadgeType.SUCCESS
} else {
StatusBadgeType.WARNING
}
}
}
@@ -59,7 +59,9 @@ data class ToolsetDetailUiState(
* first-opened toolset into every entry — bug found on-device 2026-08-06).
* The screen drives [setToolset] + [loadConfig] per entry instead.
*/
class ToolsetDetailViewModel : ViewModel(), ToastHost {
class ToolsetDetailViewModel :
ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(ToolsetDetailUiState())
val uiState: StateFlow<ToolsetDetailUiState> = _uiState.asStateFlow()
@@ -101,11 +103,12 @@ class ToolsetDetailViewModel : ViewModel(), ToastHost {
)
}
is NetworkResult.Failure ->
is NetworkResult.Failure -> {
state.copy(
isLoading = false,
errorMessage = "Failed to load toolset config: ${result.error.message}",
)
}
}
}
}
@@ -150,11 +153,12 @@ class ToolsetDetailViewModel : ViewModel(), ToastHost {
)
}
is NetworkResult.Failure ->
is NetworkResult.Failure -> {
state.copy(
selectingProvider = null,
toastMessage = "Failed to select backend: ${result.error.message}",
)
}
}
}
}
@@ -176,18 +180,20 @@ class ToolsetDetailViewModel : ViewModel(), ToastHost {
}
_uiState.update { state ->
when (result) {
is NetworkResult.Success ->
is NetworkResult.Success -> {
state.copy(
savingEnvKey = null,
config = state.config?.let { patchEnvIsSet(it, key, true) },
toastMessage = "Saved $key",
)
}
is NetworkResult.Failure ->
is NetworkResult.Failure -> {
state.copy(
savingEnvKey = null,
toastMessage = "Failed to save $key: ${result.error.message}",
)
}
}
}
}
@@ -199,19 +205,21 @@ class ToolsetDetailViewModel : ViewModel(), ToastHost {
val result = safeApiCall { ApiClient.hermesApi.deleteEnvVar(EnvVarDeleteRequest(key)) }
_uiState.update { state ->
when (result) {
is NetworkResult.Success ->
is NetworkResult.Success -> {
state.copy(
deletingEnvKey = null,
config = state.config?.let { patchEnvIsSet(it, key, false) },
revealedValues = state.revealedValues - key,
toastMessage = "Cleared $key",
)
}
is NetworkResult.Failure ->
is NetworkResult.Failure -> {
state.copy(
deletingEnvKey = null,
toastMessage = "Failed to clear $key: ${result.error.message}",
)
}
}
}
}
@@ -222,11 +230,13 @@ class ToolsetDetailViewModel : ViewModel(), ToastHost {
val result = safeApiCall { ApiClient.hermesApi.revealEnvVar(EnvVarRevealRequest(key)) }
_uiState.update { state ->
when (result) {
is NetworkResult.Success ->
is NetworkResult.Success -> {
state.copy(revealedValues = state.revealedValues + (key to result.data.value))
}
is NetworkResult.Failure ->
is NetworkResult.Failure -> {
state.copy(toastMessage = "Failed to reveal $key: ${result.error.message}")
}
}
}
}