diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 6eac2a5e..381300c0 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -52,13 +52,13 @@ jobs: uses: actions/cache@v6 with: path: ~/.local/bin/ktlint - key: ktlint-1.2.1 + key: ktlint-1.8.0 - name: Install ktlint if: steps.cache-ktlint.outputs.cache-hit != 'true' run: | mkdir -p ~/.local/bin - curl -sSLO https://github.com/pinterest/ktlint/releases/download/1.2.1/ktlint + curl -sSLO https://github.com/pinterest/ktlint/releases/download/1.8.0/ktlint chmod +x ktlint mv ktlint ~/.local/bin/ diff --git a/AGENTS.md b/AGENTS.md index ec463978..254d6894 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ If you have a local Android SDK (`ANDROID_HOME` set), these work: ```bash # Download the matching binary -curl -sSLO https://github.com/pinterest/ktlint/releases/download/1.2.1/ktlint +curl -sSLO https://github.com/pinterest/ktlint/releases/download/1.8.0/ktlint chmod +x ktlint ./ktlint # check one file ./ktlint --format # auto-fix @@ -48,7 +48,7 @@ chmod +x ktlint | Job | Purpose | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| `ktlint` | ktlint 1.2.1 style check + `checkColorLiterals` (hardcoded Color guard, issue #622) | +| `ktlint` | ktlint 1.8.0 style check + `checkColorLiterals` (hardcoded Color guard, issue #622) | | `android-lint` | Android Lint | | `unit-tests` | JUnit | | `build` | assembleDebug (gated by the 3 above) | @@ -68,7 +68,7 @@ Requires `permissions: contents: write` on the `build-release` job. ## Code Style -- **ktlint 1.2.1** is enforced in CI. Run `./ktlint --format` before pushing. +- **ktlint 1.8.0** is enforced in CI. Run `./ktlint --format` before pushing. - Import ordering is the #1 CI failure: ktlint enforces ASCII-lexicographic order (uppercase before lowercase: `LaunchedEffect` before `collectAsState`). - `const val` must use SCREAMING_SNAKE_CASE. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 28a84dee..b780ce4f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,7 +73,7 @@ We enforce Kotlin coding conventions and Jetpack Compose best practices. ### Kotlin Formatting -- Code formatting is checked and enforced by **ktlint 1.2.1** in CI. The authoritative command is the Gradle task (not the standalone binary): +- Code formatting is checked and enforced by **ktlint 1.8.0** in CI. The authoritative command is the Gradle task (not the standalone binary): ```bash ./gradlew ktlintCheck # check (CI-equivalent) ./gradlew ktlintFormat # auto-fix, then re-check with ktlintCheck diff --git a/README.md b/README.md index 64fa7aaf..25d51a48 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ app/src/main/java/com/m57/hermescontrol/ - **Theming:** 6 built-in presets (Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Neon Noir) + Material You dynamic colors - **Image Loading:** Coil 2.7.0 - **Testing:** JUnit 5, MockK, Turbine, Espresso, Compose UI testing -- **Formatting:** `ktlint` 1.2.1 style rules (checked automatically in CI) +- **Formatting:** `ktlint` 1.8.0 style rules (checked automatically in CI) --- diff --git a/app/src/main/java/com/m57/hermescontrol/assistant/AssistantSession.kt b/app/src/main/java/com/m57/hermescontrol/assistant/AssistantSession.kt index 50beab25..c5fd05db 100644 --- a/app/src/main/java/com/m57/hermescontrol/assistant/AssistantSession.kt +++ b/app/src/main/java/com/m57/hermescontrol/assistant/AssistantSession.kt @@ -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( diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/Profile.kt b/app/src/main/java/com/m57/hermescontrol/data/model/Profile.kt index f06756a7..fd5afd9f 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/Profile.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/Profile.kt @@ -239,13 +239,14 @@ data class GroupChatRoomMeta( ) { val memberNames: List 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 diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/GatewayFileClient.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/GatewayFileClient.kt index e00234a0..be915019 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/GatewayFileClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/GatewayFileClient.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/LocalNetwork.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/LocalNetwork.kt index 200e8617..3f2926e4 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/LocalNetwork.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/LocalNetwork.kt @@ -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 } } diff --git a/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateState.kt b/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateState.kt index a58e1feb..49b3bd4c 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateState.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateState.kt @@ -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. */ diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt index 965da7f8..25af9ec2 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/JsonRpcModels.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/JsonRpcModels.kt index 1c15f4bb..b8e91c84 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/JsonRpcModels.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/JsonRpcModels.kt @@ -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? = diff --git a/app/src/main/java/com/m57/hermescontrol/theme/THEMES.md b/app/src/main/java/com/m57/hermescontrol/theme/THEMES.md index 5180da53..a19be635 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/THEMES.md +++ b/app/src/main/java/com/m57/hermescontrol/theme/THEMES.md @@ -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`. diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt index a0ad8fdc..d261d1d2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt @@ -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() @@ -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 = matchedProfiles + var groupMembers: List = 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 { it.lastActivity } + .thenBy { it.name }, ) - }.sortedWith( - compareByDescending { it.lastActivity } - .thenBy { it.name }, - ) } val displayGroups: List @@ -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 { 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) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/CreateGroupChatDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/CreateGroupChatDialog.kt index e13470b4..fc8a2206 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/CreateGroupChatDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/CreateGroupChatDialog.kt @@ -100,8 +100,7 @@ fun CreateGroupChatDialog( } else { selectedBots + bot.name } - } - .padding(vertical = 4.dp), + }.padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { Checkbox( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt index 67df208c..4b0157b7 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt @@ -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}") } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatListEntries.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatListEntries.kt index 5e82ead7..28c91831 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatListEntries.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatListEntries.kt @@ -38,14 +38,20 @@ fun toolCallMilestones(messages: List): Map { 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 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMediaDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMediaDelegate.kt index 1d0de41e..6e2bab35 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMediaDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMediaDelegate.kt @@ -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 { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 3dcc531e..08f31bbe 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -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) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index a461af90..74bde447 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index 7bf64241..7fe01143 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -9,9 +9,7 @@ import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive -private fun List.upsertById(message: ChatMessage): List { - return (this + message).dedupeById() -} +private fun List.upsertById(message: ChatMessage): List = (this + message).dedupeById() /** * Strips this turn's sealed-orphan prefix from the final message text. @@ -943,15 +941,16 @@ fun extractTodosFromJson(content: String): List? { ?: ((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 } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt index 34c68070..d11f5c2c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt @@ -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. */ diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt index 51d6af68..0117879e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt @@ -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() } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt index f20fbe30..891b93ee 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt @@ -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( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/DiffViewCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/DiffViewCard.kt index d791fc37..af3f116e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/DiffViewCard.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/DiffViewCard.kt @@ -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/") diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt index 8783e25c..473338b0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheet.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheet.kt index d336cf6f..a81658cc 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheet.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheet.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt index bbfddc70..b726f39c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt @@ -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) : ChatTurn + data class Agent( + val entries: List, + ) : 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): List { // 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++ + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolResultSummary.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolResultSummary.kt index ab9f6f34..24f5ee9f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolResultSummary.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolResultSummary.kt @@ -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, ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt index 2d979171..5abee578 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt @@ -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) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/ActionProgressDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/ActionProgressDialog.kt index b440f0c3..f0324ebd 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/ActionProgressDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/ActionProgressDialog.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/BotAvatar.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/BotAvatar.kt index 02470e82..f4f3c920 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/BotAvatar.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/BotAvatar.kt @@ -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. diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/NeurologyIcon.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/NeurologyIcon.kt index f76aa9be..828e2f50 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/NeurologyIcon.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/NeurologyIcon.kt @@ -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 = diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/PressureBanner.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/PressureBanner.kt index aaef28d4..ce19055d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/PressureBanner.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/PressureBanner.kt @@ -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( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigFormData.kt b/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigFormData.kt index c33a69dd..98ff68b9 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigFormData.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigFormData.kt @@ -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) ?: "" diff --git a/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt index 09e97f9d..014b844e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt @@ -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 = diff --git a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt index fe9414b6..7049162c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt @@ -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 { - return buildMap { + private fun editorMonitorUpdates(editor: CronJobEditorState): Map = + 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()) } } - } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt index 051cca9b..4bb7293d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt @@ -55,17 +55,28 @@ enum class KanbanTaskAction( /** Desktop parity: which actions are valid from a given status. */ fun kanbanActionsForStatus(status: String): List = 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.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 = _uiState.asStateFlow() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt index 81bdd570..de370b11 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt @@ -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 } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryViewModel.kt index 926500f9..316f9ae6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryViewModel.kt @@ -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 = _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() } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingScreen.kt index f3560479..520183b8 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingScreen.kt @@ -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) } -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/MemoryProviderDetailScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/MemoryProviderDetailScreen.kt index 29d87924..bf8acbca 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/plugins/MemoryProviderDetailScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/MemoryProviderDetailScreen.kt @@ -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 }, ) + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/MemoryProviderDetailViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/MemoryProviderDetailViewModel.kt index f4151412..5dbffcea 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/plugins/MemoryProviderDetailViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/MemoryProviderDetailViewModel.kt @@ -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 = _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) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt index fab1dd3b..5318e084 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt @@ -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 { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt index 4718dc5a..070c4110 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt @@ -884,7 +884,9 @@ private fun SkillScanSection( } when { - isScanning -> Unit + isScanning -> { + Unit + } scanError != null -> { Surface( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt index 5cf498ba..53c373c6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt @@ -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" diff --git a/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetDetailScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetDetailScreen.kt index 814b8a54..ca5c8d94 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetDetailScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetDetailScreen.kt @@ -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 } + } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetDetailViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetDetailViewModel.kt index 81f691ee..a65012b5 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetDetailViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetDetailViewModel.kt @@ -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 = _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}") + } } } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/KanbanEventsClientTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/KanbanEventsClientTest.kt index 22aa2276..6e21802a 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/KanbanEventsClientTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/KanbanEventsClientTest.kt @@ -20,7 +20,13 @@ class KanbanEventsClientTest { assertEquals("t-1", event.taskId) assertEquals("r-9", event.runId) assertEquals("status", event.kind) - assertEquals("done", event.payload?.get("status")?.jsonPrimitive?.content) + assertEquals( + "done", + event.payload + ?.get("status") + ?.jsonPrimitive + ?.content, + ) assertEquals(1710000000L, event.createdAt) } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/bots/BotsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/bots/BotsViewModelTest.kt index e074c4a2..2b419069 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/bots/BotsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/bots/BotsViewModelTest.kt @@ -143,12 +143,16 @@ class BotsViewModelTest { // Toggle show hidden (sorted: default (active), scout (worker active now), reviewer (canonical last_active)) viewModel.toggleShowHidden() - val displayedWithHidden = viewModel.uiState.value.displayProfiles.map { it.name } + val displayedWithHidden = + viewModel.uiState.value.displayProfiles + .map { it.name } assertEquals(listOf("default", "reviewer", "scout"), displayedWithHidden) // Search filter viewModel.setSearchQuery("arxiv") - val searchResults = viewModel.uiState.value.displayProfiles.map { it.name } + val searchResults = + viewModel.uiState.value.displayProfiles + .map { it.name } assertEquals(listOf("scout"), searchResults) } @@ -230,8 +234,16 @@ class BotsViewModelTest { advanceUntilIdle() assertEquals(2, viewModel.uiState.value.allGroups.size) - assertEquals("Dream Team", viewModel.uiState.value.allGroups[0].name) - assertEquals(2, viewModel.uiState.value.allGroups[0].members.size) + assertEquals( + "Dream Team", + viewModel.uiState.value.allGroups[0] + .name, + ) + assertEquals( + 2, + viewModel.uiState.value.allGroups[0] + .members.size, + ) var success = false viewModel.disbandGroupChat("Dream Team") { success = true } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModelTest.kt index a1ca7537..a8bc0116 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModelTest.kt @@ -205,7 +205,9 @@ class GroupChatViewModelTest { testScheduler.runCurrent() // State should contain user message - val userMsg = viewModel.uiState.value.messages.first() + val userMsg = + viewModel.uiState.value.messages + .first() assertTrue(userMsg.isUser) assertEquals("@scout what's the status?", userMsg.text) @@ -213,7 +215,9 @@ class GroupChatViewModelTest { eventsFlow.emit(WsEvent.MessageToken(token = "Building ", sessionId = "session-scout-1")) testScheduler.runCurrent() - val streamingMsg = viewModel.uiState.value.messages.last() + val streamingMsg = + viewModel.uiState.value.messages + .last() assertFalse(streamingMsg.isUser) assertEquals("scout", streamingMsg.senderName) assertEquals("Building ", streamingMsg.text) @@ -222,7 +226,9 @@ class GroupChatViewModelTest { eventsFlow.emit(WsEvent.MessageToken(token = "the APK...", sessionId = "session-scout-1")) testScheduler.runCurrent() - val updatedStreaming = viewModel.uiState.value.messages.last() + val updatedStreaming = + viewModel.uiState.value.messages + .last() assertEquals("Building the APK...", updatedStreaming.text) // Complete turn @@ -285,7 +291,11 @@ class GroupChatViewModelTest { testScheduler.runCurrent() assertEquals(2, viewModel.uiState.value.messages.size) - assertEquals("scout", viewModel.uiState.value.messages[1].senderName) + assertEquals( + "scout", + viewModel.uiState.value.messages[1] + .senderName, + ) // Continuation pass triggers coder eventsFlow.emit( @@ -345,7 +355,11 @@ class GroupChatViewModelTest { // Because lease is held by desktop_client_99, mobile only posts the user message and yields assertEquals(1, viewModel.uiState.value.messages.size) - assertTrue(viewModel.uiState.value.messages.first().isUser) + assertTrue( + viewModel.uiState.value.messages + .first() + .isUser, + ) } @Test @@ -452,7 +466,9 @@ class GroupChatViewModelTest { ) testScheduler.runCurrent() - val msgWithTool = viewModel.uiState.value.messages.last() + val msgWithTool = + viewModel.uiState.value.messages + .last() assertEquals(1, msgWithTool.toolCalls.size) val toolCall = msgWithTool.toolCalls.first() assertEquals("terminal", toolCall.name) @@ -469,7 +485,9 @@ class GroupChatViewModelTest { ) testScheduler.runCurrent() - val msgAfterToolDone = viewModel.uiState.value.messages.last() + val msgAfterToolDone = + viewModel.uiState.value.messages + .last() assertFalse(msgAfterToolDone.toolCalls.first().isRunning) // Complete message @@ -481,7 +499,9 @@ class GroupChatViewModelTest { ) testScheduler.runCurrent() - val finalMsg = viewModel.uiState.value.messages.last() + val finalMsg = + viewModel.uiState.value.messages + .last() assertEquals("Current time is Sat Aug 29", finalMsg.text) assertEquals(1, finalMsg.toolCalls.size) assertFalse(finalMsg.toolCalls.first().isRunning) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatInputPolicyTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatInputPolicyTest.kt index 02b4fdb0..f5aed210 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatInputPolicyTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatInputPolicyTest.kt @@ -158,7 +158,13 @@ class ChatInputPolicyTest { @Test fun applyMention_insertsHandleAndAdvancesCursor() { - val initial = TextFieldValue("ask @sc", selection = androidx.compose.ui.text.TextRange(7)) + val initial = + TextFieldValue( + "ask @sc", + selection = + androidx.compose.ui.text + .TextRange(7), + ) val updated = ChatInputPolicy.applyMention(initial, "scout") assertEquals("ask @scout ", updated.text) assertEquals(11, updated.selection.end) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatToolDedupeTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatToolDedupeTest.kt index e2150aef..eb0c1403 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatToolDedupeTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatToolDedupeTest.kt @@ -369,12 +369,13 @@ class ChatToolDedupeTest { var stream = StreamingState() stream = ChatWsEventReducer.reduce(s, stream, WsEvent.MessageStart("session-1"), "session-1").streamingState stream = - ChatWsEventReducer.reduce( - s, - stream, - WsEvent.ReasoningDelta("thinking about meow", "session-1"), - "session-1", - ).streamingState + ChatWsEventReducer + .reduce( + s, + stream, + WsEvent.ReasoningDelta("thinking about meow", "session-1"), + "session-1", + ).streamingState var r = ChatWsEventReducer.reduce( s, diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt index f5a4ec18..437cbe3a 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt @@ -159,7 +159,12 @@ class ChatUpdateCommandTest { advanceUntilIdle() assertTrue("confirm dialog should open", vm.uiState.value.updateConfirmOpen) - assertEquals("/update", vm.uiState.value.messages.last().content) + assertEquals( + "/update", + vm.uiState.value.messages + .last() + .content, + ) // The whole point of the fix: NOTHING goes over the wire. verify(exactly = 0) { diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index 566fa3c0..6494f7e5 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -513,7 +513,10 @@ class ChatViewModelTest { // A blocklisted command can never dispatch — it must not climb // the autocomplete ranking either. - assertTrue(viewModel.uiState.value.slashUsageCounts.isEmpty()) + assertTrue( + viewModel.uiState.value.slashUsageCounts + .isEmpty(), + ) verify(exactly = 0) { HermesWsClient.request(WsMethods.COMMAND_DISPATCH, any(), any()) } @@ -1033,7 +1036,10 @@ class ChatViewModelTest { viewModel.uiState.value.messages[1] .role, ) - assertFalse(viewModel.uiState.value.messages[1].isStreaming) + assertFalse( + viewModel.uiState.value.messages[1] + .isStreaming, + ) assertEquals( MessageRole.TOOL, viewModel.uiState.value.messages[2] @@ -1041,7 +1047,10 @@ class ChatViewModelTest { ) assertNull(viewModel.streamingState.value.streamingMessage) assertEquals( - listOf(viewModel.uiState.value.messages[1].id), + listOf( + viewModel.uiState.value.messages[1] + .id, + ), viewModel.streamingState.value.sealedOrphanIds, ) @@ -1050,7 +1059,8 @@ class ChatViewModelTest { mockEventsFlow.emit(WsEvent.MessageComplete("Calculating sum = 4", sessionId)) advanceUntilIdle() val assistant = - viewModel.uiState.value.messages.filter { it.role == MessageRole.ASSISTANT } + viewModel.uiState.value.messages + .filter { it.role == MessageRole.ASSISTANT } assertEquals(2, assistant.size) assertEquals("Calculating sum", assistant[0].content) assertEquals(" = 4", assistant[1].content) @@ -1573,7 +1583,8 @@ class ChatViewModelTest { every { AuthManager.getBaseUrl() } returns "http://test.local/" coEvery { mockApi.getSessions(any(), any(), any()) } returns retrofit2.Response.success( - com.m57.hermescontrol.data.model.SessionListResponse(sessions = emptyList(), total = 0), + com.m57.hermescontrol.data.model + .SessionListResponse(sessions = emptyList(), total = 0), ) coEvery { mockApi.getSessionMessages( @@ -1623,7 +1634,8 @@ class ChatViewModelTest { val empty = retrofit2.Response.success( - com.m57.hermescontrol.data.model.SessionMessagesResponse(messages = emptyList()), + com.m57.hermescontrol.data.model + .SessionMessagesResponse(messages = emptyList()), ) messagesA.complete(empty) messagesB.complete(empty) @@ -1641,7 +1653,8 @@ class ChatViewModelTest { every { AuthManager.getBaseUrl() } returns "http://test.local/" coEvery { mockApi.getSessions(any(), any(), any()) } returns retrofit2.Response.success( - com.m57.hermescontrol.data.model.SessionListResponse(sessions = emptyList(), total = 0), + com.m57.hermescontrol.data.model + .SessionListResponse(sessions = emptyList(), total = 0), ) coEvery { mockApi.getSessionMessages( @@ -1698,7 +1711,11 @@ class ChatViewModelTest { advanceUntilIdle() assertEquals("session-b", viewModel.uiState.value.currentSessionId) - assertEquals(listOf("message-b"), viewModel.uiState.value.messages.map { it.content }) + assertEquals( + listOf("message-b"), + viewModel.uiState.value.messages + .map { it.content }, + ) } @Test @@ -1927,7 +1944,8 @@ class ChatViewModelTest { sessionIds.forEach { sessionId -> coEvery { mockApi.getSessionMessages(sessionId, any(), any(), any(), any()) } returns retrofit2.Response.success( - com.m57.hermescontrol.data.model.SessionMessagesResponse(messages = emptyList()), + com.m57.hermescontrol.data.model + .SessionMessagesResponse(messages = emptyList()), ) } } @@ -1956,7 +1974,11 @@ class ChatViewModelTest { // Warm cache painted and survived the exhausted retry cycle — the // screen never went blank, and the user gets history + an error. assertEquals(1, viewModel.uiState.value.messages.size) - assertEquals("Cached hello", viewModel.uiState.value.messages[0].content) + assertEquals( + "Cached hello", + viewModel.uiState.value.messages[0] + .content, + ) assertFalse(viewModel.uiState.value.isLoading) assertNotNull("resumeError must be set after retries exhaust", viewModel.uiState.value.resumeError) assertFalse(viewModel.uiState.value.isResumeRetrying) @@ -2305,7 +2327,8 @@ class ChatViewModelTest { assertEquals("session-new", viewModel.uiState.value.currentSessionId) assertTrue( "recovery notice must be visible", - viewModel.uiState.value.messages.any { it.content.contains("no longer available") }, + viewModel.uiState.value.messages + .any { it.content.contains("no longer available") }, ) } @@ -2328,7 +2351,8 @@ class ChatViewModelTest { ApiClient.hermesApi.getSessionMessages(capture(fetchedSessions), any(), any(), any(), any()) } returns retrofit2.Response.success( - com.m57.hermescontrol.data.model.SessionMessagesResponse(messages = emptyList()), + com.m57.hermescontrol.data.model + .SessionMessagesResponse(messages = emptyList()), ) // /fork sends session.branch keyed on the runtime id. @@ -2379,7 +2403,8 @@ class ChatViewModelTest { ) } else { retrofit2.Response.success( - com.m57.hermescontrol.data.model.SessionMessagesResponse(messages = emptyList()), + com.m57.hermescontrol.data.model + .SessionMessagesResponse(messages = emptyList()), ) } } @@ -3301,7 +3326,12 @@ class ChatViewModelTest { assertTrue(viewModel.uiState.value.hasOlderMessages) assertEquals(150, viewModel.uiState.value.messages.size) // Stable keys come from the server row id, not the from-end position. - assertEquals("rest-session-456-150", viewModel.uiState.value.messages.last().id) + assertEquals( + "rest-session-456-150", + viewModel.uiState.value.messages + .last() + .id, + ) } @Test @@ -3444,7 +3474,10 @@ class ChatViewModelTest { // 160 rows: the 10 new ones appended, the existing 150 kept — // no duplicates, no dropped newest copy (stable row-id keys). assertEquals(160, viewModel.uiState.value.messages.size) - assertTrue(viewModel.uiState.value.messages.any { it.id == "rest-session-456-160" }) + assertTrue( + viewModel.uiState.value.messages + .any { it.id == "rest-session-456-160" }, + ) } // ── Attachment open (issue #724) ───────────────────────────────────── @@ -3506,15 +3539,17 @@ class ChatViewModelTest { runTest { mockkObject(GatewayFileClient) val cacheDir = - java.io.File( - System.getProperty("java.io.tmpdir"), - "gw_save_${System.nanoTime()}", - ).apply { mkdirs() } + java.io + .File( + System.getProperty("java.io.tmpdir"), + "gw_save_${System.nanoTime()}", + ).apply { mkdirs() } every { app.cacheDir } returns cacheDir every { app.applicationContext } returns app every { app.getApplicationContext() } returns app val file = - java.io.File(System.getProperty("java.io.tmpdir"), "note_${System.nanoTime()}.txt") + java.io + .File(System.getProperty("java.io.tmpdir"), "note_${System.nanoTime()}.txt") .apply { writeText("downloaded") } coEvery { GatewayFileClient.fetch("/tmp/note.txt", any()) } returns GatewayFileResult.Success(GatewayFile("note.txt", "text/plain", file)) @@ -3552,10 +3587,11 @@ class ChatViewModelTest { runTest { mockkObject(GatewayFileClient) val cacheDir = - java.io.File( - System.getProperty("java.io.tmpdir"), - "gw_save_${System.nanoTime()}", - ).apply { mkdirs() } + java.io + .File( + System.getProperty("java.io.tmpdir"), + "gw_save_${System.nanoTime()}", + ).apply { mkdirs() } every { app.cacheDir } returns cacheDir every { app.applicationContext } returns app every { app.getApplicationContext() } returns app @@ -3590,10 +3626,11 @@ class ChatViewModelTest { runTest { mockkObject(GatewayFileClient) val cacheDir = - java.io.File( - System.getProperty("java.io.tmpdir"), - "gw_save_${System.nanoTime()}", - ).apply { mkdirs() } + java.io + .File( + System.getProperty("java.io.tmpdir"), + "gw_save_${System.nanoTime()}", + ).apply { mkdirs() } every { app.cacheDir } returns cacheDir every { app.applicationContext } returns app every { app.getApplicationContext() } returns app @@ -3606,7 +3643,8 @@ class ChatViewModelTest { GatewayFile( "report.pdf", "application/pdf", - java.io.File(System.getProperty("java.io.tmpdir"), "report_${System.nanoTime()}.pdf") + java.io + .File(System.getProperty("java.io.tmpdir"), "report_${System.nanoTime()}.pdf") .apply { writeBytes(byteArrayOf(1)) }, ), ) @@ -3625,7 +3663,11 @@ class ChatViewModelTest { verify(exactly = 0) { resolver.delete(any(), any(), any()) } assertNull(viewModel.uiState.value.savingAttachmentPath) - assertTrue(viewModel.uiState.value.openError.orEmpty().startsWith("Could not save")) + assertTrue( + viewModel.uiState.value.openError + .orEmpty() + .startsWith("Could not save"), + ) } @Test @@ -3633,10 +3675,11 @@ class ChatViewModelTest { runTest { mockkObject(GatewayFileClient) val cacheDir = - java.io.File( - System.getProperty("java.io.tmpdir"), - "gw_save_${System.nanoTime()}", - ).apply { mkdirs() } + java.io + .File( + System.getProperty("java.io.tmpdir"), + "gw_save_${System.nanoTime()}", + ).apply { mkdirs() } every { app.cacheDir } returns cacheDir every { app.applicationContext } returns app every { app.getApplicationContext() } returns app @@ -3675,10 +3718,11 @@ class ChatViewModelTest { runTest { mockkObject(GatewayFileClient) val cacheDir = - java.io.File( - System.getProperty("java.io.tmpdir"), - "gw_open_${System.nanoTime()}", - ).apply { mkdirs() } + java.io + .File( + System.getProperty("java.io.tmpdir"), + "gw_open_${System.nanoTime()}", + ).apply { mkdirs() } every { app.cacheDir } returns cacheDir every { app.applicationContext } returns app every { app.getApplicationContext() } returns app @@ -3711,10 +3755,11 @@ class ChatViewModelTest { runTest { mockkObject(GatewayFileClient) val cacheDir = - java.io.File( - System.getProperty("java.io.tmpdir"), - "gw_open_${System.nanoTime()}", - ).apply { mkdirs() } + java.io + .File( + System.getProperty("java.io.tmpdir"), + "gw_open_${System.nanoTime()}", + ).apply { mkdirs() } every { app.cacheDir } returns cacheDir every { app.applicationContext } returns app every { app.getApplicationContext() } returns app @@ -3743,7 +3788,11 @@ class ChatViewModelTest { // …and cleared in all outcomes. assertNull(vm.uiState.value.openingAttachmentPath) - assertTrue(vm.uiState.value.openError.orEmpty().contains("big.pdf")) + assertTrue( + vm.uiState.value.openError + .orEmpty() + .contains("big.pdf"), + ) } @Test @@ -3763,7 +3812,10 @@ class ChatViewModelTest { advanceUntilIdle() // Optimistic UI state has the user message immediately - assertTrue(vm.uiState.value.messages.any { it.content == "My super important long prompt" }) + assertTrue( + vm.uiState.value.messages + .any { it.content == "My super important long prompt" }, + ) assertTrue(vm.uiState.value.isAgentTyping) // But wsClient.sendMessage not dispatched yet because session_id was null assertTrue(sentPrompts.isEmpty()) @@ -3781,7 +3833,10 @@ class ChatViewModelTest { // Prompt should be dispatched automatically! assertEquals(listOf("My super important long prompt"), sentPrompts) assertEquals("session-storage-969", vm.uiState.value.currentSessionId) - assertTrue(vm.uiState.value.messages.any { it.content == "My super important long prompt" }) + assertTrue( + vm.uiState.value.messages + .any { it.content == "My super important long prompt" }, + ) } @Test @@ -3806,7 +3861,10 @@ class ChatViewModelTest { advanceUntilIdle() // Optimistic message in UI - assertTrue(vm.uiState.value.messages.any { it.content == "Prompt typed during reconnect" }) + assertTrue( + vm.uiState.value.messages + .any { it.content == "Prompt typed during reconnect" }, + ) // Complete the new SESSION_CREATE RPC mockEventsFlow.emit( @@ -3842,6 +3900,9 @@ class ChatViewModelTest { assertFalse(vm.uiState.value.isAgentTyping) assertNotNull(vm.uiState.value.errorMessage) - assertTrue(vm.uiState.value.errorMessage!!.contains("Backend exploded")) + assertTrue( + vm.uiState.value.errorMessage!! + .contains("Backend exploded"), + ) } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt index 43f43064..848bee17 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt @@ -421,7 +421,12 @@ class ChatWsEventReducerTest { ) assertEquals(1, result.state.messages.size) - assertEquals("complete", result.state.messages.single().content) + assertEquals( + "complete", + result.state.messages + .single() + .content, + ) } @Test @@ -556,12 +561,13 @@ class ChatWsEventReducerTest { var reasoningState = start.streamingState for (token in reasoningTokens) { reasoningState = - ChatWsEventReducer.reduce( - state, - reasoningState, - WsEvent.ReasoningDelta(token, "session-1"), - "session-1", - ).streamingState + ChatWsEventReducer + .reduce( + state, + reasoningState, + WsEvent.ReasoningDelta(token, "session-1"), + "session-1", + ).streamingState } assertEquals(true, reasoningState.isReasoning) assertEquals("The user just said \"run echo hi\". Let me run it.", reasoningState.reasoningText) @@ -578,8 +584,18 @@ class ChatWsEventReducerTest { "session-1", ) assertEquals(1, result.state.messages.size) - assertEquals(MessageRole.TOOL, result.state.messages.first().role) - assertEquals("terminal", result.state.messages.first().toolName) + assertEquals( + MessageRole.TOOL, + result.state.messages + .first() + .role, + ) + assertEquals( + "terminal", + result.state.messages + .first() + .toolName, + ) // Issue #771: streaming message + reasoning survive the tool call assertEquals(true, result.streamingState.streamingMessage?.isStreaming) assertEquals("The user just said \"run echo hi\". Let me run it.", result.streamingState.reasoningText) @@ -592,7 +608,12 @@ class ChatWsEventReducerTest { WsEvent.ToolComplete("terminal", mapOf("output" to "hi"), "session-1"), "session-1", ) - assertEquals(ToolStatus.COMPLETED, result.state.messages.first().toolStatus) + assertEquals( + ToolStatus.COMPLETED, + result.state.messages + .first() + .toolStatus, + ) // 5. reasoning.available — authoritative full-trace fill val fullReasoning = "The user just said \"run echo hi\". That's a simple terminal command. Let me just run it." @@ -683,7 +704,12 @@ class ChatWsEventReducerTest { "session-1", ) - assertEquals("payload reasoning", result.state.messages.last().reasoningText) + assertEquals( + "payload reasoning", + result.state.messages + .last() + .reasoningText, + ) } @Test diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt index 79216464..a029d76b 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt @@ -223,7 +223,8 @@ class SlashCommandDispatchRpcTest { // The optimistic bubble shows the queued TEXT (prefix stripped) so // the transcript sync dedupes it against the server echo. val userBubble = - vm.uiState.value.messages.lastOrNull { it.role == MessageRole.USER } + vm.uiState.value.messages + .lastOrNull { it.role == MessageRole.USER } assertEquals("do the thing", userBubble?.content) } @@ -247,7 +248,9 @@ class SlashCommandDispatchRpcTest { vm.sendMessage("/queue") advanceUntilIdle() - val last = vm.uiState.value.messages.lastOrNull() + val last = + vm.uiState.value.messages + .lastOrNull() assertEquals("usage: /queue ", last?.content) assertEquals(0, sendCalls) assertTrue( @@ -628,7 +631,9 @@ class SlashCommandDispatchRpcTest { vm.sendMessage("/focus on") advanceUntilIdle() - val last = vm.uiState.value.messages.lastOrNull() + val last = + vm.uiState.value.messages + .lastOrNull() assertEquals("Focus view: ON (tool progress pinned off)", last?.content) } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/common/ActionProgressControllerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/common/ActionProgressControllerTest.kt index cd3a0e96..164c1ed8 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/common/ActionProgressControllerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/common/ActionProgressControllerTest.kt @@ -202,7 +202,10 @@ class ActionProgressControllerTest { tick() assertEquals(ActionProgressPhase.SUCCEEDED, controller.state.value.phase) - assertTrue(controller.state.value.trailingLines.isEmpty()) + assertTrue( + controller.state.value.trailingLines + .isEmpty(), + ) controller.pushTrailingLines(listOf("Outcome: success", "Version: 0.20.4 → 0.20.5")) @@ -216,7 +219,10 @@ class ActionProgressControllerTest { val controller = controller() controller.open() controller.pushTrailingLines(emptyList()) - assertTrue(controller.state.value.trailingLines.isEmpty()) + assertTrue( + controller.state.value.trailingLines + .isEmpty(), + ) } @Test diff --git a/app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt index defba80b..73fff0e9 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt @@ -204,7 +204,9 @@ class CronJobsViewModelTest { vm.saveEditor() settle() - val toast = vm.uiState.value.editorState.toastMessage.orEmpty() + val toast = + vm.uiState.value.editorState.toastMessage + .orEmpty() assertTrue(toast, toast.contains("invalid time '25:00'")) assertFalse(vm.uiState.value.editorState.isSaving) } @@ -394,7 +396,11 @@ class CronJobsViewModelTest { coVerify { mockApi.deleteCronJob("j5") } assertTrue(vm.uiState.value.editorState.isOpen) - assertTrue(vm.uiState.value.editorState.toastMessage.orEmpty().contains("Failed to save")) + assertTrue( + vm.uiState.value.editorState.toastMessage + .orEmpty() + .contains("Failed to save"), + ) } @Test @@ -549,7 +555,9 @@ class CronJobsViewModelTest { vm.loadCronJobs() settle() - val job = vm.uiState.value.jobs.first() + val job = + vm.uiState.value.jobs + .first() assertNotNull(job.last_fire_error) assertEquals("gateway unreachable", job.last_fire_error?.detail) assertEquals("2026-08-18T09:00:00", job.last_fire_error?.at) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanViewModelTest.kt index f2e15a1c..5660cd3c 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanViewModelTest.kt @@ -222,7 +222,12 @@ class KanbanViewModelTest { coEvery { mockApi.updateKanbanTask(any(), any()) } returns Response.success(Unit) vm.moveTask(KanbanTask(id = "t1", title = "Task 1", status = "todo"), KanbanTaskAction.READY) settle() - assertEquals("ready", vm.uiState.value.tasks.first { it.id == "t1" }.status) + assertEquals( + "ready", + vm.uiState.value.tasks + .first { it.id == "t1" } + .status, + ) coVerify { mockApi.updateKanbanTask("t1", mapOf("status" to "ready")) } } @@ -234,8 +239,16 @@ class KanbanViewModelTest { coEvery { mockApi.updateKanbanTask(any(), any()) } returns Response.error(409, "".toResponseBody()) vm.moveTask(KanbanTask(id = "t1", title = "Task 1", status = "todo"), KanbanTaskAction.READY) settle() - assertEquals("todo", vm.uiState.value.tasks.first { it.id == "t1" }.status) - assertTrue(vm.uiState.value.toastMessage?.contains("Move failed") == true) + assertEquals( + "todo", + vm.uiState.value.tasks + .first { it.id == "t1" } + .status, + ) + assertTrue( + vm.uiState.value.toastMessage + ?.contains("Move failed") == true, + ) } @Test diff --git a/app/src/test/java/com/m57/hermescontrol/ui/memory/MemoryViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/memory/MemoryViewModelTest.kt index 15701bba..6ccb1f64 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/memory/MemoryViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/memory/MemoryViewModelTest.kt @@ -81,7 +81,9 @@ class MemoryViewModelTest { @Test fun `load also fetches learning graph for self-improvement section`() { - val graph = com.m57.hermescontrol.data.model.LearningGraphResponse(nodes = emptyList()) + val graph = + com.m57.hermescontrol.data.model + .LearningGraphResponse(nodes = emptyList()) coEvery { mockApi.getLearningGraph() } returns Response.success(graph) val vm = MemoryViewModel() vm.load() @@ -100,7 +102,11 @@ class MemoryViewModelTest { testDispatcher.scheduler.advanceUntilIdle() assertNull(vm.uiState.value.memory) - assertTrue(vm.uiState.value.errorMessage.orEmpty().isNotBlank()) + assertTrue( + vm.uiState.value.errorMessage + .orEmpty() + .isNotBlank(), + ) } @Test @@ -127,7 +133,11 @@ class MemoryViewModelTest { vm.resetMemory("memory") testDispatcher.scheduler.advanceUntilIdle() - assertTrue(vm.uiState.value.toastMessage.orEmpty().contains("Failed to reset memory")) + assertTrue( + vm.uiState.value.toastMessage + .orEmpty() + .contains("Failed to reset memory"), + ) assertNull(vm.uiState.value.resetting) } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/pairing/PairingViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/pairing/PairingViewModelTest.kt index d9279a97..f612977c 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/pairing/PairingViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/pairing/PairingViewModelTest.kt @@ -97,9 +97,21 @@ class PairingViewModelTest { assertFalse(state.isLoading) assertNull(state.errorMessage) assertEquals(1, state.pairing?.pending?.size) - assertEquals("bob", state.pairing?.pending?.first()?.userName) + assertEquals( + "bob", + state.pairing + ?.pending + ?.first() + ?.userName, + ) assertEquals(1, state.pairing?.approved?.size) - assertEquals("alice", state.pairing?.approved?.first()?.userName) + assertEquals( + "alice", + state.pairing + ?.approved + ?.first() + ?.userName, + ) } @Test @@ -148,7 +160,10 @@ class PairingViewModelTest { assertEquals("discord", requestSlot.captured.platform) assertEquals("u2", requestSlot.captured.userId) coVerify(exactly = 1) { mockApi.getPairing() } - assertTrue(vm.uiState.value.toastMessage?.contains("u2") == true) + assertTrue( + vm.uiState.value.toastMessage + ?.contains("u2") == true, + ) } @Test diff --git a/app/src/test/java/com/m57/hermescontrol/ui/plugins/MemoryProviderDetailViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/plugins/MemoryProviderDetailViewModelTest.kt index 0fe71235..1926d096 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/plugins/MemoryProviderDetailViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/plugins/MemoryProviderDetailViewModelTest.kt @@ -229,7 +229,11 @@ class MemoryProviderDetailViewModelTest { vm.saveConfig() testDispatcher.scheduler.advanceUntilIdle() - assertTrue(vm.uiState.value.toastMessage.orEmpty().contains("Failed to save")) + assertTrue( + vm.uiState.value.toastMessage + .orEmpty() + .contains("Failed to save"), + ) } @Test @@ -242,7 +246,13 @@ class MemoryProviderDetailViewModelTest { val state = vm.uiState.value assertEquals("Setup completed", state.toastMessage) assertTrue(state.setupResult?.ok == true) - assertEquals("installed", state.setupResult?.results?.first()?.status) + assertEquals( + "installed", + state.setupResult + ?.results + ?.first() + ?.status, + ) } @Test @@ -253,7 +263,11 @@ class MemoryProviderDetailViewModelTest { vm.runSetup() testDispatcher.scheduler.advanceUntilIdle() - assertTrue(vm.uiState.value.toastMessage.orEmpty().contains("Setup failed")) + assertTrue( + vm.uiState.value.toastMessage + .orEmpty() + .contains("Setup failed"), + ) } @Test diff --git a/app/src/test/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModelTest.kt index 28b7a7a8..517a786e 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModelTest.kt @@ -136,7 +136,10 @@ class ProfilesViewModelTest { testDispatcher.scheduler.advanceUntilIdle() coVerify { mockApi.renameProfile("work", RenameProfileRequest("play")) } - assertTrue(vm.uiState.value.toastMessage!!.contains("renamed")) + assertTrue( + vm.uiState.value.toastMessage!! + .contains("renamed"), + ) // reload happened coVerify { mockApi.getProfiles() } } @@ -162,7 +165,10 @@ class ProfilesViewModelTest { vm.renameProfile("work", "play") testDispatcher.scheduler.advanceUntilIdle() - assertTrue(vm.uiState.value.toastMessage!!.contains("Failed to rename")) + assertTrue( + vm.uiState.value.toastMessage!! + .contains("Failed to rename"), + ) assertFalse(vm.uiState.value.isLoading) } @@ -175,7 +181,10 @@ class ProfilesViewModelTest { testDispatcher.scheduler.advanceUntilIdle() coVerify { mockApi.deleteProfile("work") } - assertTrue(vm.uiState.value.toastMessage!!.contains("deleted")) + assertTrue( + vm.uiState.value.toastMessage!! + .contains("deleted"), + ) coVerify { mockApi.getProfiles() } } @@ -187,7 +196,10 @@ class ProfilesViewModelTest { vm.deleteProfile("work") testDispatcher.scheduler.advanceUntilIdle() - assertTrue(vm.uiState.value.toastMessage!!.contains("Failed to delete")) + assertTrue( + vm.uiState.value.toastMessage!! + .contains("Failed to delete"), + ) } @Test @@ -200,7 +212,10 @@ class ProfilesViewModelTest { testDispatcher.scheduler.advanceUntilIdle() coVerify { mockApi.describeProfileAuto("work", ProfileDescribeAutoRequest(overwrite = true)) } - assertTrue(vm.uiState.value.toastMessage!!.contains("Auto-described")) + assertTrue( + vm.uiState.value.toastMessage!! + .contains("Auto-described"), + ) assertFalse(vm.uiState.value.isAutoDescribing) coVerify { mockApi.getProfiles() } } @@ -214,7 +229,10 @@ class ProfilesViewModelTest { vm.autoDescribeProfile("work") testDispatcher.scheduler.advanceUntilIdle() - assertTrue(vm.uiState.value.toastMessage!!.contains("no aux client configured")) + assertTrue( + vm.uiState.value.toastMessage!! + .contains("no aux client configured"), + ) assertFalse(vm.uiState.value.isAutoDescribing) } @@ -241,7 +259,10 @@ class ProfilesViewModelTest { testDispatcher.scheduler.advanceUntilIdle() assertNull(vm.uiState.value.setupCommand) - assertTrue(vm.uiState.value.toastMessage!!.contains("Failed to fetch setup command")) + assertTrue( + vm.uiState.value.toastMessage!! + .contains("Failed to fetch setup command"), + ) } @Test @@ -265,7 +286,11 @@ class ProfilesViewModelTest { testDispatcher.scheduler.advanceUntilIdle() assertEquals(1, vm.uiState.value.modelProviders.size) - assertEquals("gpt-4o", vm.uiState.value.modelProviders[0].models!![0]) + assertEquals( + "gpt-4o", + vm.uiState.value.modelProviders[0] + .models!![0], + ) assertEquals(listOf(PinnedModel("openai", "gpt-4")), vm.uiState.value.modelPickerPinned) assertFalse(vm.uiState.value.isLoadingBuilderData) } @@ -278,7 +303,10 @@ class ProfilesViewModelTest { vm.loadModelOptions() testDispatcher.scheduler.advanceUntilIdle() - assertTrue(vm.uiState.value.toastMessage!!.contains("Failed to load models")) + assertTrue( + vm.uiState.value.toastMessage!! + .contains("Failed to load models"), + ) assertNull(vm.uiState.value.errorMessage) assertFalse(vm.uiState.value.isLoadingBuilderData) } @@ -292,7 +320,10 @@ class ProfilesViewModelTest { assertEquals(listOf(PinnedModel("openai", "gpt-4")), storedPinnedModels) vm.togglePinModel("openai", "gpt-4") - assertTrue(vm.uiState.value.modelPickerPinned.isEmpty()) + assertTrue( + vm.uiState.value.modelPickerPinned + .isEmpty(), + ) assertTrue(storedPinnedModels.isEmpty()) } @@ -305,7 +336,10 @@ class ProfilesViewModelTest { testDispatcher.scheduler.advanceUntilIdle() coVerify { ProfileSwitchCoordinator.switchProfile("work") } - assertTrue(vm.uiState.value.toastMessage!!.contains("Switched to profile work")) + assertTrue( + vm.uiState.value.toastMessage!! + .contains("Switched to profile work"), + ) coVerify { mockApi.getProfiles() } } @@ -320,7 +354,10 @@ class ProfilesViewModelTest { testDispatcher.scheduler.advanceUntilIdle() assertNull(vm.uiState.value.activeProfileName) - assertTrue(vm.uiState.value.toastMessage!!.contains("Failed to switch profile")) + assertTrue( + vm.uiState.value.toastMessage!! + .contains("Failed to switch profile"), + ) } @Test @@ -339,7 +376,10 @@ class ProfilesViewModelTest { ), ) } - assertTrue(vm.uiState.value.toastMessage!!.contains("cloned successfully")) + assertTrue( + vm.uiState.value.toastMessage!! + .contains("cloned successfully"), + ) assertFalse(vm.uiState.value.isLoading) coVerify { mockApi.getProfiles() } } @@ -352,7 +392,10 @@ class ProfilesViewModelTest { vm.cloneProfile("default", "dev-copy") testDispatcher.scheduler.advanceUntilIdle() - assertTrue(vm.uiState.value.toastMessage!!.contains("Failed to clone profile")) + assertTrue( + vm.uiState.value.toastMessage!! + .contains("Failed to clone profile"), + ) assertFalse(vm.uiState.value.isLoading) } @@ -378,12 +421,20 @@ class ProfilesViewModelTest { vm.hideProfile("secret-bot") assertEquals(listOf("secret-bot"), storedHiddenProfiles) assertTrue(vm.uiState.value.hasHiddenProfiles) - assertEquals(listOf("default"), vm.uiState.value.displayProfiles.map { it.name }) + assertEquals( + listOf("default"), + vm.uiState.value.displayProfiles + .map { it.name }, + ) // Toggle show hidden vm.toggleShowHidden() assertTrue(vm.uiState.value.showHidden) - assertEquals(listOf("default", "secret-bot"), vm.uiState.value.displayProfiles.map { it.name }) + assertEquals( + listOf("default", "secret-bot"), + vm.uiState.value.displayProfiles + .map { it.name }, + ) // Unhide vm.unhideProfile("secret-bot") diff --git a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt index 94799d90..1ec675fe 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt @@ -116,7 +116,11 @@ class SessionsViewModelTest { vm.loadSessions() testDispatcher.scheduler.advanceUntilIdle() - assertEquals(listOf("s-1", "s-2"), vm.uiState.value.sessions.map { it.id }) + assertEquals( + listOf("s-1", "s-2"), + vm.uiState.value.sessions + .map { it.id }, + ) assertEquals(2, vm.uiState.value.total) assertFalse(vm.uiState.value.hasMore) assertFalse(vm.uiState.value.isLoadingMore) @@ -206,7 +210,11 @@ class SessionsViewModelTest { vm.loadSessions() testDispatcher.scheduler.advanceUntilIdle() - assertEquals(listOf("old-pinned", "recent"), vm.uiState.value.sessions.map { it.id }) + assertEquals( + listOf("old-pinned", "recent"), + vm.uiState.value.sessions + .map { it.id }, + ) } @Test @@ -226,8 +234,17 @@ class SessionsViewModelTest { vm.togglePin("s-2") testDispatcher.scheduler.advanceUntilIdle() - assertEquals(listOf("s-2", "s-1"), vm.uiState.value.sessions.map { it.id }) - assertEquals(true, vm.uiState.value.sessions.first().pinned) + assertEquals( + listOf("s-2", "s-1"), + vm.uiState.value.sessions + .map { it.id }, + ) + assertEquals( + true, + vm.uiState.value.sessions + .first() + .pinned, + ) assertEquals("Session pinned", vm.uiState.value.toastMessage) } @@ -249,9 +266,19 @@ class SessionsViewModelTest { vm.togglePin("s-2") testDispatcher.scheduler.advanceUntilIdle() - assertEquals(listOf("s-1", "s-2"), vm.uiState.value.sessions.map { it.id }) - assertFalse(vm.uiState.value.sessions[1].pinned ?: false) + assertEquals( + listOf("s-1", "s-2"), + vm.uiState.value.sessions + .map { it.id }, + ) + assertFalse( + vm.uiState.value.sessions[1] + .pinned ?: false, + ) assertNotNull(vm.uiState.value.toastMessage) - assertTrue(vm.uiState.value.toastMessage!!.contains("Pin failed")) + assertTrue( + vm.uiState.value.toastMessage!! + .contains("Pin failed"), + ) } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/skills/SkillsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/skills/SkillsViewModelTest.kt index 1cad80e1..9a29bdc0 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/skills/SkillsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/skills/SkillsViewModelTest.kt @@ -126,7 +126,11 @@ class SkillsViewModelTest { vm.loadHubSources() settle() - assertEquals(true, vm.uiState.value.hubSourcesError?.contains("Failed to load hub sources")) + assertEquals( + true, + vm.uiState.value.hubSourcesError + ?.contains("Failed to load hub sources"), + ) assertEquals(false, vm.uiState.value.isHubSourcesLoading) } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/toolsets/ToolsetDetailViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/toolsets/ToolsetDetailViewModelTest.kt index fd08befc..93bc3767 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/toolsets/ToolsetDetailViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/toolsets/ToolsetDetailViewModelTest.kt @@ -165,7 +165,15 @@ class ToolsetDetailViewModelTest { testDispatcher.scheduler.advanceUntilIdle() val state = vm.uiState.value - assertEquals(true, state.config?.providers?.get(1)?.envVars?.first { it.key == "KEY_B" }?.isSet) + assertEquals( + true, + state.config + ?.providers + ?.get(1) + ?.envVars + ?.first { it.key == "KEY_B" } + ?.isSet, + ) assertEquals("Saved KEY_B", state.toastMessage) assertNull(state.savingEnvKey) } @@ -196,7 +204,15 @@ class ToolsetDetailViewModelTest { testDispatcher.scheduler.advanceUntilIdle() val state = vm.uiState.value - assertEquals(false, state.config?.providers?.get(1)?.envVars?.first { it.key == "KEY_B" }?.isSet) + assertEquals( + false, + state.config + ?.providers + ?.get(1) + ?.envVars + ?.first { it.key == "KEY_B" } + ?.isSet, + ) assertFalse(state.revealedValues.containsKey("KEY_B")) assertEquals("Cleared KEY_B", state.toastMessage) assertNull(state.deletingEnvKey) @@ -213,7 +229,10 @@ class ToolsetDetailViewModelTest { assertEquals("sekret", vm.uiState.value.revealedValues["KEY_B"]) vm.hideEnvVar("KEY_B") - assertFalse(vm.uiState.value.revealedValues.containsKey("KEY_B")) + assertFalse( + vm.uiState.value.revealedValues + .containsKey("KEY_B"), + ) } @Test @@ -316,7 +335,10 @@ class ToolsetDetailViewModelTest { testDispatcher.scheduler.advanceUntilIdle() assertNull(vm.uiState.value.postSetup) - assertTrue(vm.uiState.value.toastMessage?.contains("spawn rejected") == true) + assertTrue( + vm.uiState.value.toastMessage + ?.contains("spawn rejected") == true, + ) coVerify(exactly = 0) { mockApi.getActionStatus(any()) } } }