From cdcecb97348a3bdf18e03a722151aba4fc2d0bef Mon Sep 17 00:00:00 2001 From: M57 Date: Sun, 30 Aug 2026 20:51:06 +0300 Subject: [PATCH 01/20] feat(profiles): add local on-device profile hiding and filtering --- .../data/config/ServerStoreState.kt | 2 + .../hermescontrol/data/local/AuthManager.kt | 22 +++++ .../hermescontrol/ui/bots/BotsViewModel.kt | 10 +- .../hermescontrol/ui/chat/ChatViewModel.kt | 5 +- .../ui/profiles/ProfilesScreen.kt | 98 +++++++++++++++++-- .../ui/profiles/ProfilesViewModel.kt | 46 ++++++++- app/src/main/res/values-ko/strings.xml | 6 ++ app/src/main/res/values-zh/strings.xml | 6 ++ app/src/main/res/values/strings.xml | 6 ++ .../ui/bots/BotsViewModelTest.kt | 4 + .../ui/profiles/ProfilesViewModelTest.kt | 45 +++++++++ 11 files changed, 240 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt index 1de600f7..daa7a714 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt @@ -25,6 +25,8 @@ data class ServerStoreState( // App display language. "system" = follow device locale; otherwise a BCP-47 // language code such as "en" or "ko". Applied via ContextWrapper in MainActivity. val appLanguage: String = "system", + // Local on-device hidden profile names (filtered out from list views unless revealed). + val hiddenProfiles: List = emptyList(), // App version the silent update check (issue #867) last completed for. // Null / mismatched with BuildConfig.VERSION_NAME → the About tab runs // its one-time check again (re-check on app version bump). diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index 4c06b7bf..a671101c 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -357,6 +357,28 @@ object AuthManager { serverStore.update { it.copy(pinnedModels = pinned) } } + // ── Hidden Profiles (Local On-Device Filtering) ────────────────────── + + fun getHiddenProfiles(): List = serverStore.getLatestState().hiddenProfiles + + fun isProfileHidden(name: String): Boolean = serverStore.getLatestState().hiddenProfiles.contains(name) + + fun hideProfile(name: String) { + serverStore.update { s -> + s.copy(hiddenProfiles = (s.hiddenProfiles + name).distinct()) + } + } + + fun unhideProfile(name: String) { + serverStore.update { s -> + s.copy(hiddenProfiles = s.hiddenProfiles.filter { it != name }) + } + } + + fun setHiddenProfiles(hidden: List) { + serverStore.update { it.copy(hiddenProfiles = hidden.distinct()) } + } + fun getProfileToken(profileId: String): String? = requirePrefs().getString("token_$profileId", null) fun setProfileToken( 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 c1a9764e..a0ad8fdc 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 @@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.bots import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.model.BotAvatarMeta import com.m57.hermescontrol.data.model.BotRosterMeta import com.m57.hermescontrol.data.model.CreateProfileRequest @@ -47,12 +48,13 @@ data class BotsUiState( val activeProfileName: String? = null, val searchQuery: String = "", val showHidden: Boolean = false, + val hiddenProfiles: Set = emptySet(), val selectedTab: BotsTab = BotsTab.BOTS, val errorMessage: String? = null, val toastMessage: String? = null, ) { val hasHiddenBots: Boolean - get() = profiles.any { it.isHidden } + get() = profiles.any { it.isHidden || it.name in hiddenProfiles } val allGroups: List get() { @@ -185,7 +187,8 @@ data class BotsUiState( val query = searchQuery.trim().lowercase() return profiles .filter { profile -> - if (!showHidden && profile.isHidden) return@filter false + val isHidden = profile.isHidden || profile.name in hiddenProfiles + if (!showHidden && isHidden) return@filter false if (query.isBlank()) return@filter true profile.name.lowercase().contains(query) || profile.effectiveTitle.lowercase().contains(query) || @@ -212,6 +215,7 @@ class BotsViewModel( val uiState: StateFlow = _uiState.asStateFlow() init { + _uiState.update { it.copy(hiddenProfiles = AuthManager.getHiddenProfiles().toSet()) } if (autoLoad) { loadBots() } @@ -262,6 +266,7 @@ class BotsViewModel( isRefreshing = false, profiles = profilesWithMeta, activeProfileName = activeName ?: it.activeProfileName, + hiddenProfiles = AuthManager.getHiddenProfiles().toSet(), errorMessage = null, ) } @@ -273,6 +278,7 @@ class BotsViewModel( isRefreshing = false, profiles = profilesResult.data.profiles.orEmpty(), activeProfileName = activeName ?: it.activeProfileName, + hiddenProfiles = AuthManager.getHiddenProfiles().toSet(), errorMessage = null, ) } 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 29114bed..a461af90 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 @@ -1901,7 +1901,10 @@ class ChatViewModel( // Fetch available bot profiles for @ autocomplete val profilesResult = safeApiCall { ApiClient.hermesApi.getProfiles() } if (profilesResult is NetworkResult.Success) { - val profiles = profilesResult.data.profiles.orEmpty() + val profiles = + profilesResult.data.profiles.orEmpty().filter { + !AuthManager.isProfileHidden(it.name) && it.botMeta()?.hidden != true + } _uiState.update { it.copy(availableBots = profiles) } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt index e79d8e12..cada9a40 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt @@ -21,6 +21,8 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card @@ -49,6 +51,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily @@ -131,6 +134,29 @@ fun ProfilesScreen( }, actions = { if (!isBuildingProfile) { + if (state.hasHiddenProfiles) { + IconButton( + onClick = { viewModel.toggleShowHidden() }, + modifier = Modifier.testTag("profiles_action_toggle_hidden"), + ) { + Icon( + imageVector = + if (state.showHidden) { + Icons.Default.VisibilityOff + } else { + Icons.Default.Visibility + }, + contentDescription = + stringResource( + if (state.showHidden) { + R.string.profiles_hide_hidden + } else { + R.string.profiles_show_hidden + }, + ), + ) + } + } IconButton(onClick = { isBuildingProfile = true viewModel.loadBuilderData() @@ -173,6 +199,16 @@ fun ProfilesScreen( ) } + state.displayProfiles.isEmpty() -> { + EmptyState( + title = stringResource(R.string.profiles_empty_title), + subtitle = stringResource(R.string.profiles_all_hidden_desc), + onAction = { viewModel.toggleShowHidden() }, + actionLabel = stringResource(R.string.profiles_show_hidden), + modifier = Modifier.padding(paddingValues), + ) + } + else -> { Box(Modifier.fillMaxSize()) { if (state.isLoading && state.profiles.isEmpty()) { @@ -194,7 +230,7 @@ fun ProfilesScreen( contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - items(state.profiles, key = { it.name }) { profile -> + items(state.displayProfiles, key = { it.name }) { profile -> val isActive = profile.name == state.activeProfileName Card( modifier = Modifier.fillMaxWidth(), @@ -225,11 +261,37 @@ fun ProfilesScreen( verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { - Text( - text = profile.name.replaceFirstChar { it.uppercase() }, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = profile.name.replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + val isProfileHidden = + profile.name in state.hiddenProfiles || + profile.botMeta()?.hidden == true + if (isProfileHidden) { + Spacer(modifier = Modifier.width(8.dp)) + Box( + modifier = + Modifier + .clip(RoundedCornerShape(4.dp)) + .background( + MaterialTheme.colorScheme.surfaceVariant, + ).padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Text( + text = + stringResource( + R.string.profiles_hidden_badge, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + ) + } + } + } val descriptionText = if (!profile.description.isNullOrBlank()) { profile.description @@ -376,6 +438,30 @@ fun ProfilesScreen( setupCmdProfileName = profile.name }, ) + val isProfileHidden = + profile.name in state.hiddenProfiles || + profile.botMeta()?.hidden == true + DropdownMenuItem( + text = { + Text( + stringResource( + if (isProfileHidden) { + R.string.profiles_action_unhide + } else { + R.string.profiles_action_hide + }, + ), + ) + }, + onClick = { + showMenu = false + if (isProfileHidden) { + viewModel.unhideProfile(profile.name) + } else { + viewModel.hideProfile(profile.name) + } + }, + ) if (profile.is_default != true) { HorizontalDivider() DropdownMenuItem( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt index e02bd971..26e3cbd3 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt @@ -38,6 +38,8 @@ data class ProfilesUiState( val isLoadingSoul: Boolean = false, val errorMessage: String? = null, val toastMessage: String? = null, + val showHidden: Boolean = false, + val hiddenProfiles: Set = emptySet(), // Rename / delete / auto-describe / setup-command states val isAutoDescribing: Boolean = false, val setupCommand: String? = null, @@ -50,7 +52,20 @@ data class ProfilesUiState( val availableSkills: List = emptyList(), val hubSearchResults: List = emptyList(), val isSearchingHub: Boolean = false, -) +) { + val hasHiddenProfiles: Boolean + get() = profiles.any { it.name in hiddenProfiles || it.botMeta()?.hidden == true } + + val displayProfiles: List + get() = + profiles.filter { profile -> + if (showHidden) { + true + } else { + !(profile.name in hiddenProfiles || profile.botMeta()?.hidden == true) + } + } +} class ProfilesViewModel( private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, @@ -59,6 +74,34 @@ class ProfilesViewModel( private val _uiState = MutableStateFlow(ProfilesUiState()) val uiState: StateFlow = _uiState.asStateFlow() + init { + _uiState.update { it.copy(hiddenProfiles = AuthManager.getHiddenProfiles().toSet()) } + } + + fun toggleShowHidden() { + _uiState.update { it.copy(showHidden = !it.showHidden) } + } + + fun hideProfile(name: String) { + AuthManager.hideProfile(name) + _uiState.update { + it.copy( + hiddenProfiles = AuthManager.getHiddenProfiles().toSet(), + toastMessage = "Profile $name hidden", + ) + } + } + + fun unhideProfile(name: String) { + AuthManager.unhideProfile(name) + _uiState.update { + it.copy( + hiddenProfiles = AuthManager.getHiddenProfiles().toSet(), + toastMessage = "Profile $name unhidden", + ) + } + } + fun loadProfiles() { _uiState.update { it.copy(isLoading = true, errorMessage = null) } viewModelScope.launch { @@ -77,6 +120,7 @@ class ProfilesViewModel( isLoading = false, profiles = profilesResult.data.profiles.orEmpty(), activeProfileName = activeResult.data.active, + hiddenProfiles = AuthManager.getHiddenProfiles().toSet(), ) } } else { diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index d43eff7c..38f2446f 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -453,6 +453,12 @@ 모델 설정 복제 설명 편집 + 프로필 숨기기 + 프로필 숨김 해제 + 숨겨진 프로필 표시 + 숨겨진 프로필 숨기기 + 숨김 + 모든 프로필이 현재 숨겨져 있습니다. 아래를 탭하여 숨겨진 프로필을 표시하세요. 활성 프로필 소울 프로필 편집: %1$s 모델 설정: %1$s diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 6ee20c4a..9e66e094 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -596,6 +596,12 @@ 设置模型 克隆 编辑描述 + 隐藏配置文件 + 取消隐藏配置文件 + 显示隐藏的配置文件 + 隐藏已隐藏的配置文件 + 已隐藏 + 所有配置文件当前均已隐藏。点击下方以显示已隐藏的配置文件。 当前配置 编辑 Soul 配置:%1$s 设置模型:%1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d086f4ea..f47e7ff9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -682,6 +682,12 @@ Set Model Clone Edit Description + Hide Profile + Unhide Profile + Show hidden profiles + Hide hidden profiles + Hidden + All profiles are currently hidden. Tap below to show hidden profiles. Active Profile Edit Soul Profile: %1$s Set Model: %1$s 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 74777d1f..e074c4a2 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 @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.bots +import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.model.ActiveProfileResponse import com.m57.hermescontrol.data.model.BotAvatarMeta import com.m57.hermescontrol.data.model.BotRosterMeta @@ -45,6 +46,8 @@ class BotsViewModelTest { @Before fun setUp() { Dispatchers.setMain(testDispatcher) + mockkObject(AuthManager) + every { AuthManager.getHiddenProfiles() } returns emptyList() mockkObject(ApiClient) mockApi = mockk(relaxed = true) every { ApiClient.hermesApi } returns mockApi @@ -57,6 +60,7 @@ class BotsViewModelTest { @After fun tearDown() { + unmockkObject(AuthManager) unmockkObject(ApiClient) unmockkObject(HermesWsClient) Dispatchers.resetMain() 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 bfe1736b..28b7a7a8 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 @@ -56,6 +56,7 @@ class ProfilesViewModelTest { private var storedPinnedModels: MutableList = mutableListOf() private var storedSelectedProfile: String? = null private var storedProfileToken: String? = null + private var storedHiddenProfiles: MutableList = mutableListOf() private fun stubProfilesLoad() { coEvery { mockApi.getProfiles() } returns @@ -88,6 +89,15 @@ class ProfilesViewModelTest { every { AuthManager.setProfileToken(any(), any()) } answers { storedProfileToken = secondArg() } + every { AuthManager.getHiddenProfiles() } answers { storedHiddenProfiles.toList() } + every { AuthManager.hideProfile(any()) } answers { + val name = firstArg() + if (!storedHiddenProfiles.contains(name)) storedHiddenProfiles.add(name) + } + every { AuthManager.unhideProfile(any()) } answers { + val name = firstArg() + storedHiddenProfiles.remove(name) + } mockkObject(HermesWsClient) every { HermesWsClient.disconnect() } returns Unit @@ -345,4 +355,39 @@ class ProfilesViewModelTest { assertTrue(vm.uiState.value.toastMessage!!.contains("Failed to clone profile")) assertFalse(vm.uiState.value.isLoading) } + + @Test + fun `hideProfile and unhideProfile update local hidden state and display filtering`() { + coEvery { mockApi.getProfiles() } returns + Response.success( + ProfilesResponse( + listOf( + ProfileInfo(name = "default", is_default = true), + ProfileInfo(name = "secret-bot"), + ), + ), + ) + + val vm = createViewModel() + vm.loadProfiles() + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(2, vm.uiState.value.displayProfiles.size) + assertFalse(vm.uiState.value.hasHiddenProfiles) + + vm.hideProfile("secret-bot") + assertEquals(listOf("secret-bot"), storedHiddenProfiles) + assertTrue(vm.uiState.value.hasHiddenProfiles) + 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 }) + + // Unhide + vm.unhideProfile("secret-bot") + assertTrue(storedHiddenProfiles.isEmpty()) + assertFalse(vm.uiState.value.hasHiddenProfiles) + } } From 807c0af381efb2cb75d4fd237db676d9d51efa72 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 06:37:47 +0000 Subject: [PATCH 02/20] chore: bump latex from 1.5.3 to 1.5.4 Bumps `latex` from 1.5.3 to 1.5.4. Updates `io.github.huarangmeng:latex-base` from 1.5.3 to 1.5.4 - [Release notes](https://github.com/huarangmeng/latex/releases) - [Commits](https://github.com/huarangmeng/latex/compare/release-1.5.3...release-1.5.4) Updates `io.github.huarangmeng:latex-parser` from 1.5.3 to 1.5.4 - [Release notes](https://github.com/huarangmeng/latex/releases) - [Commits](https://github.com/huarangmeng/latex/compare/release-1.5.3...release-1.5.4) Updates `io.github.huarangmeng:latex-renderer` from 1.5.3 to 1.5.4 - [Release notes](https://github.com/huarangmeng/latex/releases) - [Commits](https://github.com/huarangmeng/latex/compare/release-1.5.3...release-1.5.4) --- updated-dependencies: - dependency-name: io.github.huarangmeng:latex-base dependency-version: 1.5.4 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: io.github.huarangmeng:latex-parser dependency-version: 1.5.4 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: io.github.huarangmeng:latex-renderer dependency-version: 1.5.4 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 684835c6..255f6ef9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,7 +29,7 @@ coil = "3.6.0" room = "3.0.2" sqlcipher = "4.18.0" datastore = "1.1.2" -latex = "1.5.3" +latex = "1.5.4" [libraries] androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" } From d8e3992c33c1b548365dd7b50d6d9e734efb098f Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Mon, 31 Aug 2026 11:10:26 +0300 Subject: [PATCH 03/20] fix: memory screen reset actions serialization error (fixes #1020) --- .../m57/hermescontrol/data/model/MemoryResponse.kt | 11 +++++++++++ .../m57/hermescontrol/data/remote/HermesApiService.kt | 4 ++-- .../m57/hermescontrol/ui/memory/MemoryViewModel.kt | 3 ++- .../hermescontrol/ui/memory/MemoryViewModelTest.kt | 11 ++++++----- 4 files changed, 21 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/MemoryResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/MemoryResponse.kt index ef1e88f8..eb528cf5 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/MemoryResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/MemoryResponse.kt @@ -13,3 +13,14 @@ data class BuiltinFileSizes( val memory: Long? = null, val user: Long? = null, ) + +@Serializable +data class MemoryResetRequest( + val target: String = "all", +) + +@Serializable +data class MemoryResetResponse( + val ok: Boolean = false, + val deleted: List = emptyList(), +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 40e13100..7b92ec94 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -851,8 +851,8 @@ interface HermesApiService { @POST("api/memory/reset") suspend fun resetMemory( - @Body body: Map, - ): Response> + @Body body: MemoryResetRequest, + ): Response // ── Admin: Credential pool ──────────────────────────────────────── @GET("api/credentials/pool") 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 85cb7657..926500f9 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 @@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.memory import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.data.model.LearningGraphResponse +import com.m57.hermescontrol.data.model.MemoryResetRequest import com.m57.hermescontrol.data.model.MemoryResponse import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.NetworkResult @@ -67,7 +68,7 @@ class MemoryViewModel : ViewModel(), ToastHost { if (_uiState.value.resetting != null) return _uiState.update { it.copy(resetting = target) } viewModelScope.launch { - val result = safeApiCall { ApiClient.hermesApi.resetMemory(mapOf("target" to target)) } + val result = safeApiCall { ApiClient.hermesApi.resetMemory(MemoryResetRequest(target = target)) } when (result) { is NetworkResult.Success -> _uiState.update { 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 b8de1cf5..1c5dd148 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 @@ -1,5 +1,7 @@ package com.m57.hermescontrol.ui.memory +import com.m57.hermescontrol.data.model.MemoryResetRequest +import com.m57.hermescontrol.data.model.MemoryResetResponse import com.m57.hermescontrol.data.model.MemoryProviderStatusRow import com.m57.hermescontrol.data.model.MemoryResponse import com.m57.hermescontrol.data.remote.ApiClient @@ -103,15 +105,15 @@ class MemoryViewModelTest { @Test fun `resetMemory posts target and reloads`() { - coEvery { mockApi.resetMemory(mapOf("target" to "all")) } returns - Response.success(emptyMap()) + coEvery { mockApi.resetMemory(MemoryResetRequest(target = "all")) } returns + Response.success(MemoryResetResponse(ok = true)) val vm = MemoryViewModel() vm.load() testDispatcher.scheduler.advanceUntilIdle() vm.resetMemory("all") testDispatcher.scheduler.advanceUntilIdle() - coVerify { mockApi.resetMemory(mapOf("target" to "all")) } + coVerify { mockApi.resetMemory(MemoryResetRequest(target = "all")) } assertEquals("Memory (all) reset successfully", vm.uiState.value.toastMessage) // One load + one reload after reset. coVerify(exactly = 2) { mockApi.getMemory() } @@ -119,7 +121,7 @@ class MemoryViewModelTest { @Test fun `resetMemory failure shows error toast`() { - coEvery { mockApi.resetMemory(mapOf("target" to "memory")) } returns + coEvery { mockApi.resetMemory(MemoryResetRequest(target = "memory")) } returns Response.error(500, "boom".toResponseBody()) val vm = MemoryViewModel() vm.resetMemory("memory") @@ -128,4 +130,3 @@ class MemoryViewModelTest { assertTrue(vm.uiState.value.toastMessage.orEmpty().contains("Failed to reset memory")) assertNull(vm.uiState.value.resetting) } -} From ad838f696e80106f25953b1643d611d0ad79633a Mon Sep 17 00:00:00 2001 From: M57 Date: Mon, 31 Aug 2026 11:24:45 +0300 Subject: [PATCH 04/20] chore: add missing imports and fix test closing brace --- .../java/com/m57/hermescontrol/data/remote/HermesApiService.kt | 2 ++ .../com/m57/hermescontrol/ui/memory/MemoryViewModelTest.kt | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 7b92ec94..2f81b314 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -58,6 +58,8 @@ import com.m57.hermescontrol.data.model.MemoryProviderConfigUpdateRequest import com.m57.hermescontrol.data.model.MemoryProviderConfigUpdateResponse import com.m57.hermescontrol.data.model.MemoryProviderSetupRequest import com.m57.hermescontrol.data.model.MemoryProviderSetupResponse +import com.m57.hermescontrol.data.model.MemoryResetRequest +import com.m57.hermescontrol.data.model.MemoryResetResponse import com.m57.hermescontrol.data.model.MemoryResponse import com.m57.hermescontrol.data.model.MessagingPlatformResponse import com.m57.hermescontrol.data.model.MessagingPlatformTestResult 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 1c5dd148..15701bba 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 @@ -1,8 +1,8 @@ package com.m57.hermescontrol.ui.memory +import com.m57.hermescontrol.data.model.MemoryProviderStatusRow import com.m57.hermescontrol.data.model.MemoryResetRequest import com.m57.hermescontrol.data.model.MemoryResetResponse -import com.m57.hermescontrol.data.model.MemoryProviderStatusRow import com.m57.hermescontrol.data.model.MemoryResponse import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.HermesApiService @@ -130,3 +130,4 @@ class MemoryViewModelTest { assertTrue(vm.uiState.value.toastMessage.orEmpty().contains("Failed to reset memory")) assertNull(vm.uiState.value.resetting) } +} From 3911f5302a7dada71067dff1f8d3b5c80da3769b Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Mon, 31 Aug 2026 11:27:18 +0300 Subject: [PATCH 05/20] add ktlint to the nix flake --- flake.nix | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/flake.nix b/flake.nix index a4b5857e..7564f81b 100644 --- a/flake.nix +++ b/flake.nix @@ -66,6 +66,9 @@ # Android SDK (platforms, build-tools, platform-tools, emulator, system-images) androidSdk + + # Linting + ktlint ]; # Point everything at the Nix-managed SDK @@ -79,6 +82,8 @@ shellHook = '' echo " HermesControl Android dev shell" echo " Java: $(java -version 2>&1 | head -1)" + echo " Gradle: $(gradle --version 2>/dev/null | grep -m1 Gradle || echo "gradle $(gradle --version 2>&1 | head -1)")" + echo " ktlint: $(ktlint --version 2>/dev/null || echo "not found")" echo " ANDROID_HOME: $ANDROID_HOME" echo "" From cfe3e736fd4e90ca28ddf5d8d8e39a74ca152261 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:27:40 +0300 Subject: [PATCH 06/20] chore: upgrade ktlint to 1.8.0 and format codebase (#1024) Co-authored-by: M57 --- .github/workflows/android.yml | 4 +- AGENTS.md | 6 +- CONTRIBUTING.md | 2 +- README.md | 2 +- .../assistant/AssistantSession.kt | 4 +- .../m57/hermescontrol/data/model/Profile.kt | 15 +- .../data/remote/GatewayFileClient.kt | 4 +- .../hermescontrol/data/remote/LocalNetwork.kt | 20 +- .../data/update/AppUpdateState.kt | 16 +- .../hermescontrol/data/ws/HermesWsClient.kt | 9 +- .../hermescontrol/data/ws/JsonRpcModels.kt | 55 ++- .../com/m57/hermescontrol/theme/THEMES.md | 2 +- .../hermescontrol/ui/bots/BotsViewModel.kt | 133 ++++---- .../ui/bots/CreateGroupChatDialog.kt | 3 +- .../ui/bots/group/GroupChatViewModel.kt | 92 ++--- .../hermescontrol/ui/chat/ChatListEntries.kt | 10 +- .../ui/chat/ChatMediaDelegate.kt | 22 +- .../m57/hermescontrol/ui/chat/ChatScreen.kt | 13 +- .../hermescontrol/ui/chat/ChatViewModel.kt | 28 +- .../ui/chat/ChatWsEventReducer.kt | 23 +- .../m57/hermescontrol/ui/chat/MarkdownText.kt | 8 +- .../ui/chat/SlashCommandDispatcher.kt | 39 ++- .../ui/chat/components/ChatComposer.kt | 3 +- .../ui/chat/components/DiffViewCard.kt | 3 +- .../ui/chat/components/MessageCards.kt | 3 +- .../components/SubagentInspectionSheet.kt | 26 +- .../ui/chat/fullbleed/FullBleedTurns.kt | 49 ++- .../ui/chat/tool/ToolResultSummary.kt | 16 +- .../ui/chat/tool/ToolViewBuilder.kt | 317 +++++++++++++----- .../ui/common/ActionProgressDialog.kt | 12 +- .../m57/hermescontrol/ui/common/BotAvatar.kt | 13 +- .../hermescontrol/ui/common/NeurologyIcon.kt | 71 ++-- .../hermescontrol/ui/common/PressureBanner.kt | 15 +- .../hermescontrol/ui/config/ConfigFormData.kt | 6 +- .../hermescontrol/ui/config/ConfigScreen.kt | 5 +- .../ui/cron/CronJobsViewModel.kt | 18 +- .../ui/kanban/KanbanViewModel.kt | 49 ++- .../ui/mcp/McpServersViewModel.kt | 5 + .../ui/memory/MemoryViewModel.kt | 18 +- .../hermescontrol/ui/pairing/PairingScreen.kt | 5 +- .../ui/plugins/MemoryProviderDetailScreen.kt | 9 +- .../plugins/MemoryProviderDetailViewModel.kt | 33 +- .../settings/components/AppearanceSection.kt | 3 +- .../hermescontrol/ui/skills/SkillsScreen.kt | 4 +- .../ui/system/SystemViewModel.kt | 5 +- .../ui/toolsets/ToolsetDetailScreen.kt | 36 +- .../ui/toolsets/ToolsetDetailViewModel.kt | 28 +- .../data/ws/KanbanEventsClientTest.kt | 8 +- .../ui/bots/BotsViewModelTest.kt | 20 +- .../ui/bots/group/GroupChatViewModelTest.kt | 36 +- .../ui/chat/ChatInputPolicyTest.kt | 8 +- .../ui/chat/ChatToolDedupeTest.kt | 13 +- .../ui/chat/ChatUpdateCommandTest.kt | 7 +- .../ui/chat/ChatViewModelTest.kt | 155 ++++++--- .../ui/chat/ChatWsEventReducerTest.kt | 48 ++- .../ui/chat/SlashCommandDispatchRpcTest.kt | 11 +- .../ui/common/ActionProgressControllerTest.kt | 10 +- .../ui/cron/CronJobsViewModelTest.kt | 14 +- .../ui/kanban/KanbanViewModelTest.kt | 19 +- .../ui/memory/MemoryViewModelTest.kt | 16 +- .../ui/pairing/PairingViewModelTest.kt | 21 +- .../MemoryProviderDetailViewModelTest.kt | 20 +- .../ui/profiles/ProfilesViewModelTest.kt | 83 ++++- .../ui/sessions/SessionsViewModelTest.kt | 41 ++- .../ui/skills/SkillsViewModelTest.kt | 6 +- .../ui/toolsets/ToolsetDetailViewModelTest.kt | 30 +- 66 files changed, 1287 insertions(+), 541 deletions(-) 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()) } } } From b5c4ba50cd901add77be143e91ffb18112c64118 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:43:37 +0300 Subject: [PATCH 07/20] ci: smart path filtering to skip heavy Android builds on doc-only PRs (#1025) Co-authored-by: M57 --- .github/workflows/android.yml | 48 ++++++++++++++++++++++++++++++++--- .github/workflows/codeql.yml | 26 +++++++++++++++++++ 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 381300c0..8a792ae4 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -21,11 +21,43 @@ concurrency: cancel-in-progress: true jobs: + # ───────────────────────────────────────────────────────────────────── + # Job 0: Detect changes — skips heavy Android build/tests for doc-only PRs + # ───────────────────────────────────────────────────────────────────── + changes: + name: Detect Changes + runs-on: ubuntu-latest + timeout-minutes: 3 + outputs: + code: ${{ steps.filter.outputs.code }} + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Paths Filter + uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + code: + - 'app/**' + - 'gradle/**' + - 'build.gradle.kts' + - 'settings.gradle.kts' + - 'gradle.properties' + - 'gradlew' + - 'gradlew.bat' + - '.github/workflows/android.yml' + - '.editorconfig' + - 'flake.nix' + - 'flake.lock' + # ───────────────────────────────────────────────────────────────────── # Job 1: ktlint (code style) — fastest feedback, runs in parallel. # ───────────────────────────────────────────────────────────────────── ktlint: name: ktlint — Code Style + needs: [changes] + if: github.event_name != 'pull_request' || needs.changes.outputs.code == 'true' runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -88,6 +120,8 @@ jobs: # ───────────────────────────────────────────────────────────────────── android-lint: name: Android Lint — Static Analysis + needs: [changes] + if: github.event_name != 'pull_request' || needs.changes.outputs.code == 'true' runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -125,6 +159,8 @@ jobs: # ───────────────────────────────────────────────────────────────────── unit-tests: name: Unit & Integration Tests + needs: [changes] + if: github.event_name != 'pull_request' || needs.changes.outputs.code == 'true' runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -162,7 +198,8 @@ jobs: # ───────────────────────────────────────────────────────────────────── build: name: Build Debug APK - needs: [ktlint, android-lint, unit-tests] + needs: [changes, ktlint, android-lint, unit-tests] + if: github.event_name != 'pull_request' || needs.changes.outputs.code == 'true' runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -204,7 +241,8 @@ jobs: # ───────────────────────────────────────────────────────────────────── release-compile: name: Release Compile Check - needs: [ktlint, android-lint, unit-tests] + needs: [changes, ktlint, android-lint, unit-tests] + if: github.event_name != 'pull_request' || needs.changes.outputs.code == 'true' runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -236,6 +274,8 @@ jobs: # ───────────────────────────────────────────────────────────────────── instrumented-tests: name: Instrumented Tests + needs: [changes] + if: github.event_name != 'pull_request' || needs.changes.outputs.code == 'true' runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -299,7 +339,7 @@ jobs: # ───────────────────────────────────────────────────────────────────── ci-summary: name: CI Summary - needs: [ktlint, android-lint, unit-tests, build, release-compile] + needs: [changes, ktlint, android-lint, unit-tests, build, release-compile] runs-on: ubuntu-latest timeout-minutes: 2 if: always() @@ -323,7 +363,7 @@ jobs: name="${entry%%:*}" result="${entry#*:}" echo "::notice::$name = $result" - if [[ "$result" != "success" ]]; then + if [[ "$result" != "success" && "$result" != "skipped" ]]; then FAILED=1 FAILED_JOBS+="$name " fi diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index fd4acdd0..f1c19e7b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -3,8 +3,34 @@ name: "CodeQL Analysis" on: push: branches: [ "main", "dev" ] + paths-ignore: + - '**.md' + - 'docs/**' + - '.github/PULL_REQUEST_TEMPLATE.md' + - '.github/workflows/pr-labeler.yml' + - '.github/workflows/stale.yml' + - '.github/workflows/merge-conflict-detector.yml' + - 'LICENSE' + - 'fastlane/**' + - '.gitignore' + - '.editorconfig' + - '.envrc' + - '*.html' pull_request: branches: [ "main", "dev" ] + paths-ignore: + - '**.md' + - 'docs/**' + - '.github/PULL_REQUEST_TEMPLATE.md' + - '.github/workflows/pr-labeler.yml' + - '.github/workflows/stale.yml' + - '.github/workflows/merge-conflict-detector.yml' + - 'LICENSE' + - 'fastlane/**' + - '.gitignore' + - '.editorconfig' + - '.envrc' + - '*.html' schedule: - cron: '30 3 * * 1' # Runs at 03:30 UTC every Monday From 53c8eca714a89361aab4071b03da8c2b68d6f71f Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:54:09 +0300 Subject: [PATCH 08/20] feat: adopt side-questions via prompt.btw and /btw command in Chat (fixes #1015) (#1026) Co-authored-by: M57 --- .../m57/hermescontrol/data/ws/EventParser.kt | 7 + .../com/m57/hermescontrol/data/ws/WsEvent.kt | 12 + .../m57/hermescontrol/data/ws/WsMethods.kt | 1 + .../m57/hermescontrol/ui/chat/ChatScreen.kt | 8 + .../hermescontrol/ui/chat/ChatViewModel.kt | 92 +++++++ .../ui/chat/ChatWsEventReducer.kt | 33 +++ .../ui/chat/SlashCommandDispatcher.kt | 14 + .../ui/chat/components/ChatComposer.kt | 4 +- .../ui/chat/components/SideQuestionSheet.kt | 239 ++++++++++++++++++ .../ui/chat/ChatViewModelTest.kt | 34 +++ .../ui/chat/ChatWsEventReducerTest.kt | 37 +++ .../ui/chat/SlashCommandDispatcherTest.kt | 8 + 12 files changed, 487 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/SideQuestionSheet.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt index 453c8d58..e9287308 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt @@ -163,6 +163,13 @@ object EventParser { WsEvent.ReviewSummary(text, sessionId) } + "btw.complete" -> { + val taskId = payload?.get("task_id") as? String ?: "" + val question = payload?.get("question") as? String ?: "" + val text = (payload?.get("text") as? String)?.trim() ?: "" + WsEvent.BtwComplete(taskId, question, text, sessionId) + } + "session.updated" -> { WsEvent.SessionUpdated(payload) } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt index fd10205a..57558b90 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt @@ -150,6 +150,18 @@ sealed class WsEvent { val sessionId: String? = null, ) : WsEvent() + /** + * Context-aware side-question completion event (issue #1015). + * Emitted when `prompt.btw` completes answering a side question. + * Payload: `{ task_id: "...", question: "...", text: "..." }` + */ + data class BtwComplete( + val taskId: String = "", + val question: String = "", + val text: String = "", + val sessionId: String? = null, + ) : WsEvent() + // ── Status ─────────────────────────────────────────────────────────── data class StatusUpdate( diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt index 1401ac15..d6f4a3ea 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt @@ -25,6 +25,7 @@ object WsMethods { // ── Interaction ─────────────────────────────────────────────────────── const val PROMPT_SUBMIT = "prompt.submit" + const val PROMPT_BTW = "prompt.btw" const val CLARIFY_RESPOND = "clarify.respond" const val APPROVAL_RESPOND = "approval.respond" const val SUDO_RESPOND = "sudo.respond" 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 08f31bbe..364d14d2 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 @@ -100,6 +100,7 @@ import com.m57.hermescontrol.ui.chat.components.ContextUsageChip import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay import com.m57.hermescontrol.ui.chat.components.ReloginDialog import com.m57.hermescontrol.ui.chat.components.SearchBarRow +import com.m57.hermescontrol.ui.chat.components.SideQuestionSheet import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet import com.m57.hermescontrol.ui.chat.components.TaskProgressChip import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController @@ -933,6 +934,13 @@ fun ChatScreen( ) } + state.btwState?.let { btw -> + SideQuestionSheet( + state = btw, + onDismiss = { viewModel.dismissBtw() }, + ) + } + viewingImage?.let { image -> ImageViewerDialog( image = image, 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 74bde447..a167db16 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 @@ -353,6 +353,8 @@ data class ChatUiState( val reactionKind: String? = null, /** Monotonic trigger ID so consecutive same-kind reactions re-animate. */ val reactionTriggerId: Long = 0L, + /** Side-question state for /btw (issue #1015). */ + val btwState: BtwUiState? = null, /** Subagent delegation indicators (issue #538) — transient UI state. */ val subagentIndicators: List = emptyList(), /** Agent todo / plan items (issue #736). */ @@ -379,6 +381,17 @@ data class ClarifyUi( val clarifyId: String? = null, ) +/** + * State for the context-aware side-question bottom sheet (issue #1015, `/btw`). + */ +data class BtwUiState( + val taskId: String? = null, + val question: String = "", + val answer: String? = null, + val isLoading: Boolean = true, + val error: String? = null, +) + /** * String sent to the agent when a clarify prompt is dismissed (the Dismiss * button). This is a *reject* — "I'm not answering this question" — NOT an @@ -1546,6 +1559,12 @@ class ChatViewModel( // it matches the server echo and the transcript sync dedupes instead // of rendering a duplicate below its answer. val result = slashDispatcher.dispatch(command) + + if (result is SlashResult.SideQuestion) { + handleSideQuestionCommand(result.question) + return + } + val displayContent = if (result is SlashResult.QueuePrompt) result.displayContent else command val userMsg = ChatMessage(role = MessageRole.USER, content = displayContent) @@ -1627,6 +1646,10 @@ class ChatViewModel( handleQueueCommand(command) } + is SlashResult.SideQuestion -> { + handleSideQuestionCommand(result.question) + } + is SlashResult.RpcDispatch -> { dispatchViaRpc(command) } @@ -1651,6 +1674,75 @@ class ChatViewModel( submitPrompt(arg, queued = true) } + // ── Side Questions via /btw (issue #1015) ───────────────────────────── + + fun dismissBtw() { + _uiState.update { it.copy(btwState = null) } + } + + fun submitSideQuestion(question: String) { + val trimmed = question.trim() + if (trimmed.isBlank()) { + addAssistantMessage( + "Usage: `/btw ` — Ask a side question about this session without mutating its history.", + ) + return + } + val sessionId = runtimeSessionId ?: _uiState.value.currentSessionId + if (sessionId.isNullOrBlank()) { + addAssistantMessage("No active session for side questions. Start a chat first.") + return + } + + _uiState.update { + it.copy( + btwState = + BtwUiState( + question = trimmed, + isLoading = true, + ), + ) + } + + viewModelScope.launch(ioDispatcher) { + try { + val rpcResult = + wsClient + .request( + WsMethods.PROMPT_BTW, + mapOf("session_id" to sessionId, "text" to trimmed), + ).await() + val taskId = (rpcResult as? Map<*, *>)?.get("task_id") as? String + if (!taskId.isNullOrBlank()) { + _uiState.update { state -> + state.btwState?.let { current -> + state.copy(btwState = current.copy(taskId = taskId)) + } ?: state + } + } + } catch (e: Exception) { + _uiState.update { state -> + state.btwState?.let { current -> + state.copy( + btwState = + current.copy( + isLoading = false, + error = e.message ?: "Failed to dispatch side question", + ), + ) + } ?: state + } + } + } + } + + private fun handleSideQuestionCommand(question: String) { + viewModelScope.launch { + slashUsageStore.recordUse("btw") + } + submitSideQuestion(question) + } + // ── Update from chat (issue #862) ──────────────────────────────────── // `/update` can't travel via the slash worker (the backend handler is // interactive + session-exiting → guaranteed 45s timeout). Intercept it 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 7fe01143..acf51088 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 @@ -71,6 +71,7 @@ object ChatWsEventReducer { is WsEvent.ToolGenerating -> event.sessionId is WsEvent.SubagentEvent -> event.sessionId is WsEvent.ReviewSummary -> event.sessionId + is WsEvent.BtwComplete -> event.sessionId is WsEvent.SessionUsage -> event.sessionId else -> null } @@ -129,6 +130,8 @@ object ChatWsEventReducer { is WsEvent.ReviewSummary -> onReviewSummary(state, streamingState, event) + is WsEvent.BtwComplete -> onBtwComplete(state, streamingState, event) + is WsEvent.SessionUsage -> onSessionUsage(state, streamingState, event) is WsEvent.RpcError -> onRpcError(state, streamingState, event) @@ -652,6 +655,36 @@ object ChatWsEventReducer { ) } + // ── BtwComplete (Side question completed) ───────────────────────────── + + private fun onBtwComplete( + state: ChatUiState, + streamingState: StreamingState, + event: WsEvent.BtwComplete, + ): ReducerResult { + val currentBtw = state.btwState + val updatedBtw = + if (currentBtw != null) { + currentBtw.copy( + taskId = event.taskId.ifBlank { currentBtw.taskId }, + question = event.question.ifBlank { currentBtw.question }, + answer = event.text, + isLoading = false, + ) + } else { + BtwUiState( + taskId = event.taskId, + question = event.question, + answer = event.text, + isLoading = false, + ) + } + return ReducerResult( + state = state.copy(btwState = updatedBtw), + streamingState = streamingState, + ) + } + // ── RpcError ────────────────────────────────────────────────────── private fun onRpcError( 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 0117879e..8cef91ab 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 @@ -61,6 +61,11 @@ class SlashCommandDispatcher { SlashResult.OpenHistory } + "/btw" -> { + val arg = command.split(" ", limit = 2).getOrElse(1) { "" }.trim() + SlashResult.SideQuestion(question = arg) + } + else -> { SlashResult.RpcDispatch } @@ -129,4 +134,13 @@ sealed class SlashResult { data class QueuePrompt( val displayContent: String, ) : SlashResult() + + /** + * Context-aware side question about the current session (issue #1015). + * Dispatched via the `prompt.btw` RPC. Answers without mutating the + * session's history or invalidating prompt caching. + */ + data class SideQuestion( + val question: 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 891b93ee..e018f609 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 @@ -136,8 +136,8 @@ fun ChatInputBar( // also enforced at dispatch time (issue #576, deliverable #3). val hiddenSlashDisplay = CommandBlocklist.UNSUPPORTED val commandNames = - commandCatalog.pairs - .map { it[0] } + (commandCatalog.pairs.map { it[0] } + listOf("/btw", "/queue", "/fork", "/model", "/new", "/stop")) + .distinct() .filter { it.lowercase() !in hiddenSlashDisplay } androidx.compose.animation.AnimatedVisibility( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SideQuestionSheet.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SideQuestionSheet.kt new file mode 100644 index 00000000..606dc07a --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SideQuestionSheet.kt @@ -0,0 +1,239 @@ +package com.m57.hermescontrol.ui.chat.components + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.HelpOutline +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.m57.hermescontrol.ui.chat.BtwUiState +import com.m57.hermescontrol.ui.chat.MarkdownText + +/** + * Bottom sheet displaying a context-aware side question and its answer (issue #1015, `/btw`). + * Answers from a conversation snapshot without mutating the chat transcript. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SideQuestionSheet( + state: BtwUiState, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val context = LocalContext.current + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + modifier = modifier.testTag("side_question_sheet"), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 24.dp) + .verticalScroll(rememberScrollState()), + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.AutoMirrored.Filled.HelpOutline, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Side Question", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + } + IconButton( + onClick = onDismiss, + modifier = Modifier.size(32.dp), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Question Banner + if (state.question.isNotBlank()) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = "Question", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = state.question, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Spacer(modifier = Modifier.height(12.dp)) + } + + // Loading state + if (state.isLoading) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + LinearProgressIndicator( + modifier = + Modifier + .fillMaxWidth() + .height(4.dp), + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Answering from conversation snapshot...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Error state + if (!state.error.isNullOrBlank()) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.7f), + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.ErrorOutline, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = state.error, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + } + } + + // Answer state + val answer = state.answer + if (!answer.isNullOrBlank()) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface, + border = + androidx.compose.foundation.BorderStroke( + 1.dp, + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ), + ) { + Column(modifier = Modifier.padding(14.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "Answer", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + IconButton( + onClick = { + val clipboard = + context.getSystemService( + Context.CLIPBOARD_SERVICE, + ) as? ClipboardManager + clipboard?.setPrimaryClip(ClipData.newPlainText("Side Question Answer", answer)) + Toast.makeText(context, "Copied answer", Toast.LENGTH_SHORT).show() + }, + modifier = Modifier.size(24.dp), + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = "Copy answer", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + MarkdownText( + text = answer, + textColor = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + } + } +} 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 6494f7e5..27e20089 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 @@ -478,6 +478,40 @@ class ChatViewModelTest { verify { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } } + @Test + fun testSlashCommand_btw_dispatchesPromptBtw_andSetsBtwState() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + + every { + HermesWsClient.request(WsMethods.PROMPT_BTW, any(), any()) + } returns + CompletableDeferred( + mapOf("task_id" to "btw_123456"), + ) + + viewModel.sendMessage("/btw which file was that in?") + advanceUntilIdle() + + val btw = viewModel.uiState.value.btwState + assertNotNull(btw) + assertEquals("which file was that in?", btw?.question) + assertEquals("btw_123456", btw?.taskId) + assertTrue(btw?.isLoading == true) + verify { + HermesWsClient.request( + WsMethods.PROMPT_BTW, + mapOf("session_id" to sessionId, "text" to "which file was that in?"), + any(), + ) + } + // Ensure transcript messages were NOT polluted with the side question + assertTrue( + viewModel.uiState.value.messages + .none { it.content.contains("which file was that in?") }, + ) + } + // ── Slash usage ranking (issue #865) ──────────────────────────────────── @Test 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 848bee17..da930fb0 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 @@ -535,6 +535,43 @@ class ChatWsEventReducerTest { assertEquals("💾 Self-improvement review: Skill 'android-ci' patched", msg.content) } + @Test + fun testBtwComplete_updatesBtwState() { + val state = + ChatUiState( + currentSessionId = "session-1", + btwState = + BtwUiState( + question = "what model?", + isLoading = true, + ), + ) + val event = + WsEvent.BtwComplete( + taskId = "btw_123456", + question = "what model?", + text = "This conversation uses Gemini 3.7.", + sessionId = "session-1", + ) + + val result = + ChatWsEventReducer.reduce( + state = state, + streamingState = StreamingState(), + event = event, + currentSessionId = "session-1", + ) + + val btw = result.state.btwState + org.junit.Assert.assertNotNull(btw) + assertEquals("btw_123456", btw?.taskId) + assertEquals("what model?", btw?.question) + assertEquals("This conversation uses Gemini 3.7.", btw?.answer) + assertEquals(false, btw?.isLoading) + // Zero messages appended to transcript! + assertEquals(0, result.state.messages.size) + } + // ── Issue #771: reasoning survives a tool call (regression) ──────────── // // Real gateway capture for `run echo hi` (reasoning model): diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt index 6c72abe9..6f5db563 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt @@ -74,6 +74,14 @@ class SlashCommandDispatcherTest { assertEquals(SlashResult.OpenHistory, dispatcher.dispatch("/History")) } + @Test + fun `btw routes to SideQuestion`() { + assertEquals(SlashResult.SideQuestion("what model is this?"), dispatcher.dispatch("/btw what model is this?")) + assertEquals(SlashResult.SideQuestion("what model is this?"), dispatcher.dispatch("/BTW what model is this?")) + assertEquals(SlashResult.SideQuestion(""), dispatcher.dispatch("/btw")) + assertEquals(SlashResult.SideQuestion(""), dispatcher.dispatch("/btw ")) + } + @Test fun `NEW uppercase still routes to NewSession`() { // Dispatcher lower-cases before matching, so case must not matter. From 3fd9ac767f73b663da06b3c37b27febfbef40348 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:32:17 +0300 Subject: [PATCH 09/20] feat(ws): adopt event replay via session.events.since on reconnect (#1016) (#1027) Co-authored-by: M57 --- .../m57/hermescontrol/data/ws/EventParser.kt | 11 + .../hermescontrol/data/ws/HermesWsClient.kt | 198 ++++++++++++++++++ .../m57/hermescontrol/data/ws/WsMethods.kt | 6 + .../hermescontrol/data/ws/EventParserTest.kt | 32 +++ .../data/ws/HermesWsClientTest.kt | 161 ++++++++++++++ 5 files changed, 408 insertions(+) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt index e9287308..a95da949 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt @@ -31,6 +31,17 @@ object EventParser { // ── Notification / event (no id, has method) ───────────────────── @Suppress("UNCHECKED_CAST") val params = response.params?.toAny() as? Map ?: return WsEvent.Unknown(rawJson) + return parseParams(params, rawJson) + } + + /** + * Parses an event object (bare params map containing `type`, `session_id`, `seq`, `payload`). + * Used both for live notifications and for event lists returned by `session.events.since`. + */ + fun parseParams( + params: Map, + rawJson: String = "", + ): WsEvent { val eventType = params["type"] as? String ?: return WsEvent.Unknown(rawJson) @Suppress("UNCHECKED_CAST") 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 25af9ec2..335ddaa5 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 @@ -27,12 +27,14 @@ import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.launch import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.JsonElement import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener import okio.utf8Size +import java.util.Collections import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.atomic.AtomicBoolean @@ -128,6 +130,19 @@ object HermesWsClient { @Volatile private var outboundDrainJob: Job? = null + // ── Sequence tracking & replay on reconnect (issue #1016) ───────────── + private val lastSeenSeq = ConcurrentHashMap() + + @Volatile + private var replayEpoch: String? = null + + @Volatile + private var replayInFlight: Boolean = false + private val replayHold = ConcurrentHashMap>>() + + @Volatile + private var replayJob: Job? = null + // ── Health and Ping/Pong tracking ──────────────────────────────────── @Volatile @@ -277,6 +292,20 @@ object HermesWsClient { .filterIsInstance() .collect { ChangeEventHub.emit(it) } } + // Track gateway replay_epoch across gateway.ready events (issue #1016) + wsScope.launch { + events.collect { event -> + if (event is WsEvent.GatewayReady) { + val epoch = event.data?.get("replay_epoch") as? String + if (!epoch.isNullOrEmpty()) { + if (replayEpoch != null && replayEpoch != epoch) { + lastSeenSeq.clear() + } + replayEpoch = epoch + } + } + } + } } // ── Connection helpers ──────────────────────────────────────────────── @@ -576,6 +605,12 @@ object HermesWsClient { queuedMessagesById.clear() pendingPromptSubmits.clear() pendingReply = false + lastSeenSeq.clear() + replayHold.clear() + replayEpoch = null + replayJob?.cancel() + replayJob = null + replayInFlight = false } connected.set(false) _connectionStatus.value = ConnectionStatus.DISCONNECTED @@ -819,6 +854,135 @@ object HermesWsClient { onSent = onSent, ) + // ── Sequence tracking & replay on reconnect (issue #1016) ───────────── + + private fun triggerReplay() { + if (lastSeenSeq.isEmpty() || replayInFlight) return + replayJob?.cancel() + replayJob = + wsScope.launch { + fetchReplay() + } + } + + private suspend fun fetchReplay() { + if (replayInFlight || lastSeenSeq.isEmpty()) return + replayInFlight = true + val sessionsToReplay = lastSeenSeq.keys().toList() + for (sid in sessionsToReplay) { + replayHold[sid] = Collections.synchronizedList(mutableListOf()) + } + try { + for (sid in sessionsToReplay) { + val lastSeen = lastSeenSeq[sid] ?: continue + try { + val deferred = + request( + method = WsMethods.SESSION_EVENTS_SINCE, + params = mapOf("session_id" to sid, "last_seen" to lastSeen, "since_seq" to lastSeen), + timeoutMs = 10_000L, + ) + val result = deferred.await() + + @Suppress("UNCHECKED_CAST") + val resultMap = + when (result) { + is JsonElement -> result.toAny() as? Map + is Map<*, *> -> result as? Map + else -> null + } + if (resultMap != null) { + val epoch = resultMap["epoch"] as? String + if (!epoch.isNullOrEmpty()) { + if (replayEpoch != null && replayEpoch != epoch) { + replayEpoch = epoch + lastSeenSeq.clear() + continue + } + replayEpoch = epoch + } + val eventsList = (resultMap["events"] as? List<*>)?.filterIsInstance>() + if (eventsList != null) { + for (eventMap in eventsList) { + val eventSeq = (eventMap["seq"] as? Number)?.toInt() + val eventSid = (eventMap["session_id"] as? String) ?: sid + if (eventSeq != null && eventSid.isNotEmpty()) { + val prev = lastSeenSeq[eventSid] ?: 0 + if (eventSeq <= prev) continue + lastSeenSeq[eventSid] = eventSeq + } + val parsedEvent = EventParser.parseParams(eventMap) + val finalEvent = + if (parsedEvent is WsEvent.MessageComplete) { + parsedEvent.copy( + storedSessionId = + ActiveSessionHolder.resolveStoredSessionId( + parsedEvent.sessionId, + ), + ) + } else { + parsedEvent + } + parsedEvents.tryEmit(finalEvent) + } + } + } + } catch (e: Exception) { + Log.w(TAG, "Replay failed for session $sid: ${e.message}") + } + } + } finally { + for (sid in sessionsToReplay) { + val held = replayHold.remove(sid) + if (held != null) { + synchronized(held) { + for ((heldSeq, heldEvent) in held) { + if (heldSeq != null) { + val prev = lastSeenSeq[sid] ?: 0 + if (heldSeq <= prev) continue + lastSeenSeq[sid] = heldSeq + } + parsedEvents.tryEmit(heldEvent) + } + } + } + } + replayInFlight = false + } + } + + @VisibleForTesting + internal fun getSeqWatermarks(): Map = HashMap(lastSeenSeq) + + @VisibleForTesting + internal fun setSeqWatermark( + sessionId: String, + seq: Int, + ) { + lastSeenSeq[sessionId] = seq + } + + @VisibleForTesting + internal fun clearSeqWatermarks() { + lastSeenSeq.clear() + replayHold.clear() + replayEpoch = null + replayInFlight = false + } + + @VisibleForTesting + internal fun isReplayInFlight(): Boolean = replayInFlight + + @VisibleForTesting + internal fun setReplayEpochForTest(epoch: String?) { + replayEpoch = epoch + } + + @VisibleForTesting + internal suspend fun fetchReplayForTest() { + fetchReplay() + } + // ── Internal ───────────────────────────────────────────────────────── private fun openSocket() { @@ -937,6 +1101,7 @@ object HermesWsClient { messageQueue.poll() markQueuedMessageSent(msg) } + triggerReplay() } disconnectIfIdleInBackground() } @@ -963,6 +1128,39 @@ object HermesWsClient { removeQueuedMessage(rpcId) resolvePending(rpcId, rpc.result, null) } + + if (rpc.id == null) { + @Suppress("UNCHECKED_CAST") + val params = rpc.params?.toAny() as? Map + val sid = + (params?.get("session_id") as? String) + ?: ((params?.get("payload") as? Map<*, *>)?.get("session_id") as? String) + val seq = (params?.get("seq") as? Number)?.toInt() + + if (!sid.isNullOrBlank() && seq != null) { + val prev = lastSeenSeq[sid] ?: 0 + if (seq <= prev) { + return + } + val parsed = EventParser.parse(rpc, text) + val finalEvent = + if (parsed is WsEvent.MessageComplete) { + parsed.copy( + storedSessionId = ActiveSessionHolder.resolveStoredSessionId(parsed.sessionId), + ) + } else { + parsed + } + if (replayInFlight && replayHold.containsKey(sid)) { + replayHold[sid]?.add(seq to finalEvent) + return + } + lastSeenSeq[sid] = seq + parsedEvents.tryEmit(finalEvent) + return + } + } + val parsed = EventParser.parse(rpc, text) if (parsed is WsEvent.MessageComplete) { parsed.copy( diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt index d6f4a3ea..92398aaf 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt @@ -15,6 +15,12 @@ object WsMethods { const val SESSION_TITLE = "session.title" const val SESSION_BRANCH = "session.branch" + /** Replay recorded events newer than client's last-seen seq for a session. */ + const val SESSION_EVENTS_SINCE = "session.events.since" + + /** Replay buffer telemetry for ops/debug. */ + const val SESSION_EVENTS_STATS = "session.events.stats" + /** Live context-window occupancy for the chat meter (desktop-mirror). */ const val SESSION_CONTEXT_BREAKDOWN = "session.context_breakdown" diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt index 0fe7305d..5b152e34 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt @@ -668,4 +668,36 @@ class EventParserTest { assertEquals(3, (usage?.get("compressions") as? Number)?.toInt()) assertEquals(12500, (usage?.get("context_used") as? Number)?.toInt()) } + + @Test + fun testParseParams_parsesBareEventMap() { + val params = + mapOf( + "type" to "message.token", + "session_id" to "sess-replay-1", + "seq" to 42, + "payload" to mapOf("text" to "hello replay"), + ) + val event = EventParser.parseParams(params) + assertTrue(event is WsEvent.MessageToken) + val tokenEvent = event as WsEvent.MessageToken + assertEquals("hello replay", tokenEvent.token) + assertEquals("sess-replay-1", tokenEvent.sessionId) + } + + @Test + fun testParseParams_parsesToolEvent() { + val params = + mapOf( + "type" to "tool.start", + "session_id" to "sess-replay-2", + "seq" to 43, + "payload" to mapOf("name" to "terminal", "tool_id" to "t1"), + ) + val event = EventParser.parseParams(params) + assertTrue(event is WsEvent.ToolStart) + val toolEvent = event as WsEvent.ToolStart + assertEquals("terminal", toolEvent.name) + assertEquals("sess-replay-2", toolEvent.sessionId) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt index 0abb0472..742da1f2 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt @@ -1527,4 +1527,165 @@ class HermesWsClientTest { HermesWsClient.connectionStatus.value != ConnectionStatus.CONNECTED, ) } + + @Test + fun testSequenceNumberDeduplication() { + var serverWebSocket: WebSocket? = null + val serverLatch = CountDownLatch(1) + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: okhttp3.Response, + ) { + serverWebSocket = webSocket + serverLatch.countDown() + } + }, + ), + ) + + HermesWsClient.connect() + runBlocking { + withTimeout(5000) { + HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } + } + } + assertTrue(serverLatch.await(5, TimeUnit.SECONDS)) + val ws = serverWebSocket + assertNotNull(ws) + + val receivedTokens = mutableListOf() + val collectorJob = + kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { + HermesWsClient.events.collect { event -> + if (event is WsEvent.MessageToken) { + receivedTokens.add(event.token) + } + } + } + + // Send seq 1 + ws!!.send( + """{"method":"event","params":{"type":"message.token","session_id":"s1","seq":1,"payload":{"text":"A"}}}""", + ) + Thread.sleep(100) + assertEquals(1, HermesWsClient.getSeqWatermarks()["s1"]) + assertEquals(listOf("A"), receivedTokens) + + // Send duplicate seq 1 -> should be dropped + ws.send( + """{"method":"event","params":{"type":"message.token","session_id":"s1","seq":1,"payload":{"text":"A-dup"}}}""", + ) + Thread.sleep(100) + assertEquals(1, HermesWsClient.getSeqWatermarks()["s1"]) + assertEquals(listOf("A"), receivedTokens) + + // Send seq 2 -> should be accepted + ws.send( + """{"method":"event","params":{"type":"message.token","session_id":"s1","seq":2,"payload":{"text":"B"}}}""", + ) + Thread.sleep(100) + assertEquals(2, HermesWsClient.getSeqWatermarks()["s1"]) + assertEquals(listOf("A", "B"), receivedTokens) + + collectorJob.cancel() + } + + @Test + fun testEpochChangeClearsWatermarks() { + var serverWebSocket: WebSocket? = null + val serverLatch = CountDownLatch(1) + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: okhttp3.Response, + ) { + serverWebSocket = webSocket + serverLatch.countDown() + } + }, + ), + ) + + HermesWsClient.connect() + runBlocking { + withTimeout(5000) { + HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } + } + } + assertTrue(serverLatch.await(5, TimeUnit.SECONDS)) + val ws = serverWebSocket + assertNotNull(ws) + + // Initial gateway.ready with epoch-1 + ws!!.send( + """{"method":"event","params":{"type":"gateway.ready","payload":{"replay_epoch":"epoch-1"}}}""", + ) + Thread.sleep(100) + HermesWsClient.setSeqWatermark("s1", 10) + assertEquals(10, HermesWsClient.getSeqWatermarks()["s1"]) + + // Second gateway.ready with new epoch-2 -> backend restarted, watermarks cleared + ws.send( + """{"method":"event","params":{"type":"gateway.ready","payload":{"replay_epoch":"epoch-2"}}}""", + ) + Thread.sleep(100) + assertTrue(HermesWsClient.getSeqWatermarks().isEmpty()) + } + + @Test + fun testReplayOnReconnectFlow() { + var serverWebSocket: WebSocket? = null + val serverLatch = CountDownLatch(1) + val requestLatch = CountDownLatch(1) + var receivedMethod: String? = null + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: okhttp3.Response, + ) { + serverWebSocket = webSocket + serverLatch.countDown() + } + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + if (text.contains(WsMethods.SESSION_EVENTS_SINCE)) { + receivedMethod = WsMethods.SESSION_EVENTS_SINCE + // Extract ID from JSON-RPC request + val id = Regex(""""id":"([^"]+)"""").find(text)?.groupValues?.get(1) ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":{"epoch":"ep1","events":[{"type":"message.token","session_id":"s1","seq":6,"payload":{"text":"replayed"}}]}}""", + ) + requestLatch.countDown() + } + } + }, + ), + ) + + HermesWsClient.setSeqWatermark("s1", 5) + HermesWsClient.connect() + runBlocking { + withTimeout(5000) { + HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } + } + } + assertTrue(serverLatch.await(5, TimeUnit.SECONDS)) + assertTrue(requestLatch.await(5, TimeUnit.SECONDS)) + assertEquals(WsMethods.SESSION_EVENTS_SINCE, receivedMethod) + + // Wait for replay processing + Thread.sleep(200) + assertEquals(6, HermesWsClient.getSeqWatermarks()["s1"]) + } } From ceb48132181348a9f752db5ce9d26b54fb305bb7 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:09:15 +0300 Subject: [PATCH 10/20] feat(chat): support hierarchical nested subtasks in Todos via parent property (#1018) (#1028) Co-authored-by: M57 --- .../m57/hermescontrol/data/ws/EventParser.kt | 10 + .../com/m57/hermescontrol/data/ws/WsEvent.kt | 13 ++ .../m57/hermescontrol/ui/chat/ChatMessage.kt | 2 + .../ui/chat/ChatWsEventReducer.kt | 22 +- .../components/SubagentInspectionSheet.kt | 208 ++++++++++++++++-- .../ui/chat/tool/ToolViewBuilder.kt | 4 +- .../hermescontrol/data/ws/EventParserTest.kt | 46 ++++ .../ui/chat/ChatWsEventReducerTest.kt | 84 +++++++ .../ui/chat/TaskProgressChipTest.kt | 71 ++++++ 9 files changed, 443 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt index a95da949..c85fe7ea 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt @@ -1,6 +1,7 @@ package com.m57.hermescontrol.data.ws import android.util.Log +import com.m57.hermescontrol.ui.chat.extractTodosFromMap /** * Converts raw [JsonRpcResponse] objects into typed [WsEvent] instances. @@ -189,6 +190,15 @@ object EventParser { WsEvent.SessionUsage(payload, sessionId) } + "todo.updated" -> { + val revision = + (payload?.get("revision") as? Number)?.toInt() + ?: (params["revision"] as? Number)?.toInt() + val rawTodos = payload ?: params + val todos = extractTodosFromMap(rawTodos) ?: emptyList() + WsEvent.TodoUpdated(todos, revision, sessionId) + } + "reaction" -> { val kind = payload?.get("kind") as? String ?: "" WsEvent.ReactionEvent(kind) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt index 57558b90..4b0a4ab3 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt @@ -1,5 +1,7 @@ package com.m57.hermescontrol.data.ws +import com.m57.hermescontrol.ui.chat.TodoItem + /** Parsed WebSocket events emitted by [HermesWsClient]. */ sealed class WsEvent { // ── Gateway lifecycle ──────────────────────────────────────────────── @@ -183,6 +185,17 @@ sealed class WsEvent { val sessionId: String? = null, ) : WsEvent() + /** + * Revisioned todo snapshot event emitted by backend todo tool updates (issue #1018). + * Event: `todo.updated` + * Payload: `{ "todos": [...], "revision": Int }` + */ + data class TodoUpdated( + val todos: List = emptyList(), + val revision: Int? = null, + val sessionId: String? = null, + ) : WsEvent() + // ── RPC responses ──────────────────────────────────────────────────── data class RpcResult( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt index 21577ca1..17e3bbe5 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt @@ -85,11 +85,13 @@ data class TodoItem( val id: String, val content: String, val status: String = "pending", // "pending" | "in_progress" | "completed" | "cancelled" + val parent: String? = null, ) { val isCompleted: Boolean get() = status == "completed" || status == "done" val isInProgress: Boolean get() = status == "in_progress" || status == "running" val isCancelled: Boolean get() = status == "cancelled" || status == "failed" val isPending: Boolean get() = status == "pending" || status == "queued" + val isSubtask: Boolean get() = !parent.isNullOrBlank() } /** 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 acf51088..1ce569d0 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 @@ -134,6 +134,8 @@ object ChatWsEventReducer { is WsEvent.SessionUsage -> onSessionUsage(state, streamingState, event) + is WsEvent.TodoUpdated -> onTodoUpdated(state, streamingState, event) + is WsEvent.RpcError -> onRpcError(state, streamingState, event) is WsEvent.GatewayError -> onGatewayError(state, streamingState, event) @@ -870,6 +872,20 @@ object ChatWsEventReducer { ) } + private fun onTodoUpdated( + state: ChatUiState, + streamingState: StreamingState, + event: WsEvent.TodoUpdated, + ): ReducerResult { + if (event.sessionId != null && state.currentSessionId != null && event.sessionId != state.currentSessionId) { + return ReducerResult(state = state, streamingState = streamingState) + } + return ReducerResult( + state = state.copy(todos = event.todos), + streamingState = streamingState, + ) + } + private fun onSessionUsage( state: ChatUiState, streamingState: StreamingState, @@ -957,7 +973,8 @@ fun extractTodosFromMap(data: Map?): List? { val id = (map["id"] as? String) ?: continue val content = (map["content"] as? String) ?: (map["text"] as? String) ?: "" val status = (map["status"] as? String) ?: "pending" - items.add(TodoItem(id = id, content = content, status = status)) + val parent = (map["parent"] as? String)?.takeIf { it.isNotBlank() } + items.add(TodoItem(id = id, content = content, status = status, parent = parent)) } return items.takeIf { it.isNotEmpty() } } @@ -982,7 +999,8 @@ fun extractTodosFromJson(content: String): List? { (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) + val parent = (itemObj["parent"] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() } + TodoItem(id = id, content = contentStr, status = status, parent = parent) }.takeIf { it.isNotEmpty() } } catch (_: Exception) { null 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 a81658cc..ec0e8c7c 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 @@ -1,6 +1,7 @@ package com.m57.hermescontrol.ui.chat.components import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -11,6 +12,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -19,6 +21,8 @@ import androidx.compose.material.icons.filled.Autorenew import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Groups import androidx.compose.material.icons.filled.RadioButtonUnchecked import androidx.compose.material3.ExperimentalMaterial3Api @@ -30,6 +34,10 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -57,6 +65,9 @@ fun SubagentInspectionSheet( modifier: Modifier = Modifier, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val todoTree = remember(todos) { buildTodoTree(todos) } + var collapsedParentIds by remember { mutableStateOf(setOf()) } + val displayRows = remember(todoTree, collapsedParentIds) { flattenTodoTree(todoTree, collapsedParentIds) } ModalBottomSheet( onDismissRequest = onDismiss, @@ -111,9 +122,9 @@ fun SubagentInspectionSheet( } else { LazyColumn( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - if (todos.isNotEmpty()) { + if (displayRows.isNotEmpty()) { item(key = "todos_header") { Row( verticalAlignment = Alignment.CenterVertically, @@ -134,11 +145,26 @@ fun SubagentInspectionSheet( ) } } - itemsIndexed( - items = todos, - key = { index, todo -> "todo-${todo.id}_$index" }, - ) { _, todo -> - TodoInspectionCard(todo = todo) + items( + items = displayRows, + key = { row -> "todo-${row.todo.id}_${row.depth}" }, + ) { row -> + TodoInspectionCard( + row = row, + onToggleExpand = + if (row.hasChildren) { + { + collapsedParentIds = + if (row.todo.id in collapsedParentIds) { + collapsedParentIds - row.todo.id + } else { + collapsedParentIds + row.todo.id + } + } + } else { + null + }, + ) } } @@ -180,12 +206,128 @@ fun SubagentInspectionSheet( } } +/** + * Node in the hierarchical todo tree. + */ +data class TodoNode( + val item: TodoItem, + val children: List = emptyList(), + val depth: Int = 0, +) + +/** + * Flattened row representation ready for lazy column rendering with indentation and collapse state. + */ +data class TodoDisplayRow( + val todo: TodoItem, + val depth: Int, + val hasChildren: Boolean, + val isExpanded: Boolean, + val completedSubtasksCount: Int, + val totalSubtasksCount: Int, +) + +/** + * Constructs a hierarchical tree from a flat list of [TodoItem]s using the [TodoItem.parent] property. + */ +fun buildTodoTree(todos: List): List { + val itemsById = todos.associateBy { it.id } + val childrenByParent = mutableMapOf>() + val rootItems = mutableListOf() + + for (item in todos) { + val p = item.parent + if (p != null && itemsById.containsKey(p) && p != item.id) { + childrenByParent.getOrPut(p) { mutableListOf() }.add(item) + } else { + rootItems.add(item) + } + } + + fun buildNode( + item: TodoItem, + depth: Int, + ): TodoNode { + val children = childrenByParent[item.id]?.map { buildNode(it, depth + 1) } ?: emptyList() + return TodoNode(item = item, children = children, depth = depth) + } + + return rootItems.map { buildNode(it, 0) } +} + +fun getAllDescendantTodos(node: TodoNode): List { + val descendants = mutableListOf() + for (child in node.children) { + descendants.add(child.item) + descendants.addAll(getAllDescendantTodos(child)) + } + return descendants +} + +fun flattenTodoTree( + nodes: List, + collapsedParentIds: Set, +): List { + val result = mutableListOf() + + fun traverse(node: TodoNode) { + val isExpanded = node.item.id !in collapsedParentIds + val descendants = getAllDescendantTodos(node) + val completedCount = descendants.count { it.isCompleted } + val totalCount = descendants.size + + result.add( + TodoDisplayRow( + todo = node.item, + depth = node.depth, + hasChildren = node.children.isNotEmpty(), + isExpanded = isExpanded, + completedSubtasksCount = completedCount, + totalSubtasksCount = totalCount, + ), + ) + if (node.children.isNotEmpty() && isExpanded) { + for (child in node.children) { + traverse(child) + } + } + } + + for (root in nodes) { + traverse(root) + } + return result +} + @Composable -private fun TodoInspectionCard(todo: TodoItem) { +private fun TodoInspectionCard( + row: TodoDisplayRow, + onToggleExpand: (() -> Unit)? = null, + modifier: Modifier = Modifier, +) { + val todo = row.todo + val depth = row.depth + val startPadding = (depth * 20).coerceAtMost(60).dp + Surface( - modifier = Modifier.fillMaxWidth(), + modifier = + modifier + .fillMaxWidth() + .padding(start = startPadding) + .then( + if (onToggleExpand != null) { + Modifier.clickable(onClick = onToggleExpand) + } else { + Modifier + }, + ), shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, + color = + if (depth > 0) { + MaterialTheme.colorScheme.surfaceContainer + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + }, ) { Row( modifier = Modifier.padding(12.dp), @@ -211,19 +353,35 @@ private fun TodoInspectionCard(todo: TodoItem) { MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) } } + + if (depth > 0) { + Text( + text = "↳", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + modifier = Modifier.padding(end = 6.dp), + ) + } + Icon( imageVector = statusIcon, contentDescription = null, tint = statusTint, - modifier = Modifier.size(18.dp), + modifier = Modifier.size(if (depth > 0) 16.dp else 18.dp), ) Spacer(modifier = Modifier.width(10.dp)) Text( text = todo.content, style = - MaterialTheme.typography.bodyMedium.copy( - textDecoration = if (todo.isCompleted) TextDecoration.LineThrough else TextDecoration.None, - ), + if (depth > 0) { + MaterialTheme.typography.bodySmall.copy( + textDecoration = if (todo.isCompleted) TextDecoration.LineThrough else TextDecoration.None, + ) + } else { + MaterialTheme.typography.bodyMedium.copy( + textDecoration = if (todo.isCompleted) TextDecoration.LineThrough else TextDecoration.None, + ) + }, fontWeight = if (todo.isInProgress) FontWeight.Bold else FontWeight.Normal, color = if (todo.isCompleted) { @@ -233,6 +391,28 @@ private fun TodoInspectionCard(todo: TodoItem) { }, modifier = Modifier.weight(1f), ) + + if (row.hasChildren) { + Spacer(modifier = Modifier.width(8.dp)) + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + ) { + Text( + text = "${row.completedSubtasksCount}/${row.totalSubtasksCount}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = if (row.isExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = if (row.isExpanded) "Collapse" else "Expand", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } } } } 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 5abee578..2ce9af42 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 @@ -1769,6 +1769,7 @@ object ToolViewBuilder { val id = firstString(item, listOf("id")) val content = firstString(item, listOf("content")) val status = firstString(item, listOf("status")).ifEmpty { "pending" } + val parent = firstString(item, listOf("parent")) val marker = when (status) { "completed" -> "[x]" @@ -1776,7 +1777,8 @@ object ToolViewBuilder { "cancelled" -> "[~]" else -> "[ ]" } - "$marker $id. $content" + val indent = if (parent.isNotEmpty()) " ↳ " else "" + "$indent$marker $id. $content" }.joinToString("\n") .takeIf { it.isNotEmpty() } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt index 5b152e34..dc922da5 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt @@ -6,6 +6,7 @@ import io.mockk.unmockkAll import kotlinx.serialization.json.JsonObject import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -700,4 +701,49 @@ class EventParserTest { assertEquals("terminal", toolEvent.name) assertEquals("sess-replay-2", toolEvent.sessionId) } + + @Test + fun testParseTodoUpdated_returnsTodoUpdatedEvent() { + val response = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = + mapOf( + "type" to "todo.updated", + "session_id" to "sess-abc", + "payload" to + mapOf( + "revision" to 5, + "todos" to + listOf( + mapOf("id" to "1", "content" to "Parent task", "status" to "in_progress"), + mapOf( + "id" to "2", + "content" to "Sub task", + "status" to "pending", + "parent" to "1", + ), + ), + ), + ), + ) + val event = EventParser.parse(response) + assertTrue(event is WsEvent.TodoUpdated) + val todoUpdated = event as WsEvent.TodoUpdated + assertEquals("sess-abc", todoUpdated.sessionId) + assertEquals(5, todoUpdated.revision) + assertEquals(2, todoUpdated.todos.size) + assertEquals("1", todoUpdated.todos[0].id) + assertEquals("Parent task", todoUpdated.todos[0].content) + assertEquals(null, todoUpdated.todos[0].parent) + assertFalse(todoUpdated.todos[0].isSubtask) + assertEquals("2", todoUpdated.todos[1].id) + assertEquals("Sub task", todoUpdated.todos[1].content) + assertEquals("1", todoUpdated.todos[1].parent) + assertTrue(todoUpdated.todos[1].isSubtask) + } } 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 da930fb0..eec260cd 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 @@ -979,4 +979,88 @@ class ChatWsEventReducerTest { assertEquals(1, result.state.compressionCount) assertEquals(null, result.state.usedContextTokens) } + + @Test + fun testTodoUpdated_updatesStateTodos() { + val state = ChatUiState(currentSessionId = "session-1") + val todos = + listOf( + TodoItem(id = "1", content = "Main task", status = "in_progress"), + TodoItem(id = "2", content = "Subtask 1", status = "completed", parent = "1"), + TodoItem(id = "3", content = "Subtask 2", status = "pending", parent = "1"), + ) + val event = WsEvent.TodoUpdated(todos = todos, revision = 1, sessionId = "session-1") + + val result = + ChatWsEventReducer.reduce( + state = state, + streamingState = StreamingState(), + event = event, + currentSessionId = "session-1", + ) + + assertEquals(3, result.state.todos.size) + assertEquals("Main task", result.state.todos[0].content) + assertEquals(null, result.state.todos[0].parent) + assertEquals("1", result.state.todos[1].parent) + assertTrue(result.state.todos[1].isCompleted) + assertTrue(result.state.todos[1].isSubtask) + } + + @Test + fun testTodoUpdated_differentSession_isIgnored() { + val state = + ChatUiState( + currentSessionId = "session-1", + todos = listOf(TodoItem(id = "orig", content = "Original", status = "pending")), + ) + val event = + WsEvent.TodoUpdated( + todos = listOf(TodoItem(id = "new", content = "New", status = "in_progress")), + revision = 1, + sessionId = "session-other", + ) + + val result = + ChatWsEventReducer.reduce( + state = state, + streamingState = StreamingState(), + event = event, + currentSessionId = "session-1", + ) + + assertEquals(1, result.state.todos.size) + assertEquals("Original", result.state.todos[0].content) + } + + @Test + fun testExtractTodosFromMap_preservesParentHierarchy() { + val payload = + mapOf( + "todos" to + listOf( + mapOf("id" to "t1", "content" to "Root", "status" to "in_progress"), + mapOf("id" to "t2", "content" to "Child", "status" to "pending", "parent" to "t1"), + ), + ) + val extracted = extractTodosFromMap(payload) + assertEquals(2, extracted?.size) + assertEquals("t1", extracted?.get(0)?.id) + assertEquals(null, extracted?.get(0)?.parent) + assertEquals("t2", extracted?.get(1)?.id) + assertEquals("t1", extracted?.get(1)?.parent) + } + + @Test + fun testExtractTodosFromJson_preservesParentHierarchy() { + val json = + """{"todos":[{"id":"a","content":"Task A","status":"done"},""" + + """{"id":"b","content":"Task B","status":"pending","parent":"a"}]}""" + val extracted = extractTodosFromJson(json) + assertEquals(2, extracted?.size) + assertEquals("a", extracted?.get(0)?.id) + assertEquals(null, extracted?.get(0)?.parent) + assertEquals("b", extracted?.get(1)?.id) + assertEquals("a", extracted?.get(1)?.parent) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/TaskProgressChipTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/TaskProgressChipTest.kt index 43692f21..72173a10 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/TaskProgressChipTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/TaskProgressChipTest.kt @@ -1,6 +1,9 @@ package com.m57.hermescontrol.ui.chat +import com.m57.hermescontrol.ui.chat.components.buildTodoTree import com.m57.hermescontrol.ui.chat.components.computeChipDisplay +import com.m57.hermescontrol.ui.chat.components.flattenTodoTree +import com.m57.hermescontrol.ui.chat.components.getAllDescendantTodos import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -142,4 +145,72 @@ class TaskProgressChipTest { ) assertTrue(hydrateTodosFromMessages(messages).isEmpty()) } + + @Test + fun `buildTodoTree constructs correct multi-level hierarchy`() { + val todos = + listOf( + TodoItem(id = "1", content = "Root 1", status = "in_progress"), + TodoItem(id = "1.1", content = "Child 1.1", status = "completed", parent = "1"), + TodoItem(id = "1.2", content = "Child 1.2", status = "pending", parent = "1"), + TodoItem(id = "1.2.1", content = "Grandchild 1.2.1", status = "pending", parent = "1.2"), + TodoItem(id = "2", content = "Root 2", status = "pending"), + ) + + val tree = buildTodoTree(todos) + assertEquals(2, tree.size) + assertEquals("1", tree[0].item.id) + assertEquals(0, tree[0].depth) + assertEquals(2, tree[0].children.size) + assertEquals("1.1", tree[0].children[0].item.id) + assertEquals(1, tree[0].children[0].depth) + assertEquals("1.2", tree[0].children[1].item.id) + assertEquals(1, tree[0].children[1].depth) + assertEquals(1, tree[0].children[1].children.size) + assertEquals( + "1.2.1", + tree[0] + .children[1] + .children[0] + .item.id, + ) + assertEquals(2, tree[0].children[1].children[0].depth) + assertEquals("2", tree[1].item.id) + assertEquals(0, tree[1].depth) + } + + @Test + fun `flattenTodoTree collapses and expands subtasks correctly`() { + val todos = + listOf( + TodoItem(id = "1", content = "Root 1", status = "in_progress"), + TodoItem(id = "1.1", content = "Child 1.1", status = "completed", parent = "1"), + TodoItem(id = "1.2", content = "Child 1.2", status = "pending", parent = "1"), + TodoItem(id = "2", content = "Root 2", status = "pending"), + ) + val tree = buildTodoTree(todos) + + // Expanded state + val expandedRows = flattenTodoTree(tree, collapsedParentIds = emptySet()) + assertEquals(4, expandedRows.size) + assertEquals("1", expandedRows[0].todo.id) + assertTrue(expandedRows[0].hasChildren) + assertTrue(expandedRows[0].isExpanded) + assertEquals(1, expandedRows[0].completedSubtasksCount) + assertEquals(2, expandedRows[0].totalSubtasksCount) + assertEquals("1.1", expandedRows[1].todo.id) + assertEquals(1, expandedRows[1].depth) + assertEquals("1.2", expandedRows[2].todo.id) + assertEquals(1, expandedRows[2].depth) + assertEquals("2", expandedRows[3].todo.id) + assertEquals(0, expandedRows[3].depth) + + // Collapsed state + val collapsedRows = flattenTodoTree(tree, collapsedParentIds = setOf("1")) + assertEquals(2, collapsedRows.size) + assertEquals("1", collapsedRows[0].todo.id) + assertTrue(collapsedRows[0].hasChildren) + assertFalse(collapsedRows[0].isExpanded) + assertEquals("2", collapsedRows[1].todo.id) + } } From aa28d96b5d2bb1e5333ea3185e76d6ea0e557c05 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:19:17 +0300 Subject: [PATCH 11/20] chore: bump dorny/paths-filter from 3 to 4 (#1032) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/android.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 8a792ae4..5a4281eb 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -34,7 +34,7 @@ jobs: - name: Checkout uses: actions/checkout@v7 - name: Paths Filter - uses: dorny/paths-filter@v3 + uses: dorny/paths-filter@v4 id: filter with: filters: | From 908255f5ad646d50d644d74b6ce3096642365512 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:56:28 +0300 Subject: [PATCH 12/20] feat: support session-level hidden flag in SessionListResponse and SessionsScreen (#1033) Co-authored-by: M57 --- .../data/model/SessionListResponse.kt | 9 +- .../data/remote/HermesApiService.kt | 8 ++ .../ui/sessions/SessionsScreen.kt | 84 ++++++++++++++++++- .../ui/sessions/SessionsViewModel.kt | 54 ++++++++++++ app/src/main/res/values-ko/strings.xml | 6 ++ app/src/main/res/values-zh/strings.xml | 6 ++ app/src/main/res/values/strings.xml | 6 ++ .../data/model/ModelSerializationTest.kt | 35 ++++++++ .../ui/common/RefreshOnChangeTest.kt | 48 +++++++---- .../ui/sessions/SessionsViewModelTest.kt | 80 ++++++++++++++++++ 10 files changed, 316 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt index 5e2a6985..e01e6e0b 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt @@ -83,6 +83,10 @@ data class SessionInfo( // Uses LenientNullableBooleanSerializer to tolerate both JSON booleans and raw SQLite ints (issue #966). @Serializable(with = LenientNullableBooleanSerializer::class) val pinned: Boolean? = null, + // Session-level hidden flag (issue #1019): background agent sessions or internal routines. + // Uses LenientNullableBooleanSerializer to tolerate booleans, raw SQLite ints (0/1), and strings. + @Serializable(with = LenientNullableBooleanSerializer::class) + val hidden: Boolean? = null, ) @Serializable @@ -95,10 +99,11 @@ data class SessionStatsResponse( @Serializable data class SessionRenameRequest( - // Both fields are optional: the backend PATCH accepts any subset - // (SessionRename model: title/archived/pinned, omitted = unchanged). + // All fields are optional: the backend PATCH accepts any subset + // (SessionRename model: title/archived/pinned/hidden, omitted = unchanged). val title: String? = null, val pinned: Boolean? = null, + val hidden: Boolean? = null, ) @Serializable diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 2f81b314..e6d5149c 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -216,6 +216,14 @@ interface HermesApiService { @Body body: SessionRenameRequest, ): Response + // Hide/unhide rides the same PATCH /api/sessions/{id} — body carries only + // {hidden} (backend SessionRename model, any subset accepted; issue #1019). + @PATCH("api/sessions/{id}") + suspend fun setSessionHidden( + @Path("id", encoded = true) sessionId: String, + @Body body: SessionRenameRequest, + ): Response + @POST("api/sessions/bulk-delete") suspend fun bulkDeleteSessions( @Body body: BulkDeleteRequest, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt index a905cca3..bcefaa5b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -46,6 +46,8 @@ import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.SelectAll import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.AlertDialog import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox @@ -59,6 +61,7 @@ import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField @@ -290,7 +293,7 @@ private fun displayedSessions(state: SessionsUiState): List = ) } } else { - flattenSessionTree(state.sessions) + flattenSessionTree(state.displaySessions) } @OptIn(ExperimentalFoundationApi::class) @@ -313,6 +316,7 @@ fun SessionsScreen( state.isSearchMode, state.searchQuery, state.sessions, + state.showHidden, state.searchResults, ) { displayedSessions(state) @@ -521,6 +525,29 @@ fun SessionsScreen( navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, isRefreshing = state.isLoading, onRefresh = { viewModel.loadSessions() }, + actions = { + if (state.hasHiddenSessions) { + IconButton( + onClick = { viewModel.toggleShowHidden() }, + modifier = Modifier.testTag("sessions_action_toggle_hidden"), + ) { + Icon( + imageVector = + if (state.showHidden) { + Icons.Filled.VisibilityOff + } else { + Icons.Filled.Visibility + }, + contentDescription = + if (state.showHidden) { + stringResource(R.string.sessions_hide_hidden) + } else { + stringResource(R.string.sessions_show_hidden) + }, + ) + } + } + }, modifier = modifier, ) { // Single scrolling column: search bar pinned on top, list fills below. @@ -685,6 +712,16 @@ fun SessionsScreen( ) } + state.displaySessions.isEmpty() -> { + EmptyState( + title = stringResource(R.string.history_empty_title), + subtitle = stringResource(R.string.sessions_all_hidden_desc), + icon = Icons.Filled.VisibilityOff, + actionLabel = stringResource(R.string.sessions_show_hidden), + onAction = { viewModel.toggleShowHidden() }, + ) + } + else -> { Column(modifier = Modifier.fillMaxSize()) { // ── Stats row ─────────────────────────────────────── @@ -801,6 +838,7 @@ fun SessionsScreen( isSelected = session.id in state.selectedIds, isDeleting = session.id in state.deletingSessionIds, isPinned = session.pinned == true, + isHidden = session.hidden == true, highlightBackground = primaryContainer, highlightForeground = onPrimaryContainer, onCardClick = { @@ -822,6 +860,7 @@ fun SessionsScreen( ) }, onTogglePin = { viewModel.togglePin(session.id) }, + onToggleHide = { viewModel.toggleHide(session.id) }, onDelete = { viewModel.requestDeleteSession(session.id) }, ) } @@ -969,6 +1008,7 @@ private fun SessionCard( isSelected: Boolean, isDeleting: Boolean, isPinned: Boolean, + isHidden: Boolean = false, highlightBackground: Color, highlightForeground: Color, onCardClick: () -> Unit, @@ -976,6 +1016,7 @@ private fun SessionCard( onSelect: () -> Unit, onRename: () -> Unit, onTogglePin: () -> Unit, + onToggleHide: () -> Unit, onDelete: () -> Unit, ) { val spacing = LocalSpacing.current @@ -1095,6 +1136,13 @@ private fun SessionCard( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + if (session.hidden == true) { + Spacer(modifier = Modifier.width(spacing.sm)) + StatusBadge( + text = stringResource(R.string.sessions_hidden_badge), + status = StatusBadgeType.NEUTRAL, + ) + } if (!session.status.isNullOrBlank()) { Spacer(modifier = Modifier.width(spacing.sm)) StatusBadge( @@ -1116,6 +1164,11 @@ private fun SessionCard( menuExpanded = false onTogglePin() }, + isHidden = isHidden, + onToggleHide = { + menuExpanded = false + onToggleHide() + }, onSelect = { menuExpanded = false onSelect() @@ -1301,6 +1354,8 @@ private fun SessionActionMenu( isDeleting: Boolean, isPinned: Boolean = false, onTogglePin: (() -> Unit)? = null, + isHidden: Boolean = false, + onToggleHide: (() -> Unit)? = null, onSelect: () -> Unit, onRename: () -> Unit, onDelete: () -> Unit, @@ -1324,6 +1379,33 @@ private fun SessionActionMenu( onClick = onTogglePin, ) } + if (onToggleHide != null) { + DropdownMenuItem( + text = { + Text( + stringResource( + if (isHidden) { + R.string.sessions_action_unhide + } else { + R.string.sessions_action_hide + }, + ), + ) + }, + leadingIcon = { + Icon( + imageVector = + if (isHidden) { + Icons.Filled.Visibility + } else { + Icons.Filled.VisibilityOff + }, + contentDescription = null, + ) + }, + onClick = onToggleHide, + ) + } DropdownMenuItem( text = { Text(stringResource(R.string.sessions_action_select)) }, leadingIcon = { Icon(Icons.Filled.CheckCircle, contentDescription = null) }, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt index 7b0c9308..e8182e00 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt @@ -89,8 +89,15 @@ data class SessionsUiState( val isSearching: Boolean = false, val searchResults: List = emptyList(), val searchError: String? = null, + val showHidden: Boolean = false, ) { val isSearchMode: Boolean get() = searchQuery.isNotBlank() + + val hasHiddenSessions: Boolean + get() = sessions.any { it.hidden == true } + + val displaySessions: List + get() = if (showHidden) sessions else sessions.filter { it.hidden != true } } class SessionsViewModel : @@ -315,6 +322,10 @@ class SessionsViewModel : // ── Bulk selection ─────────────────────────────────────────────────── + fun toggleShowHidden() { + _uiState.update { it.copy(showHidden = !it.showHidden) } + } + fun toggleSelecting() { _uiState.update { it.copy( @@ -455,6 +466,49 @@ class SessionsViewModel : } } + // ── Hide / unhide (issue #1019) ────────────────────────────────────── + + /** + * Toggle the hidden flag on a session via PATCH /api/sessions/{id} ({hidden}). + */ + fun toggleHide(sessionId: String) { + val session = _uiState.value.sessions.find { it.id == sessionId } ?: return + val targetHidden = session.hidden != true + viewModelScope.launch { + val result = + safeApiCall { + ApiClient.hermesApi.setSessionHidden( + sessionId = sessionId, + body = SessionRenameRequest(hidden = targetHidden), + ) + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + sessions = + it.sessions + .map { s -> if (s.id == sessionId) s.copy(hidden = targetHidden) else s } + .pinnedFirst(), + toastMessage = + if (targetHidden) { + "Session hidden" + } else { + "Session unhidden" + }, + ) + } + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy(toastMessage = "Hide failed: ${result.error.message}") + } + } + } + } + } + // ── Delete (single) ────────────────────────────────────────────────── fun requestDeleteSession(sessionId: String) { diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 38f2446f..a285a430 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -406,6 +406,12 @@ 전체 선택 선택 해제 %1$d개 삭제 + 숨기기 + 숨김 해제 + 숨겨진 세션 표시 + 숨겨진 세션 숨기기 + 숨김 + 모든 세션이 현재 숨겨져 있습니다. 아래를 탭하여 숨겨진 세션을 표시하세요. 툴셋이 없습니다 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 9e66e094..15a976fe 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -523,6 +523,12 @@ 重命名 置顶 取消置顶 + 隐藏 + 取消隐藏 + 显示已隐藏的会话 + 隐藏已隐藏的会话 + 已隐藏 + 所有会话当前均已隐藏。点击下方以显示已隐藏的会话。 已置顶 分支会话 重命名会话 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f47e7ff9..1f270a38 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -599,6 +599,12 @@ Rename Pin Unpin + Hide + Unhide + Show hidden sessions + Hide hidden sessions + Hidden + All sessions are currently hidden. Tap below to show hidden sessions. Pinned Forked session Rename Session diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/ModelSerializationTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/ModelSerializationTest.kt index 94ce2d81..2fda9252 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/model/ModelSerializationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/model/ModelSerializationTest.kt @@ -312,6 +312,41 @@ class ModelSerializationTest { assertEquals(false, s8.pinned) } + @Test + fun testSessionInfoDeserialization_hiddenToleratesIntAndBooleanAndString() { + val s1 = json.decodeFromString("""{"id": "s1", "hidden": 0}""") + assertEquals(false, s1.hidden) + + val s2 = json.decodeFromString("""{"id": "s2", "hidden": 1}""") + assertEquals(true, s2.hidden) + + val s3 = json.decodeFromString("""{"id": "s3", "hidden": false}""") + assertEquals(false, s3.hidden) + + val s4 = json.decodeFromString("""{"id": "s4", "hidden": true}""") + assertEquals(true, s4.hidden) + + val s5 = json.decodeFromString("""{"id": "s5", "hidden": null}""") + assertNull(s5.hidden) + + val s6 = json.decodeFromString("""{"id": "s6"}""") + assertNull(s6.hidden) + + val s7 = json.decodeFromString("""{"id": "s7", "hidden": "1"}""") + assertEquals(true, s7.hidden) + + val s8 = json.decodeFromString("""{"id": "s8", "hidden": "false"}""") + assertEquals(false, s8.hidden) + } + + @Test + fun testSessionRenameRequestSerialization_includesHidden() { + val req = SessionRenameRequest(title = "new name", hidden = true) + val encoded = json.encodeToString(SessionRenameRequest.serializer(), req) + assertTrue(encoded.contains("\"hidden\":true")) + assertTrue(encoded.contains("\"title\":\"new name\"")) + } + @Test fun testSessionMessagesResponseDeserialization_missingMessages_causesNullOnNonNullableField() { val jsonStr = "{}" diff --git a/app/src/test/java/com/m57/hermescontrol/ui/common/RefreshOnChangeTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/common/RefreshOnChangeTest.kt index e5f07aeb..7714b5a6 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/common/RefreshOnChangeTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/common/RefreshOnChangeTest.kt @@ -31,13 +31,19 @@ class RefreshOnChangeTest { private class TestViewModel : ViewModel() { val state = MutableStateFlow("initial") + var job: kotlinx.coroutines.Job? = null init { - refreshOnChange( - eventType = ChangeEvents.CRON, - apiCall = { NetworkResult.Success("refreshed") }, - onSuccess = { state.value = it }, - ) + job = + refreshOnChange( + eventType = ChangeEvents.CRON, + apiCall = { NetworkResult.Success("refreshed") }, + onSuccess = { state.value = it }, + ) + } + + fun cleanup() { + job?.cancel() } } @@ -55,23 +61,31 @@ class RefreshOnChangeTest { fun testRefreshOnChange_updatesOnMatchingEvent() = runTest(testDispatcher) { val viewModel = TestViewModel() - // Let the init coroutine run so the collector actually subscribes - // before the event fires (replay=0 — events before subscription - // are dropped, matching production where VMs subscribe at - // construction and events arrive afterwards). - advanceUntilIdle() - ChangeEventHub.emit(WsEvent.ChangeEvent(ChangeEvents.CRON)) - advanceUntilIdle() - assertEquals("refreshed", viewModel.state.value) + try { + // Let the init coroutine run so the collector actually subscribes + // before the event fires (replay=0 — events before subscription + // are dropped, matching production where VMs subscribe at + // construction and events arrive afterwards). + advanceUntilIdle() + ChangeEventHub.emit(WsEvent.ChangeEvent(ChangeEvents.CRON)) + advanceUntilIdle() + assertEquals("refreshed", viewModel.state.value) + } finally { + viewModel.cleanup() + } } @Test fun testRefreshOnChange_ignoresOtherEventTypes() = runTest(testDispatcher) { val viewModel = TestViewModel() - advanceUntilIdle() - ChangeEventHub.emit(WsEvent.ChangeEvent(ChangeEvents.SESSIONS)) - advanceUntilIdle() - assertEquals("initial", viewModel.state.value) + try { + advanceUntilIdle() + ChangeEventHub.emit(WsEvent.ChangeEvent(ChangeEvents.SESSIONS)) + advanceUntilIdle() + assertEquals("initial", viewModel.state.value) + } finally { + viewModel.cleanup() + } } } 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 1ec675fe..386d1871 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 @@ -281,4 +281,84 @@ class SessionsViewModelTest { .contains("Pin failed"), ) } + + @Test + fun `displaySessions filters out hidden sessions by default and shows them on toggleShowHidden`() { + val vm = createViewModel() + coEvery { mockApi.getSessions(any(), any(), any()) } returns + Response.success( + SessionListResponse( + sessions = + listOf( + SessionInfo("s-visible-1", hidden = false), + SessionInfo("s-hidden", hidden = true), + SessionInfo("s-visible-2", hidden = null), + ), + total = 3, + ), + ) + vm.loadSessions() + testDispatcher.scheduler.advanceUntilIdle() + + assertTrue(vm.uiState.value.hasHiddenSessions) + assertFalse(vm.uiState.value.showHidden) + assertEquals( + listOf("s-visible-1", "s-visible-2"), + vm.uiState.value.displaySessions + .map { it.id }, + ) + + vm.toggleShowHidden() + assertTrue(vm.uiState.value.showHidden) + assertEquals( + listOf("s-visible-1", "s-hidden", "s-visible-2"), + vm.uiState.value.displaySessions + .map { it.id }, + ) + + vm.toggleShowHidden() + assertFalse(vm.uiState.value.showHidden) + assertEquals( + listOf("s-visible-1", "s-visible-2"), + vm.uiState.value.displaySessions + .map { it.id }, + ) + } + + @Test + fun `toggleHide updates hidden status and surfaces toast`() { + val vm = createViewModel() + coEvery { mockApi.getSessions(any(), any(), any()) } returns + Response.success( + SessionListResponse( + sessions = listOf(SessionInfo("s-1", hidden = false)), + total = 1, + ), + ) + vm.loadSessions() + testDispatcher.scheduler.advanceUntilIdle() + + coEvery { mockApi.setSessionHidden(any(), any()) } returns Response.success(Unit) + vm.toggleHide("s-1") + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals( + true, + vm.uiState.value.sessions + .first() + .hidden, + ) + assertEquals("Session hidden", vm.uiState.value.toastMessage) + + vm.toggleHide("s-1") + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals( + false, + vm.uiState.value.sessions + .first() + .hidden, + ) + assertEquals("Session unhidden", vm.uiState.value.toastMessage) + } } From 11dd1eff92fdd922d965baff5988a59361c0d857 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:19:00 +0300 Subject: [PATCH 13/20] feat(chat): live subagent steering and cancellation action sheet (closes #1030) (#1034) Co-authored-by: M57 --- .../m57/hermescontrol/ui/chat/ChatMessage.kt | 4 +- .../m57/hermescontrol/ui/chat/ChatScreen.kt | 2 + .../hermescontrol/ui/chat/ChatViewModel.kt | 79 ++++++ .../components/SubagentInspectionSheet.kt | 253 ++++++++++++++++-- app/src/main/res/values-ko/strings.xml | 14 + app/src/main/res/values-zh/strings.xml | 9 + app/src/main/res/values/strings.xml | 9 + .../ui/chat/ChatViewModelTest.kt | 64 +++++ 8 files changed, 414 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt index 17e3bbe5..a437b894 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt @@ -114,8 +114,10 @@ data class SubagentIndicator( ) { val isComplete: Boolean get() = type == "subagent.complete" || status == "completed" || status == "done" val isFailed: Boolean get() = status == "failed" || status == "interrupted" + val isCancelled: Boolean get() = status == "cancelled" || status == "stopped" || status == "canceled" + val isSteered: Boolean get() = status == "steered" val isQueued: Boolean get() = status == "queued" - val isRunning: Boolean get() = !isComplete && !isFailed && !isQueued + val isRunning: Boolean get() = !isComplete && !isFailed && !isCancelled && !isQueued } enum class MessageRole { 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 364d14d2..7a09cfb5 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 @@ -930,6 +930,8 @@ fun ChatScreen( SubagentInspectionSheet( indicators = state.subagentIndicators, todos = state.todos, + onSteerSubagent = { indicator, msg -> viewModel.steerSubagent(indicator, msg) }, + onStopSubagent = { indicator -> viewModel.stopSubagent(indicator) }, onDismiss = { showSubagentInspectionSheet = false }, ) } 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 a167db16..a774705f 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 @@ -1960,6 +1960,85 @@ class ChatViewModel( } } + /** + * Send course-correction guidance to a live subagent child session (issue #1030). + */ + fun steerSubagent( + indicator: SubagentIndicator, + message: String, + ) { + val trimmed = message.trim() + if (trimmed.isBlank()) return + val sessionId = runtimeSessionId ?: return + val subagentId = indicator.subagentId + val steerCommand = + if (!subagentId.isNullOrBlank()) { + "/steer $subagentId $trimmed" + } else { + "/steer $trimmed" + } + viewModelScope.launch(ioDispatcher) { + wsClient.sendRedirect( + sessionId, + steerCommand, + onSent = { id -> trackRequest(id, WsMethods.SESSION_REDIRECT) }, + ) + } + _uiState.update { current -> + val updated = + current.subagentIndicators.map { ind -> + if (ind.subagentId == indicator.subagentId && + (ind.goal == indicator.goal || indicator.subagentId != null) + ) { + val newLogs = + (ind.logs + SubagentLogLine(text = "Course correction: \"$trimmed\"", isSummary = true)) + .takeLast(30) + ind.copy(status = "steered", logs = newLogs) + } else { + ind + } + } + current.copy(subagentIndicators = updated) + } + } + + /** + * Stop / interrupt a single live subagent early (issue #1030). + */ + fun stopSubagent(indicator: SubagentIndicator) { + val sessionId = runtimeSessionId ?: return + val subagentId = indicator.subagentId + val stopCommand = + if (!subagentId.isNullOrBlank()) { + "/stop $subagentId" + } else { + "/stop" + } + viewModelScope.launch(ioDispatcher) { + wsClient.sendRedirect( + sessionId, + stopCommand, + onSent = { id -> trackRequest(id, WsMethods.SESSION_REDIRECT) }, + ) + } + _uiState.update { current -> + val updated = + current.subagentIndicators.map { ind -> + if (ind.subagentId == indicator.subagentId && + (ind.goal == indicator.goal || indicator.subagentId != null) + ) { + val newLogs = + (ind.logs + SubagentLogLine(text = "Stopped subagent", isError = true)) + .takeLast(30) + ind.copy(status = "cancelled", logs = newLogs) + } else { + ind + } + } + current.copy(subagentIndicators = updated) + } + } + fun createNewSession(setLoading: Boolean = true) { // A fresh create has no persisted row until the first prompt. sessionHasServerPresence = false 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 ec0e8c7c..b820b585 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 @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.chat.components +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -15,8 +16,12 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.AltRoute import androidx.compose.material.icons.automirrored.filled.FormatListBulleted +import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Autorenew import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.CheckCircle @@ -24,12 +29,18 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.HourglassEmpty import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState @@ -45,6 +56,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -54,13 +66,15 @@ import com.m57.hermescontrol.ui.chat.SubagentIndicator import com.m57.hermescontrol.ui.chat.TodoItem /** - * Inspection sheet displaying active & completed subagent tasks and agent todos. + * Inspection sheet displaying active & completed subagent tasks and agent todos (issue #1030). */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun SubagentInspectionSheet( indicators: List = emptyList(), todos: List = emptyList(), + onSteerSubagent: ((SubagentIndicator, String) -> Unit)? = null, + onStopSubagent: ((SubagentIndicator) -> Unit)? = null, onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { @@ -197,7 +211,21 @@ fun SubagentInspectionSheet( "subagent-${indicator.subagentId ?: indicator.goal ?: "${indicator.type}_$index"}" }, ) { _, indicator -> - InspectionItemCard(indicator = indicator) + InspectionItemCard( + indicator = indicator, + onSteer = + if (onSteerSubagent != null) { + { message -> onSteerSubagent(indicator, message) } + } else { + null + }, + onStop = + if (onStopSubagent != null) { + { onStopSubagent(indicator) } + } else { + null + }, + ) } } } @@ -418,29 +446,77 @@ private fun TodoInspectionCard( } @Composable -private fun InspectionItemCard(indicator: SubagentIndicator) { +private fun InspectionItemCard( + indicator: SubagentIndicator, + onSteer: ((String) -> Unit)? = null, + onStop: (() -> Unit)? = null, +) { + var showSteerInput by remember { mutableStateOf(false) } + var steerText by remember { mutableStateOf("") } + val statusColors = LocalHermesStatusColors.current + + val (statusIcon, statusTint, statusLabel) = + when { + indicator.isComplete -> { + Triple( + Icons.Filled.CheckCircle, + statusColors.success, + stringResource(R.string.subagent_status_completed), + ) + } + + indicator.isFailed -> { + Triple( + Icons.Filled.Cancel, + statusColors.error, + stringResource(R.string.subagent_status_failed), + ) + } + + indicator.isCancelled -> { + Triple( + Icons.Filled.Cancel, + statusColors.warning, + stringResource(R.string.subagent_status_cancelled), + ) + } + + indicator.isSteered -> { + Triple( + Icons.AutoMirrored.Filled.AltRoute, + MaterialTheme.colorScheme.primary, + stringResource(R.string.subagent_status_steered), + ) + } + + indicator.isQueued -> { + Triple( + Icons.Filled.HourglassEmpty, + MaterialTheme.colorScheme.onSurfaceVariant, + stringResource(R.string.subagent_status_running), + ) + } + + else -> { + Triple( + Icons.Filled.Autorenew, + MaterialTheme.colorScheme.primary, + stringResource(R.string.subagent_status_running), + ) + } + } + Surface( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.surfaceContainerHigh, ) { Column(modifier = Modifier.padding(12.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - val statusColors = LocalHermesStatusColors.current - val (statusIcon, statusTint) = - when { - indicator.isComplete -> { - Icons.Filled.CheckCircle to statusColors.success - } - - indicator.isFailed -> { - Icons.Filled.Cancel to statusColors.error - } - - else -> { - Icons.Filled.Autorenew to MaterialTheme.colorScheme.primary - } - } + // Header Row: Status Icon, Goal, and Status Badge + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { Icon( imageVector = statusIcon, contentDescription = null, @@ -456,6 +532,44 @@ private fun InspectionItemCard(indicator: SubagentIndicator) { fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f), ) + + Spacer(modifier = Modifier.width(8.dp)) + Surface( + shape = RoundedCornerShape(8.dp), + color = statusTint.copy(alpha = 0.15f), + ) { + Text( + text = statusLabel, + style = MaterialTheme.typography.labelSmall, + color = statusTint, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } + + // Duration & Model info + if (indicator.durationSeconds != null || !indicator.model.isNullOrBlank()) { + Spacer(modifier = Modifier.height(4.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (indicator.durationSeconds != null) { + Text( + text = stringResource(R.string.subagent_duration, indicator.durationSeconds), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (!indicator.model.isNullOrBlank()) { + Text( + text = indicator.model, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + ) + } + } } if (!indicator.summary.isNullOrBlank()) { @@ -467,6 +581,7 @@ private fun InspectionItemCard(indicator: SubagentIndicator) { ) } + // Live Transcript Logs if (indicator.logs.isNotEmpty()) { Spacer(modifier = Modifier.height(8.dp)) Column( @@ -503,6 +618,106 @@ private fun InspectionItemCard(indicator: SubagentIndicator) { } } } + + // Interactive Controls for active subagent (issue #1030) + if (indicator.isRunning || indicator.isSteered) { + Spacer(modifier = Modifier.height(10.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (onSteer != null) { + FilledTonalButton( + onClick = { showSteerInput = !showSteerInput }, + modifier = Modifier.weight(1f), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.AltRoute, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text(stringResource(R.string.subagent_action_steer)) + } + } + + if (onStop != null) { + OutlinedButton( + onClick = onStop, + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = statusColors.error, + ), + ) { + Icon( + imageVector = Icons.Filled.Stop, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(R.string.subagent_action_stop)) + } + } + } + + // Inline Steering Input Field + AnimatedVisibility(visible = showSteerInput) { + Column(modifier = Modifier.padding(top = 8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = steerText, + onValueChange = { steerText = it }, + placeholder = { + Text( + text = stringResource(R.string.subagent_steer_placeholder), + style = MaterialTheme.typography.bodySmall, + ) + }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = + KeyboardActions( + onSend = { + if (steerText.isNotBlank()) { + onSteer?.invoke(steerText) + steerText = "" + showSteerInput = false + } + }, + ), + modifier = Modifier.weight(1f), + textStyle = MaterialTheme.typography.bodySmall, + ) + Spacer(modifier = Modifier.width(6.dp)) + IconButton( + onClick = { + if (steerText.isNotBlank()) { + onSteer?.invoke(steerText) + steerText = "" + showSteerInput = false + } + }, + enabled = steerText.isNotBlank(), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = stringResource(R.string.subagent_action_steer), + tint = + if (steerText.isNotBlank()) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + }, + ) + } + } + } + } + } } } } diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index a285a430..748d77e4 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -846,4 +846,18 @@ 이 그룹을 위한 사용자 지정 규칙, 어조 또는 컨텍스트 (선택사항)… 이 방의 모든 봇에 적용되는 사용자 지정 가이드라인 설정 + 에이전트 계획 + 실시간 기록 + 활성 작업 또는 계획 항목이 없습니다. + 요약: %1$s + 작업 및 계획 검사 + 안내 + 중지 + 하위 에이전트 안내 입력… + 실행 중 + 완료됨 + 안내됨 + 취소됨 + 실패 + %1$.1f초 \ No newline at end of file diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 15a976fe..00e6c408 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1089,6 +1089,15 @@ 没有活动任务或计划项。 摘要:%1$s 任务与计划检查 + 引导 + 停止 + 向子代理发送调整引导… + 运行中 + 已完成 + 已引导 + 已取消 + 失败 + %1$.1f秒 需要认证 清理检查点 迁移配置 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1f270a38..0ae0b120 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1218,6 +1218,15 @@ No active tasks or plan items. Summary: %1$s Task & Plan Inspection + Steer + Stop + Course-correction guidance for subagent… + Running + Completed + Steered + Cancelled + Failed + %1$.1fs Auth required Checkpoint prune Config migrate 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 27e20089..0382f8d4 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 @@ -3939,4 +3939,68 @@ class ChatViewModelTest { .contains("Backend exploded"), ) } + + // ── Subagent steering & cancellation (issue #1030) ─────────────────── + + @Test + fun testSteerSubagent_sendsRedirectAndUpdatesStatus() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + val indicator = + SubagentIndicator( + type = "subagent.start", + subagentId = "sub-123", + goal = "Search database", + status = "running", + ) + mockEventsFlow.emit( + WsEvent.SubagentEvent( + type = "subagent.start", + payload = mapOf("subagent_id" to "sub-123", "goal" to "Search database"), + sessionId = sessionId, + ), + ) + advanceUntilIdle() + + viewModel.steerSubagent(indicator, "Use index scan instead") + advanceUntilIdle() + + val updated = + viewModel.uiState.value.subagentIndicators + .first { it.subagentId == "sub-123" } + assertEquals("steered", updated.status) + assertTrue(updated.logs.any { it.text.contains("Use index scan instead") }) + io.mockk.verify { HermesWsClient.sendRedirect(sessionId, "/steer sub-123 Use index scan instead", any()) } + } + + @Test + fun testStopSubagent_sendsRedirectAndMarksCancelled() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + val indicator = + SubagentIndicator( + type = "subagent.start", + subagentId = "sub-123", + goal = "Run deep scan", + status = "running", + ) + mockEventsFlow.emit( + WsEvent.SubagentEvent( + type = "subagent.start", + payload = mapOf("subagent_id" to "sub-123", "goal" to "Run deep scan"), + sessionId = sessionId, + ), + ) + advanceUntilIdle() + + viewModel.stopSubagent(indicator) + advanceUntilIdle() + + val updated = + viewModel.uiState.value.subagentIndicators + .first { it.subagentId == "sub-123" } + assertEquals("cancelled", updated.status) + assertTrue(updated.logs.any { it.text.contains("Stopped subagent") }) + io.mockk.verify { HermesWsClient.sendRedirect(sessionId, "/stop sub-123", any()) } + } } From 21db9bfdabfd5a87432624f300f58d38389b73bd Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:20:18 +0300 Subject: [PATCH 14/20] fix(memory): scope memory endpoints to active profile and auto-reload on profile switch (#1035) Co-authored-by: M57 --- .../data/remote/ProfileScopeInterceptor.kt | 1 + .../ui/memory/MemoryViewModel.kt | 20 ++++++++++-- .../remote/ProfileScopeInterceptorTest.kt | 13 ++++++++ .../ui/memory/MemoryViewModelTest.kt | 31 +++++++++++++++++++ 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptor.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptor.kt index 02d92bd1..72de4781 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptor.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptor.kt @@ -34,6 +34,7 @@ object ProfileScopeInterceptor : Interceptor { "/api/env", "/api/gateway", "/api/mcp", + "/api/memory", "/api/messaging/platforms", "/api/messaging/telegram/onboarding", "/api/messaging/whatsapp/onboarding", 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 316f9ae6..1ec57e4f 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 @@ -8,6 +8,7 @@ import com.m57.hermescontrol.data.model.MemoryResponse import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.safeApiCall +import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator import com.m57.hermescontrol.ui.common.ToastHost import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -37,9 +38,24 @@ class MemoryViewModel : private val _uiState = MutableStateFlow(MemoryUiState()) val uiState: StateFlow = _uiState.asStateFlow() - fun load() { + init { + viewModelScope.launch { + ProfileSwitchCoordinator.switched.collect { + load(silent = true) + } + } + viewModelScope.launch { + ProfileSwitchCoordinator.connectionSwitched.collect { + load(silent = true) + } + } + } + + fun load(silent: Boolean = false) { if (_uiState.value.isLoading) return - _uiState.update { it.copy(isLoading = true, errorMessage = null) } + if (!silent) { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + } viewModelScope.launch { val result = safeApiCall { ApiClient.hermesApi.getMemory() } when (result) { diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt index f4da2244..c97cee18 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt @@ -118,6 +118,19 @@ class ProfileScopeInterceptorTest { assertEquals("alpha", url.queryParameter("profile")) } + @Test + fun memoryEndpoints_areScoped() { + val client = clientFor("yasmin") + for (path in listOf("api/memory", "api/memory/learning-graph", "api/memory/providers/mem0/config")) { + server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + val req = Request.Builder().url(server.url(path)).build() + client.newCall(req).execute().close() + val url = server.takeRequest().requestUrl!! + assertEquals("yasmin", url.queryParameter("profile")) + assertEquals("/$path", url.encodedPath) + } + } + @Test fun lookalikePath_notScoped() { // Sourcery review (PR #540): `startsWith` must not match non-segment 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 6ccb1f64..cfb23409 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 @@ -6,6 +6,7 @@ import com.m57.hermescontrol.data.model.MemoryResetResponse import com.m57.hermescontrol.data.model.MemoryResponse import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.HermesApiService +import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -14,6 +15,7 @@ import io.mockk.mockkObject import io.mockk.unmockkAll import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain @@ -36,6 +38,8 @@ import retrofit2.Response class MemoryViewModelTest { private val testDispatcher = StandardTestDispatcher() private lateinit var mockApi: HermesApiService + private val mockSwitchFlow = MutableSharedFlow(extraBufferCapacity = 1) + private val mockConnSwitchFlow = MutableSharedFlow(extraBufferCapacity = 1) private val memoryResponse = MemoryResponse( @@ -56,6 +60,9 @@ class MemoryViewModelTest { fun setUp() { Dispatchers.setMain(testDispatcher) mockkObject(ApiClient) + mockkObject(ProfileSwitchCoordinator) + every { ProfileSwitchCoordinator.switched } returns mockSwitchFlow + every { ProfileSwitchCoordinator.connectionSwitched } returns mockConnSwitchFlow mockApi = mockk(relaxed = true) every { ApiClient.hermesApi } returns mockApi coEvery { mockApi.getMemory() } returns Response.success(memoryResponse) @@ -79,6 +86,30 @@ class MemoryViewModelTest { assertEquals(1024L, state.memory?.builtin_files?.memory) } + @Test + fun `profile switch triggers silent reload`() { + val vm = MemoryViewModel() + testDispatcher.scheduler.advanceUntilIdle() + coVerify(exactly = 0) { mockApi.getMemory() } + + mockSwitchFlow.tryEmit("yasmin") + testDispatcher.scheduler.advanceUntilIdle() + + coVerify(exactly = 1) { mockApi.getMemory() } + } + + @Test + fun `connection profile switch triggers silent reload`() { + val vm = MemoryViewModel() + testDispatcher.scheduler.advanceUntilIdle() + coVerify(exactly = 0) { mockApi.getMemory() } + + mockConnSwitchFlow.tryEmit("server-remote") + testDispatcher.scheduler.advanceUntilIdle() + + coVerify(exactly = 1) { mockApi.getMemory() } + } + @Test fun `load also fetches learning graph for self-improvement section`() { val graph = From d8385a2b0294dab8fdf10906054b71b474e8d15a Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:44:39 +0300 Subject: [PATCH 15/20] feat(ws): adopt WS ping method for heartbeat and latency tracking (#1036) --- .../hermescontrol/data/ws/HermesWsClient.kt | 33 ++++++++++ .../m57/hermescontrol/data/ws/WsMethods.kt | 5 ++ .../data/ws/HermesWsClientTest.kt | 61 +++++++++++++++++++ 3 files changed, 99 insertions(+) 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 335ddaa5..a1d9c710 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 @@ -152,6 +152,12 @@ object HermesWsClient { val isHealthy: Boolean get() = isConnected && (System.currentTimeMillis() - lastPongTimestamp < 60_000L) + // ── Observable latency tracking (issue #1017) ───────────────────────── + private val _lastLatencyMs = MutableStateFlow(null) + + /** Last measured round-trip latency in milliseconds via [ping], or null when disconnected / unmeasured. */ + val lastLatencyMs: StateFlow = _lastLatencyMs.asStateFlow() + private var healthJob: Job? = null private fun startHealthTracking() { @@ -161,11 +167,37 @@ object HermesWsClient { wsScope.launch { while (connected.get()) { delay(30_000L) + if (connected.get()) { + try { + ping(timeoutMs = 10_000L) + } catch (e: Exception) { + Log.w(TAG, "Health check ping failed: ${e.message}") + } + } if (!runHealthCheckPass()) break } } } + /** + * Ultra-lightweight JSON-RPC liveness check and RTT measurement (issue #1017). + * Bypasses agent queues on the backend; measures round-trip time in milliseconds. + * Updates [lastLatencyMs] and [lastPongTimestamp]. + */ + suspend fun ping(timeoutMs: Long = 5_000L): Long { + val start = System.currentTimeMillis() + request(WsMethods.PING, emptyMap(), timeoutMs = timeoutMs).await() + val latency = (System.currentTimeMillis() - start).coerceAtLeast(0L) + lastPongTimestamp = System.currentTimeMillis() + _lastLatencyMs.value = latency + return latency + } + + @VisibleForTesting + internal fun setLastLatencyForTest(latency: Long?) { + _lastLatencyMs.value = latency + } + /** * One liveness pass. Returns false when the tracking loop should stop: * either the connection is already gone or the watchdog fired a cancel. @@ -199,6 +231,7 @@ object HermesWsClient { private fun stopHealthTracking() { healthJob?.cancel() healthJob = null + _lastLatencyMs.value = null } // ── Public observable stream ───────────────────────────────────────── diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt index 92398aaf..dd00f6b5 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt @@ -37,6 +37,11 @@ object WsMethods { const val SUDO_RESPOND = "sudo.respond" const val SECRET_RESPOND = "secret.respond" + // ── Heartbeat & Latency (issue #1017) ────────────────────────────────── + + /** Ultra-lightweight liveness check and round-trip latency measurement. */ + const val PING = "ping" + // ── Commands catalog ────────────────────────────────────────────────── const val COMMANDS_CATALOG = "commands.catalog" const val COMMAND_DISPATCH = "command.dispatch" diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt index 742da1f2..7994b464 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt @@ -1688,4 +1688,65 @@ class HermesWsClientTest { Thread.sleep(200) assertEquals(6, HermesWsClient.getSeqWatermarks()["s1"]) } + + @Test + fun testPingMethodConstant() { + assertEquals("ping", WsMethods.PING) + } + + @Test + fun testPingMeasuresLatencyAndUpdatesTimestamp() { + var serverWebSocket: WebSocket? = null + val serverLatch = CountDownLatch(1) + val pingLatch = CountDownLatch(1) + var pingReceived = false + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: okhttp3.Response, + ) { + serverWebSocket = webSocket + serverLatch.countDown() + } + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + if (text.contains(""""method":"ping"""")) { + pingReceived = true + val id = Regex(""""id":"([^"]+)"""").find(text)?.groupValues?.get(1) ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":{"pong":true,"timestamp":1700000000.0}}""", + ) + pingLatch.countDown() + } + } + }, + ), + ) + + HermesWsClient.connect() + runBlocking { + withTimeout(5000) { + HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } + } + } + assertTrue(serverLatch.await(5, TimeUnit.SECONDS)) + + val latency = + runBlocking { + HermesWsClient.ping(timeoutMs = 5000) + } + + assertTrue(pingLatch.await(5, TimeUnit.SECONDS)) + assertTrue(pingReceived) + assertTrue(latency >= 0) + assertNotNull(HermesWsClient.lastLatencyMs.value) + assertEquals(latency, HermesWsClient.lastLatencyMs.value) + assertTrue(HermesWsClient.lastPongTimestamp > 0) + } } From 6ec277af8b473b73a1246eb60985ac0e067b00d5 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:18:45 +0300 Subject: [PATCH 16/20] =?UTF-8?q?feat(mcp):=20MCP=20dashboard=20enhancemen?= =?UTF-8?q?ts=20=E2=80=94=20JSON=20config=20import,=20token=20overhead=20b?= =?UTF-8?q?adges,=20and=20batch=20health=20testing=20(#1037)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../m57/hermescontrol/data/model/McpServer.kt | 17 ++ .../data/remote/HermesApiService.kt | 3 +- .../m57/hermescontrol/ui/mcp/McpJsonParser.kt | 287 ++++++++++++++++++ .../hermescontrol/ui/mcp/McpServersScreen.kt | 240 ++++++++++++++- .../ui/mcp/McpServersViewModel.kt | 159 +++++++++- app/src/main/res/values-ko/strings.xml | 10 + app/src/main/res/values-zh/strings.xml | 10 + app/src/main/res/values/strings.xml | 10 + .../hermescontrol/ui/mcp/McpJsonParserTest.kt | 244 +++++++++++++++ 9 files changed, 963 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/mcp/McpJsonParser.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/mcp/McpJsonParserTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt b/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt index cf351590..69ba7c59 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt @@ -1,4 +1,5 @@ package com.m57.hermescontrol.data.model +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable @@ -18,6 +19,22 @@ data class McpServer( val status: String? = null, val error: String? = null, val auth: String? = null, + val tools: List? = null, +) + +@Serializable +data class McpServerTestResponse( + val ok: Boolean = false, + val error: String? = null, + val tools: List = emptyList(), +) + +@Serializable +data class McpServerToolInfo( + val name: String, + val description: String? = null, + @SerialName("schema_chars") + val schemaChars: Int? = null, ) @Serializable diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index e6d5149c..3be6c802 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -51,6 +51,7 @@ import com.m57.hermescontrol.data.model.McpCatalogInstallRequest import com.m57.hermescontrol.data.model.McpCatalogResponse import com.m57.hermescontrol.data.model.McpOAuthFlowResponse import com.m57.hermescontrol.data.model.McpServer +import com.m57.hermescontrol.data.model.McpServerTestResponse import com.m57.hermescontrol.data.model.McpServerToggleRequest import com.m57.hermescontrol.data.model.McpServersResponse import com.m57.hermescontrol.data.model.MemoryProviderConfigResponse @@ -519,7 +520,7 @@ interface HermesApiService { @POST("api/mcp/servers/{name}/test") suspend fun testMcpServer( @Path("name") name: String, - ): Response + ): Response @DELETE("api/mcp/servers/{name}") suspend fun deleteMcpServer( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpJsonParser.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpJsonParser.kt new file mode 100644 index 00000000..393a35fa --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpJsonParser.kt @@ -0,0 +1,287 @@ +package com.m57.hermescontrol.ui.mcp + +import com.m57.hermescontrol.data.model.AddMcpServerRequest +import com.m57.hermescontrol.data.model.McpServerTestResponse +import com.m57.hermescontrol.data.model.McpServerToolInfo +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.util.Locale +import kotlin.math.ceil + +/** + * Client-side parser for MCP server JSON configs (Claude Desktop / Cursor formats, single server JSON). + * Issue #1029. + */ +object McpJsonParser { + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + } + + /** + * Parses pasted JSON text into a list of [AddMcpServerRequest] models. + * Returns an empty list if no valid server entries are found or if parsing fails. + */ + fun parse(text: String): List { + val trimmed = text.trim() + if (trimmed.isEmpty()) return emptyList() + + val rootElement: JsonElement = + try { + json.parseToJsonElement(trimmed) + } catch (_: Exception) { + return emptyList() + } + + if (rootElement !is JsonObject) return emptyList() + + // 1. Check for wrapped "mcpServers" or "mcp_servers" container + val wrapper = rootElement["mcpServers"] ?: rootElement["mcp_servers"] + if (wrapper is JsonObject) { + return parseServerMap(wrapper) + } + + // 2. Check if the root object itself is a single server definition (has "command" or "url" directly) + if (isServerObject(rootElement)) { + val name = + rootElement["name"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } + ?: inferName(rootElement) + val req = parseSingleServer(name, rootElement) + return if (req != null) listOf(req) else emptyList() + } + + // 3. Check if the root object is a dictionary of name -> serverObject + val mapEntries = parseServerMap(rootElement) + if (mapEntries.isNotEmpty()) { + return mapEntries + } + + return emptyList() + } + + private fun parseServerMap(obj: JsonObject): List { + val results = mutableListOf() + for ((name, element) in obj) { + if (element is JsonObject && isServerObject(element)) { + val req = parseSingleServer(name, element) + if (req != null) { + results.add(req) + } + } + } + return results + } + + private fun isServerObject(obj: JsonObject): Boolean = + obj.containsKey("command") || obj.containsKey("url") || obj.containsKey("transport") + + private fun parseSingleServer( + name: String, + obj: JsonObject, + ): AddMcpServerRequest? { + val cleanName = name.trim() + if (cleanName.isEmpty()) return null + + val url = + obj["url"] + ?.jsonPrimitive + ?.contentOrNull + ?.trim() + ?.takeIf { it.isNotEmpty() } + val command = + obj["command"] + ?.jsonPrimitive + ?.contentOrNull + ?.trim() + ?.takeIf { it.isNotEmpty() } + + if (url == null && command == null) return null + + val args: List? = + when (val argsElement = obj["args"]) { + is JsonArray -> { + argsElement.mapNotNull { + (it as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull + } + } + + else -> { + null + } + } + + val env: Map? = + when (val envElement = obj["env"]) { + is JsonObject -> { + envElement.entries + .mapNotNull { (k, v) -> + (v as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull?.let { k to it } + }.toMap() + .takeIf { it.isNotEmpty() } + } + + else -> { + null + } + } + + // Auth detection from "auth" or "headers" + var auth = (obj["auth"] as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull?.lowercase() + var bearerToken = (obj["bearerToken"] as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull + + val headers = obj["headers"] + if (headers is JsonObject) { + val authHeader = + (headers["Authorization"] as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull + ?: (headers["authorization"] as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull + if (authHeader != null) { + auth = "header" + if (authHeader.startsWith("Bearer ", ignoreCase = true)) { + bearerToken = authHeader.substring(7).trim() + } else { + bearerToken = authHeader.trim() + } + } + } + + val finalAuth = + when (auth) { + "oauth" -> "oauth" + "header" -> "header" + else -> "none" + } + + return AddMcpServerRequest( + name = cleanName, + url = if (command == null) url else null, + command = command, + args = args, + env = env, + auth = finalAuth, + bearerToken = if (finalAuth == "header") bearerToken else null, + ) + } + + private fun inferName(obj: JsonObject): String { + val command = (obj["command"] as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull?.trim() + val args = obj["args"] + if (!command.isNullOrEmpty()) { + if (args is JsonArray && args.isNotEmpty()) { + val firstArg = (args[0] as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull + if (firstArg != null && !firstArg.startsWith("-")) { + return sanitizeName(firstArg.split("/").last()) + } + } + return sanitizeName(command.split("/").last()) + } + val url = (obj["url"] as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull?.trim() + if (!url.isNullOrEmpty()) { + return try { + val host = java.net.URI(url).host ?: "server" + host.split(".").firstOrNull { it != "api" && it != "www" && it != "mcp" } ?: "server" + } catch (_: Exception) { + "server" + } + } + return "server" + } + + private fun sanitizeName(raw: String): String = + raw + .replace(Regex("""\.(cjs|js|mjs|py|ts)$""", RegexOption.IGNORE_CASE), "") + .replace(Regex("""^(mcp-server-|server-|mcp-)"""), "") + .replace(Regex("""(-mcp-server|-mcp|-server)$"""), "") + .ifEmpty { "server" } +} + +/** + * Token overhead estimator for MCP server tool schemas (issue #1029). + */ +object McpTokenEstimator { + /** + * Approximate token overhead for a server's tool schemas. + * Sum of ceil(schema_chars / 4.0) over tools carrying valid schema_chars. + */ + fun estimateTokens(tools: List): Int? { + var total = 0 + var sawValid = false + for (tool in tools) { + val chars = tool.schemaChars + if (chars != null && chars > 0) { + total += ceil(chars / 4.0).toInt() + sawValid = true + } + } + return if (sawValid) total else null + } + + fun formatTokenOverhead( + toolCount: Int, + tokenEstimate: Int?, + ): String { + if (tokenEstimate == null) { + return if (toolCount == 1) "1 tool" else "$toolCount tools" + } + val tokenFormatted = + if (tokenEstimate >= 1000) { + String.format(Locale.US, "%.1fk", tokenEstimate / 1000.0) + } else { + tokenEstimate.toString() + } + val toolStr = if (toolCount == 1) "1 tool" else "$toolCount tools" + return "$toolStr • ~$tokenFormatted tokens" + } +} + +/** + * Health status for visual color-coded badges (issue #1029). + */ +enum class McpHealthStatus { + HEALTHY, + AUTH_REQUIRED, + ERROR, + TESTING, + UNKNOWN, + ; + + companion object { + fun resolve( + isTesting: Boolean, + testResult: McpServerTestResponse?, + serverStatus: String?, + serverError: String?, + ): McpHealthStatus { + if (isTesting) return TESTING + if (testResult != null) { + if (testResult.ok) return HEALTHY + val err = (testResult.error ?: "").lowercase() + if (err.contains("oauth") || err.contains("auth") || err.contains("token")) { + return AUTH_REQUIRED + } + return ERROR + } + if (!serverError.isNullOrBlank()) { + val err = serverError.lowercase() + if (err.contains("oauth") || err.contains("auth") || err.contains("token")) { + return AUTH_REQUIRED + } + return ERROR + } + if (serverStatus != null) { + return when (serverStatus.lowercase()) { + "running", "ok", "connected", "healthy" -> HEALTHY + "error", "failed" -> ERROR + else -> UNKNOWN + } + } + return UNKNOWN + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt index 9e02924a..8548b5c7 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt @@ -22,11 +22,15 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Science import androidx.compose.material.icons.filled.Storage +import androidx.compose.material.icons.filled.UploadFile import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card @@ -41,6 +45,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SecondaryTabRow +import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Tab import androidx.compose.material3.Text @@ -54,8 +59,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType @@ -90,6 +97,7 @@ fun McpServersScreen( val state by viewModel.uiState.collectAsStateWithLifecycle() val spacing = LocalSpacing.current val context = LocalContext.current + val clipboardManager = LocalClipboardManager.current var query by remember { mutableStateOf("") } var showDetail by remember { mutableStateOf(null) } @@ -123,6 +131,32 @@ fun McpServersScreen( navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, isRefreshing = state.isLoading, onRefresh = { viewModel.loadServers() }, + actions = { + IconButton( + onClick = { viewModel.testAllServers() }, + enabled = !state.isTestingAll && state.servers.any { it.enabled }, + ) { + if (state.isTestingAll) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + ) + } else { + Icon( + imageVector = Icons.Filled.Science, + contentDescription = stringResource(R.string.mcp_servers_action_test_all), + ) + } + } + IconButton( + onClick = { viewModel.toggleImportDialog() }, + ) { + Icon( + imageVector = Icons.Filled.UploadFile, + contentDescription = stringResource(R.string.mcp_servers_action_import_json), + ) + } + }, ) { paddingValues -> when { state.isLoading && state.servers.isEmpty() -> { @@ -272,6 +306,95 @@ fun McpServersScreen( }, ) } + + if (state.showImportDialog) { + androidx.compose.material3.AlertDialog( + onDismissRequest = { viewModel.toggleImportDialog() }, + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Filled.UploadFile, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(spacing.sm)) + Text(stringResource(R.string.mcp_servers_import_dialog_title)) + } + }, + text = { + Column { + Text( + text = stringResource(R.string.mcp_servers_import_dialog_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(spacing.sm)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + OutlinedButton( + onClick = { + val clip = clipboardManager.getText()?.text + if (!clip.isNullOrBlank()) { + viewModel.updateImportJsonInput(clip) + } + }, + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp), + ) { + Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp)) + Spacer(modifier = Modifier.width(spacing.xs)) + Text( + text = stringResource(R.string.mcp_servers_import_dialog_paste), + style = MaterialTheme.typography.labelSmall, + ) + } + } + Spacer(modifier = Modifier.height(spacing.xs)) + OutlinedTextField( + value = state.importJsonInput, + onValueChange = viewModel::updateImportJsonInput, + placeholder = { + Text( + "{\n \"mcpServers\": {\n \"name\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@mcp/srv\"]\n }\n }\n}", + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + ) + }, + textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + modifier = + Modifier + .fillMaxWidth() + .height(180.dp), + maxLines = 10, + ) + } + }, + confirmButton = { + Button( + onClick = { viewModel.submitImportJson() }, + enabled = !state.isImportingJson && state.importJsonInput.isNotBlank(), + ) { + if (state.isImportingJson) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(modifier = Modifier.width(spacing.xs)) + Text(stringResource(R.string.mcp_servers_import_dialog_importing)) + } else { + Text(stringResource(R.string.mcp_servers_action_submit)) + } + } + }, + dismissButton = { + OutlinedButton(onClick = { viewModel.toggleImportDialog() }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } } // ── Section header (MCP-specific: distinct icon + neutral title so it reads @@ -325,15 +448,29 @@ private fun AddServerSection( icon = Icons.Filled.Add, title = stringResource(R.string.mcp_servers_add_server), trailing = { - TextButton( - onClick = { viewModel.toggleAddForm() }, - colors = - ButtonDefaults.textButtonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - ), - ) { - Text(if (state.showAddForm) "Hide" else "New") + Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) { + TextButton( + onClick = { viewModel.toggleImportDialog() }, + colors = + ButtonDefaults.textButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Icon(Icons.Filled.UploadFile, contentDescription = null, modifier = Modifier.size(14.dp)) + Spacer(modifier = Modifier.width(spacing.xs)) + Text(stringResource(R.string.mcp_servers_action_import_json)) + } + TextButton( + onClick = { viewModel.toggleAddForm() }, + colors = + ButtonDefaults.textButtonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + ) { + Text(if (state.showAddForm) "Hide" else "New") + } } }, ) @@ -463,6 +600,9 @@ private fun ServerCard( onOpenBrowser: (String) -> Unit, ) { var showEnv by remember { mutableStateOf(false) } + val isTesting = server.name in state.testingServers + val testResult = state.serverTestResults[server.name] + val healthStatus = McpHealthStatus.resolve(isTesting, testResult, server.status, server.error) Card( modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), @@ -491,13 +631,77 @@ private fun ServerCard( fontWeight = FontWeight.Bold, ) Spacer(modifier = Modifier.width(spacing.sm)) - ServerStatusBadge(server.status) + when (healthStatus) { + McpHealthStatus.HEALTHY -> { + StatusBadge( + text = stringResource(R.string.mcp_servers_status_healthy), + status = StatusBadgeType.SUCCESS, + ) + } + + McpHealthStatus.AUTH_REQUIRED -> { + StatusBadge( + text = stringResource(R.string.mcp_servers_status_needs_auth), + status = StatusBadgeType.WARNING, + ) + } + + McpHealthStatus.ERROR -> { + StatusBadge( + text = stringResource(R.string.mcp_servers_status_error), + status = StatusBadgeType.ERROR, + ) + } + + McpHealthStatus.TESTING -> { + StatusBadge( + text = stringResource(R.string.mcp_servers_status_testing), + status = StatusBadgeType.INFO, + ) + } + + McpHealthStatus.UNKNOWN -> { + ServerStatusBadge(server.status) + } + } } Text( text = stringResource(R.string.mcp_servers_label_transport, server.transport ?: "stdio"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + + // Tool count & schema token overhead badge (issue #1029) + val toolInfos = testResult?.tools + val toolCount = toolInfos?.size ?: server.tools?.size + if (toolCount != null && toolCount > 0) { + val tokenEst = toolInfos?.let { McpTokenEstimator.estimateTokens(it) } + val label = McpTokenEstimator.formatTokenOverhead(toolCount, tokenEst) + Spacer(modifier = Modifier.height(spacing.xs)) + Surface( + color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f), + shape = RoundedCornerShape(6.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = spacing.xs + 2.dp, vertical = 2.dp), + ) { + Icon( + imageVector = Icons.Filled.Build, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer, + ) + Spacer(modifier = Modifier.width(spacing.xs)) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + fontWeight = FontWeight.Medium, + ) + } + } + } } Switch( checked = server.enabled, @@ -556,12 +760,22 @@ private fun ServerCard( } FilledTonalButton( onClick = { viewModel.testServer(server.name) }, + enabled = !isTesting, modifier = Modifier.weight(1f), contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp), ) { - Icon(Icons.Filled.Science, contentDescription = null, modifier = Modifier.size(16.dp)) - Spacer(modifier = Modifier.width(spacing.xs)) - Text(stringResource(R.string.mcp_servers_action_test), maxLines = 1) + if (isTesting) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.width(spacing.xs)) + Text(stringResource(R.string.mcp_servers_status_testing), maxLines = 1) + } else { + Icon(Icons.Filled.Science, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(spacing.xs)) + Text(stringResource(R.string.mcp_servers_action_test), maxLines = 1) + } } IconButton(onClick = { viewModel.deleteServer(server.name) }) { Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.action_delete)) 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 de370b11..4d1d119e 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 @@ -7,6 +7,7 @@ import com.m57.hermescontrol.data.model.McpCatalogEntry import com.m57.hermescontrol.data.model.McpCatalogInstallRequest import com.m57.hermescontrol.data.model.McpOAuthFlowResponse import com.m57.hermescontrol.data.model.McpServer +import com.m57.hermescontrol.data.model.McpServerTestResponse import com.m57.hermescontrol.data.model.McpServerToggleRequest import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.NetworkResult @@ -14,6 +15,8 @@ import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.safeLaunchLoad import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -38,6 +41,14 @@ data class McpServersUiState( val addServerAuth: String = "none", // "none" | "header" | "oauth" val addServerBearerToken: String = "", val addingServer: Boolean = false, + // JSON Import (issue #1029) + val showImportDialog: Boolean = false, + val importJsonInput: String = "", + val isImportingJson: Boolean = false, + // Batch health & tool test results (issue #1029) + val serverTestResults: Map = emptyMap(), + val testingServers: Set = emptySet(), + val isTestingAll: Boolean = false, // Env vars for editing val editingEnvFor: String? = null, val envKeyInput: String = "", @@ -69,6 +80,8 @@ class McpServersViewModel : it.copy( isLoading = false, servers = data.servers.orEmpty(), + serverTestResults = emptyMap(), + testingServers = emptySet(), ) } }, @@ -126,23 +139,105 @@ class McpServersViewModel : fun testServer(name: String) { viewModelScope.launch { - _uiState.update { it.copy(toastMessage = "Testing server '$name'…") } + _uiState.update { + it.copy( + testingServers = it.testingServers + name, + toastMessage = "Testing server '$name'…", + ) + } val result = withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.testMcpServer(name) } } when (result) { is NetworkResult.Success -> { - _uiState.update { it.copy(toastMessage = "Server '$name' tested — OK") } + val response = result.data + val toolCount = response.tools.size + val tokenEst = McpTokenEstimator.estimateTokens(response.tools) + val tokenStr = McpTokenEstimator.formatTokenOverhead(toolCount, tokenEst) + _uiState.update { state -> + state.copy( + serverTestResults = state.serverTestResults + (name to response), + testingServers = state.testingServers - name, + toastMessage = + if (response.ok) { + "Server '$name' tested — OK ($tokenStr)" + } else { + "Server '$name' test failed: ${response.error ?: "unknown error"}" + }, + ) + } } is NetworkResult.Failure -> { - _uiState.update { it.copy(toastMessage = "Server '$name' test failed: ${result.error.message}") } + val errResp = McpServerTestResponse(ok = false, error = result.error.message) + _uiState.update { state -> + state.copy( + serverTestResults = state.serverTestResults + (name to errResp), + testingServers = state.testingServers - name, + toastMessage = "Server '$name' test failed: ${result.error.message}", + ) + } } } } } + fun testAllServers() { + val enabledServers = _uiState.value.servers.filter { it.enabled } + if (enabledServers.isEmpty()) { + _uiState.update { it.copy(toastMessage = "No enabled servers to test") } + return + } + + viewModelScope.launch { + val names = enabledServers.map { it.name }.toSet() + _uiState.update { + it.copy( + isTestingAll = true, + testingServers = it.testingServers + names, + toastMessage = "Testing ${enabledServers.size} server(s)…", + ) + } + + val testJobs = + enabledServers.map { server -> + async(Dispatchers.IO) { + val res = safeApiCall { ApiClient.hermesApi.testMcpServer(server.name) } + server.name to res + } + } + + val results = testJobs.awaitAll() + val newResults = mutableMapOf() + var passCount = 0 + + for ((name, result) in results) { + when (result) { + is NetworkResult.Success -> { + newResults[name] = result.data + if (result.data.ok) passCount++ + } + + is NetworkResult.Failure -> { + newResults[name] = McpServerTestResponse(ok = false, error = result.error.message) + } + } + } + + val failCount = enabledServers.size - passCount + _uiState.update { state -> + state.copy( + isTestingAll = false, + serverTestResults = state.serverTestResults + newResults, + testingServers = state.testingServers - names, + toastMessage = + "Tested ${enabledServers.size} servers: $passCount passed, $failCount failed", + ) + } + } + } + fun deleteServer(name: String) { viewModelScope.launch { val result = @@ -162,6 +257,64 @@ class McpServersViewModel : } } + // ── JSON Import (issue #1029) ──────────────────────────── + + fun toggleImportDialog() { + _uiState.update { it.copy(showImportDialog = !it.showImportDialog, importJsonInput = "") } + } + + fun updateImportJsonInput(v: String) { + _uiState.update { it.copy(importJsonInput = v) } + } + + fun submitImportJson() { + val input = _uiState.value.importJsonInput + val requests = McpJsonParser.parse(input) + if (requests.isEmpty()) { + _uiState.update { it.copy(toastMessage = "Invalid JSON or no MCP servers found in input") } + return + } + + _uiState.update { it.copy(isImportingJson = true) } + viewModelScope.launch { + val importJobs = + requests.map { req -> + async(Dispatchers.IO) { + val result = safeApiCall { ApiClient.hermesApi.addMcpServer(req) } + req to result + } + } + + val results = importJobs.awaitAll() + var successCount = 0 + val errors = mutableListOf() + + for ((req, result) in results) { + when (result) { + is NetworkResult.Success -> successCount++ + is NetworkResult.Failure -> errors.add("${req.name}: ${result.error.message}") + } + } + + _uiState.update { state -> + val errSummary = errors.joinToString("; ") + val msg = + when { + errors.isEmpty() -> "Successfully imported $successCount server(s)" + successCount > 0 -> "Imported $successCount server(s), ${errors.size} failed: $errSummary" + else -> "Failed to import: $errSummary" + } + state.copy( + isImportingJson = false, + showImportDialog = false, + importJsonInput = "", + toastMessage = msg, + ) + } + loadServers() + } + } + // ── Add server form ────────────────────────────────────── fun toggleAddForm() { diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 748d77e4..fea9892d 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -583,6 +583,16 @@ 전송: %1$s 명령어: 테스트 + 모두 테스트 + JSON 가져오기 + MCP 서버 가져오기 + Claude Desktop / Cursor mcpServers JSON 스니펫 또는 서버 정의를 붙여넣으세요. + 클립보드 붙여넣기 + 가져오는 중… + 정상 + 인증 필요 + 오류 + 테스트 중… 서버 추가 HTTP URL Stdio 명령어 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 00e6c408..552b6b27 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -720,6 +720,16 @@ 传输:%1$s 命令: 测试 + 测试全部 + 导入 JSON + 导入 MCP 服务器 + 粘贴 Claude Desktop / Cursor mcpServers JSON 片段或服务器定义。 + 从剪贴板粘贴 + 导入中… + 正常 + 需要认证 + 错误 + 测试中… 添加服务器 HTTP 地址 没有 MCP 服务器 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0ae0b120..656ee4a8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -812,6 +812,16 @@ Transport: %1$s Command: Test + Test All + Import JSON + Import MCP Servers + Paste Claude Desktop / Cursor mcpServers JSON snippet or server definition. + Paste Clipboard + Importing… + Healthy + Needs Auth + Error + Testing… Add server HTTP URL No MCP servers diff --git a/app/src/test/java/com/m57/hermescontrol/ui/mcp/McpJsonParserTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/mcp/McpJsonParserTest.kt new file mode 100644 index 00000000..720f9d3d --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/mcp/McpJsonParserTest.kt @@ -0,0 +1,244 @@ +package com.m57.hermescontrol.ui.mcp + +import com.m57.hermescontrol.data.model.McpServerTestResponse +import com.m57.hermescontrol.data.model.McpServerToolInfo +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class McpJsonParserTest { + @Test + fun testParseClaudeDesktopMcpServersWrapper() { + val json = + """ + { + "mcpServers": { + "sqlite": { + "command": "uvx", + "args": ["mcp-server-sqlite", "--db-path", "/path/to/db.sqlite"], + "env": { + "DEBUG": "1" + } + }, + "github": { + "url": "https://api.github.com/mcp", + "headers": { + "Authorization": "Bearer gh_secret_123" + } + } + } + } + """.trimIndent() + + val parsed = McpJsonParser.parse(json) + assertEquals(2, parsed.size) + + val sqlite = parsed.find { it.name == "sqlite" } + assertNotNull(sqlite) + assertEquals("uvx", sqlite?.command) + assertEquals(listOf("mcp-server-sqlite", "--db-path", "/path/to/db.sqlite"), sqlite?.args) + assertEquals(mapOf("DEBUG" to "1"), sqlite?.env) + assertEquals("none", sqlite?.auth) + + val github = parsed.find { it.name == "github" } + assertNotNull(github) + assertEquals("https://api.github.com/mcp", github?.url) + assertEquals("header", github?.auth) + assertEquals("gh_secret_123", github?.bearerToken) + } + + @Test + fun testParseSnakeCaseMcpServersWrapper() { + val json = + """ + { + "mcp_servers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + } + } + } + """.trimIndent() + + val parsed = McpJsonParser.parse(json) + assertEquals(1, parsed.size) + assertEquals("filesystem", parsed[0].name) + assertEquals("npx", parsed[0].command) + assertEquals(listOf("-y", "@modelcontextprotocol/server-filesystem", "/tmp"), parsed[0].args) + } + + @Test + fun testParseDirectServerMap() { + val json = + """ + { + "postgres": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-postgres", "postgresql://localhost/db"] + } + } + """.trimIndent() + + val parsed = McpJsonParser.parse(json) + assertEquals(1, parsed.size) + assertEquals("postgres", parsed[0].name) + assertEquals("npx", parsed[0].command) + } + + @Test + fun testParseSingleServerObject() { + val json = + """ + { + "name": "custom-tools", + "command": "python3", + "args": ["-m", "mcp_server"], + "auth": "oauth" + } + """.trimIndent() + + val parsed = McpJsonParser.parse(json) + assertEquals(1, parsed.size) + assertEquals("custom-tools", parsed[0].name) + assertEquals("python3", parsed[0].command) + assertEquals("oauth", parsed[0].auth) + } + + @Test + fun testParseInvalidJsonReturnsEmpty() { + val parsed = McpJsonParser.parse("not json at all") + assertTrue(parsed.isEmpty()) + } + + @Test + fun testParseMalformedArgsAndEnvDoesNotCrash() { + val json = + """ + { + "mcpServers": { + "faulty": { + "command": "npx", + "args": ["valid", {"nested": "bad"}, ["array"], 123], + "env": { + "VALID_KEY": "valid_val", + "BAD_KEY": {"nested": "bad"}, + "BAD_LIST": [1, 2] + } + } + } + } + """.trimIndent() + + val parsed = McpJsonParser.parse(json) + assertEquals(1, parsed.size) + val server = parsed[0] + assertEquals("faulty", server.name) + assertEquals(listOf("valid", "123"), server.args) + assertEquals(mapOf("VALID_KEY" to "valid_val"), server.env) + } + + @Test + fun testTokenOverheadEstimator() { + val tools = + listOf( + McpServerToolInfo(name = "read_file", schemaChars = 400), + McpServerToolInfo(name = "write_file", schemaChars = 401), + McpServerToolInfo(name = "no_chars", schemaChars = null), + ) + + // ceil(400/4) = 100, ceil(401/4) = 101 -> sum = 201 + val est = McpTokenEstimator.estimateTokens(tools) + assertEquals(201, est) + + val formatted = McpTokenEstimator.formatTokenOverhead(tools.size, est) + assertEquals("3 tools • ~201 tokens", formatted) + } + + @Test + fun testTokenOverheadLargeFormatting() { + val tools = + listOf( + McpServerToolInfo(name = "tool_huge", schemaChars = 12800), + ) + + // ceil(12800/4) = 3200 tokens -> ~3.2k tokens + val est = McpTokenEstimator.estimateTokens(tools) + assertEquals(3200, est) + val formatted = McpTokenEstimator.formatTokenOverhead(49, est) + assertEquals("49 tools • ~3.2k tokens", formatted) + } + + @Test + fun testTokenOverheadNoSchemaChars() { + val tools = + listOf( + McpServerToolInfo(name = "tool1", schemaChars = null), + McpServerToolInfo(name = "tool2", schemaChars = 0), + ) + + val est = McpTokenEstimator.estimateTokens(tools) + assertNull(est) + assertEquals("2 tools", McpTokenEstimator.formatTokenOverhead(2, est)) + } + + @Test + fun testHealthStatusResolution() { + // In flight + assertEquals( + McpHealthStatus.TESTING, + McpHealthStatus.resolve( + isTesting = true, + testResult = null, + serverStatus = "ok", + serverError = null, + ), + ) + + // Healthy result + assertEquals( + McpHealthStatus.HEALTHY, + McpHealthStatus.resolve( + isTesting = false, + testResult = McpServerTestResponse(ok = true), + serverStatus = null, + serverError = null, + ), + ) + + // Auth needed + assertEquals( + McpHealthStatus.AUTH_REQUIRED, + McpHealthStatus.resolve( + isTesting = false, + testResult = McpServerTestResponse(ok = false, error = "OAuth authentication required"), + serverStatus = null, + serverError = null, + ), + ) + + // Error + assertEquals( + McpHealthStatus.ERROR, + McpHealthStatus.resolve( + isTesting = false, + testResult = McpServerTestResponse(ok = false, error = "Process exited with 1"), + serverStatus = null, + serverError = null, + ), + ) + + // Fallback from status + assertEquals( + McpHealthStatus.HEALTHY, + McpHealthStatus.resolve( + isTesting = false, + testResult = null, + serverStatus = "running", + serverError = null, + ), + ) + } +} From 7c00f4407dfe722567ea5b6b6417a999df0ed415 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:34:11 +0300 Subject: [PATCH 17/20] feat(i18n): add comprehensive Arabic localization and RTL layout support (#1039) --- .../ui/settings/components/LanguageSection.kt | 1 + .../util/LocaleContextWrapper.kt | 1 + app/src/main/res/values-ar/strings.xml | 1187 +++++++++++++++++ app/src/main/res/values-ko/strings.xml | 8 + app/src/main/res/values-zh/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 6 files changed, 1199 insertions(+) create mode 100644 app/src/main/res/values-ar/strings.xml diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/LanguageSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/LanguageSection.kt index 0eab7cdc..d1aebeee 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/LanguageSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/LanguageSection.kt @@ -32,6 +32,7 @@ internal fun supportedLanguages(): List> = listOf( LocaleContextWrapper.SYSTEM_LANGUAGE to stringResource(R.string.language_system), "en" to stringResource(R.string.language_english), + "ar" to stringResource(R.string.language_arabic), "zh" to stringResource(R.string.language_chinese), "ko" to stringResource(R.string.language_korean), ) diff --git a/app/src/main/java/com/m57/hermescontrol/util/LocaleContextWrapper.kt b/app/src/main/java/com/m57/hermescontrol/util/LocaleContextWrapper.kt index 11508623..8ab202ba 100644 --- a/app/src/main/java/com/m57/hermescontrol/util/LocaleContextWrapper.kt +++ b/app/src/main/java/com/m57/hermescontrol/util/LocaleContextWrapper.kt @@ -79,6 +79,7 @@ object LocaleContextWrapper { @Suppress("DEPRECATION") setLocale(locale) } + setLayoutDirection(locale) } return base.createConfigurationContext(newConfig) } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml new file mode 100644 index 00000000..03f09da1 --- /dev/null +++ b/app/src/main/res/values-ar/strings.xml @@ -0,0 +1,1187 @@ + + +Hermes Mobile +إرسال +جارٍ الاستماع… +يلزم الحصول على إذن الميكروفون للإدخال الصوتي +التعرف على الصوت غير متوفر على هذا الجهاز +إرسال +تجاهل +مطلوب كلمة مرور Sudo +يحتاج الوكيل إلى كلمة مرور sudo الخاصة بك للمتابعة. لن يتم عرضها. +كلمة المرور +مطلوب قيمة سرية +يحتاج الوكيل إلى قيمة سرية (رمز Token أو كلمة مرور) للمتابعة. لن يتم عرضها. +القيمة السرية +البحث في الرسائل\u2026 +مسح +النتيجة السابقة +النتيجة التالية +إغلاق البحث +إعادة المحاولة +إلغاء +تأكيد +مسح +موافق +جارٍ البدء… +قيد التشغيل… +اكتمل +فشل +فشل (رمز الخروج %1$d) +حذف +رجوع +فتح درج التنقل +تحديث +حفظ +تصفح مركز المهارات (Skill Hub) +إعادة محاولة البحث +إنشاء مهمتك الأولى +إضافة خادم +ربط قناة Telegram +تهيئة النموذج +بدء محادثة جديدة +استكشاف المهارات أو مهام Cron +مسح البحث +إلغاء +مسح البحث +خطأ غير معروف +مجاني +الاستخدام +إلغاء +يتجدد في: %1$s +الفوترة غير متوفرة على هذا الخادم (الطريقة غير مدعومة) +لا توجد خطة نشطة — أنت على الفئة المجانية. +سجّل الدخول إلى Nous Portal لعرض خطتك. +الرصيد المتبقي: %1$s +الخطة +شحن الرصيد +اضغط رجوع مرة أخرى للخروج +Hermes +التحكم في وكيل الذكاء الاصطناعي +محادثة +أتمتة +تهيئة +فحص +المحادثة +المهارات +Cron +النظام +الإعدادات +الملفات الشخصية +البوتات +نشط الآن +لم يتم العثور على بوتات +أنشئ ملف وكيل شخصي للبدء في استخدام وضع البوت. +البحث في البوتات… +مخفي +إظهار البوتات المخفية +إخفاء البوتات المخفية +إنشاء بوت +بوت جديد +المعرّف / الاسم اللطيف +مثال: scout، reviewer +الاسم المعروض +مثال: وكيل الاستكشاف +الوصف +ما هي مهام هذا الوكيل؟ +شكل الصورة الرمزية +لون التمييز +إنشاء +إنشاء مجموعة دردشة +مجموعة دردشة جديدة +اسم المجموعة +مثال: فريق البحث +تحديد البوتات (2 على الأقل) +إنشاء مجموعة +البوتات +المجموعات +لا توجد مجموعات دردشة +أنشئ مجموعة دردشة متعددة الوكلاء لتمكين البوتات من التعاون. +%1$d بوت +حلّ المجموعة +هل تريد حلّ %1$s؟ +سيؤدي هذا إلى إزالة المحادثة الجماعية من جميع البوتات الأعضاء. لن يتم حذف البوتات الفردية. +تعديل البوت +حفظ التغييرات +حذف البوت +هل تريد حذف %1$s؟ +سيؤدي هذا إلى حذف الملف التعريفي للوكيل وإعداداته نهائياً. +خطافات الويب (Webhooks) +البوابة +مجموعات الأدوات +الإضافات +الإعدادات +خوادم MCP +الذاكرة +النماذج +الاقتران +المفاتيح +القنوات +السجلات +كانبان +الإنجازات +السجل +العمليات +المزوّدون +التحليلات +الفواتير والاشتراكات +الملفات +Hermes +بانتظار ردود Hermes +رسالة جديدة +يحتاج Hermes إلى توضيح +اكتب ردك\u2026 +خدمة Hermes في الخلفية +تحافظ على استمرار الاتصال في الخلفية +ردود محادثة Hermes +تنبيهات عند استلام ردود جديدة من Hermes +رد +محادثات البوت هي محادثة مستمرة واحدة — يتم ضغطها بدلاً من ذلك. للحصول على جلسة مؤقتة، أنشئ محادثة قياسية. +بحث +إغلاق البحث +إعادة الاتصال +إعادة تسجيل الدخول +إعادة تسجيل الدخول إلى Hermes +اسم المستخدم +كلمة المرور +تسجيل الدخول +إلغاء +لا يمكن ترك اسم المستخدم وكلمة المرور فارغين +جاهز للدردشة +أرسل رسالة لبدء محادثة +جارٍ الاتصال بـ Hermes\u2026 +تعذر الاتصال بهذه الجلسة +جارٍ إعادة الاتصال\u2026 +تم قطع الاتصال +لا يتوفر اتصال بالشبكة +انتهت صلاحية الجلسة +غير متصل\u2026 +في انتظار الرد\u2026 +في قائمة الانتظار \u2014 سيتم الإرسال عند تفرغ الوكيل\u2026 +اكتب رسالة\u2026 +خطأ في التنفيذ: %1$s +رمز الخروج: %1$d +أداة +عرض ملف JSON الأولي +عرض المخرجات المعالجة +محادثة جديدة +التمرير إلى الأسفل +نسخ +تعديل +إزالة المرفق +تعذر التقاط الصورة +الاتصال +رمز المصادقة (Token) +السلوك +إعادة الاتصال التلقائي +إعادة الاتصال تلقائيًا عند انقطاعه +المظهر +السمة +المحادثة +تأثير الكتابة +تحريك ظهور الرسائل الجديدة كلمة بكلمة +المساعد +التأخير: %1$d مللي ثانية +10 مللي ثانية +100 مللي ثانية +حجم خط المحادثة +تغيير حجم النص في رسائل المحادثة +حجم الخط: %1$s +صغير (0.85×) +افتراضي (1.0×) +كبير (1.15×) +كبير جدًا (1.3×) +ضخم (1.5×) +معاينة +هل يمكنك ضبط حجم نص المحادثة؟ +تم! يمكنك تخصيص مقياس الخط من هنا مباشرةً. +اختبار الاتصال +حول التطبيق +اللغة +Hermes Mobile +الإصدار +رقم البنية + تصحيح الأخطاء (Debug) + إصدار الإنتاج (Release) + الإيداع (Commit) + التحديث + التحقق من وجود تحديثات + جارٍ التحقق… + التطبيق محدّث (v%1$s) + يتوفر تحديث جديد: v%1$s + تحديث + جارٍ التنزيل %1$d%% + جارٍ تثبيت v%1$s… + السماح بتثبيت التطبيقات من هذا المصدر + فتح الإعدادات + إعادة المحاولة + تم حفظ %1$d + تأثير الكتابة مفعّل + تأثير الكتابة معطّل + مفعّل + معطّل + تلقائي (حسب النظام) + فاتح + داكن + حفظ + إضافة ملف شخصي + تعديل الملف الشخصي + إضافة ملف اتصال + تعديل ملف الاتصال + اسم ملف الاتصال + حذف ملف الاتصال؟ + هل أنت متأكد من رغبتك في حذف \"%1$s\"؟ لا يمكن التراجع عن هذا الإجراء. + حذف ملف الاتصال + إخفاء الرمز المميز (Token) + إظهار الرمز المميز (Token) + تلقائي (حسب النظام) + فاتح + داكن + ألوان ديناميكية + استخراج نمط الألوان من خلفية الشاشة (Android 12+) + السمة المحددة مسبقاً + الافتراضي + أحادي اللون (Monochrome) + Gruvbox + Catppuccin + أسود AMOLED (الوضع الداكن فقط) + Nord + اللغة + تلقائي (حسب النظام) + English + العربية +简体中文 +한국어 +الملف التعريفي: %1$s +تحديد ملف تعريفي محفوظ +حالة الخادم الخلفي +Hermes %1$s · %2$s +محمي (OAuth) +Token +رمز Token مطلوب +رمز Token غير صالح (401 Unauthorized) +تم رفض الوصول (403 Forbidden) +استجاب الخادم برمز HTTP %1$d +انتهت مهلة الاتصال +تم رفض الاتصال – هل خادم Hermes قيد التشغيل؟ +تعذر حل اسم المضيف +فشل الاتصال: %1$s +حفظ الملف التعريفي للاتصال +اسم الملف التعريفي +مثال: Local Hermit، Remote Server +إخفاء الإدخال اليدوي +الإدخال يدويًا بدلاً من ذلك +رمز API token +عنوان URL للخادم +https://host:9119/ أو http://127.0.0.1:9119/ +أدخل عنوان URL صالح للخادم (https:// أو http://) +تم رفض الوصول إلى الشبكة المحلية. يُرجى منح إذن الشبكة المحلية (LAN) في الإعدادات، أو الاتصال عبر عنوان URL لبوابة بعيدة بدلاً من ذلك. +اتصال +لوحة كانبان +لم يتم العثور على لوحات. +لم يتم العثور على أعمدة لهذه اللوحة. +إضافة مهمة جديدة +عنوان المهمة +وصف المهمة +إضافة مهمة +فرز +جاهز +حظر +إلغاء الحظر +إكمال +أرشفة +هل تريد وضع علامة محظورة على هذه المهمة؟ سيتم تحرير حجز العامل. +هل تريد إكمال هذه المهمة؟ سيتم تحرير حجز العامل. +هل تريد أرشفة هذه المهمة؟ ستختفي من العرض الافتراضي للوحة. +إكمال المهمة +ملخص الإكمال +يُحفظ كنتيجة للمهمة +فشل النقل: %1$s +مباشر +غير متصل +لا توجد مهام +إضافة +جميع الحالات +مفعَّلة +معطَّلة +الكل +المهارات +البحث في المهارات\u2026 +تعديل: %1$s +معاينة: %1$s +لا يوجد محتوى للمعاينة +البحث في مركز المهارات\u2026 +بحث في المركز +لم يتم العثور على مهارات في المركز +اكتب مصطلح البحث لتصفح مركز المهارات +تثبيت +المصادر المتصلة +تم تجاوز حد الطلبات +غير متصل +عبر الفهرس +مهارات مميزة +من فهرس Hermes — ابحث أعلاه للمزيد +ابحث في المركز أعلاه لتصفح المهارات القابلة للتثبيت من المصادر المتصلة +فحص +النتيجة: %1$s +التثبيت مسموح +يتطلب تأكيدًا +التثبيت محظور +لم يتم اكتشاف أنماط خطرة + + لا توجد نتائج + نتيجة واحدة (%1$d) + نتيجتان (%1$d) + %1$d نتائج + %1$d نتيجة + %1$d نتيجة + +أدخل محتوى المهارة\u2026 +لم يتم العثور على أي مهارات +تجاهل التغييرات؟ +لديك تغييرات غير محفوظة. هل أنت متأكد من رغبتك في تجاهلها؟ +المثبتة +تصفح المركز +جميع المصادر +تجاهل +تعديل ملف المهارة +إلغاء تثبيت المهارة +تحديث المهارات من المركز +حفظ التغييرات +لا توجد سجلات متاحة +ستظهر مخرجات السجل من Hermes هنا. +إيقاف مؤقت +استئناف +مشاركة +الانتقال إلى الأسفل +الكل +الوكيل (Agent) +الأخطاء +البوابة +تحذير +خطأ +معلومات +تصحيح الأخطاء +الأدوات +واجهة سطر الأوامر (CLI) +Cron +لا توجد بيانات للنظام +انقر فوق تحديث لتحميل تشخيصات النظام. +المضيف +نظام التشغيل +المعمارية +اسم المضيف +Python +Hermes +وحدة المعالجة المركزية (CPU) +الذاكرة (RAM) +القرص +مدة التشغيل +متوسط الحمل +أنوية +%1$s / %2$s (%3$s%%) +قم بتثبيت حزمة psutil الإضافية للحصول على مقاييس وحدة المعالجة المركزية / الذاكرة / القرص. +الأحدث +يتوفر تحديث +متأخر بمقدار %1$d +التحقق من وجود تحديثات +تحديث الآن +هل تريد تحديث Hermes؟ +سيؤدي هذا إلى تشغيل أمر hermes update وإعادة تشغيل البوابة. +تحديث البرنامج +بوابة Nous Portal +مسجّل الدخول +غير مسجّل الدخول +مزود الاستنتاج: %1$s +إدارة الاشتراك +توجيه بوابة الأدوات (Tool Gateway) +سجّل الدخول باستخدام hermes portal. +مُنظِّم المهارات (Skill curator) +نشاط التحسين الذاتي والتعلم & +سجل زمني لمهارات الوكيل، والذكريات، وتحديثات مخزن الحقائق المكتسبة بمرور الوقت. +لم يتم تسجيل أي أحداث تحسين ذاتي بعد. +نشط +متوقف مؤقتاً +معطّل +كل %1$d س +آخر تشغيل %1$s +لم يُشغّل مطلقاً +استئناف +إيقاف مؤقت +تشغيل الآن +البوابة +قيد التشغيل +متوقف +بدء +إعادة التشغيل +إيقاف +النشط: %1$s +الذاكرة المدمجة +MEMORY.md: %1$s · USER.md: %2$s +إعادة ضبط MEMORY.md +إعادة ضبط USER.md +إعادة ضبط الكل +إعادة ضبط الذاكرة +سيؤدي هذا إلى مسح ملف (ملفات) الذاكرة المدمجة %1$s نهائيًا. لا يمكن التراجع عن هذا الإجراء. +لا توجد بيانات ذاكرة +تعذر تحميل حالة الذاكرة. +المزوّدون +نشط +مجمّع بيانات الاعتماد +المزوّد +مفتاح API +التسمية +إضافة مفتاح +لا توجد بيانات اعتماد مجمّعة. أضف مفتاحًا أعلاه لتفعيل التدوير التلقائي للمفاتيح. +إزالة بيانات الاعتماد +هل تريد إزالة مفتاح API المجمّع هذا؟ لن يقوم الوكيل بتدويره بعد الآن. +العمليات +تشغيل الفاحص (Doctor) +تدقيق الأمان +تحديث المهارات +حجم التوجيه (Prompt) +تفريغ بيانات الدعم +ترحيل الإعدادات +إنشاء نسخة احتياطية +تنزيل النسخة الاحتياطية +النسخ الاحتياطي +استعادة من ملف تم رفعه +رفع واستعادة +اختيار أرشيف النسخة الاحتياطية +هل تريد استعادة نسخة احتياطية كاملة لـ Hermes؟ +سيؤدي هذا إلى الكتابة فوق إعدادات Hermes الحالية والمهارات والجلسات والبيانات. لا يمكن التراجع عن هذا الإجراء. +مشاركة بيانات تصحيح الأخطاء +يرفع معلومات النظام والسجلات إلى خدمة لصق عامة ويُرجع روابط لإرسالها إلى فريق Hermes. يتم حذف النصوص الملصقة تلقائيًا بعد 6 ساعات. +إنشاء رابط المشاركة +جارٍ الرفع… +تنقيح الرموز الشبيهة ببيانات الاعتماد قبل الرفع (موصى به) +تم الرفع +تم التنقيح +غير منقح +حذف تلقائي خلال %1$d س +نسخ الكل +فشل الرفع +نقاط الفحص (Checkpoints) +%1$d جلسة/جلسات · %2$s +تنظيف +تنظيف نقاط الفحص +هل تريد حذف مخزن الظل لنقاط فحص التراجع؟ سيتم فقدان نقاط /rollback الحالية. +خطافات الأوامر (Shell hooks) +خطاف جديد +لم يتم إعداد أي خطافات أوامر (Shell hooks). +المطابق: %1$s +غير قابل للتنفيذ +مسموح +غير معتمد +خطاف أوامر جديد +الحدث +الأمر (مسار مطلق) +المطابق (اختياري) +مهلة الانتظار (ث) +الموافقة الآن (منح الإذن للتشغيل؛ وإلا فسيظل مهيأً ولكن غير نشط) +تنفذ خطافات الأوامر (Shell hooks) أوامر عشوائية على هذا المضيف. أضف البرمجيات النصية الموثوقة فقط. يسري المفعول عند إعادة تشغيل البوابة/الجلسة التالية. +إنشاء خطاف +سجل الإجراءات +قيد التشغيل +مكتمل +خروج بنتيجة %1$d +جارٍ البدء… +إلغاء +استعادة +تحديث الآن +لم يتم العثور على مزودي نماذج +تحقق من التهيئة أو أعد المحاولة +المعرف اللطيف (Slug): %1$s +الحالي +النماذج المتاحة: +لم يتم العثور على نماذج أو أن المزود يحتاج إلى مصادقة. +النموذج النشط +النماذج المثبتة +إلغاء تثبيت النموذج +تبديل ظهور قسم المثبتات +لا يوجد سجل +ستظهر جلسات المحادثة السابقة من Hermes هنا. +بلا عنوان +%1$d رسالة +تحميل المزيد من الجلسات… +البحث في الجلسات… +لا توجد نتائج +لا توجد جلسات تطابق “%1$s” +%1$d نتيجة لـ \"%2$s\" +تم التشغيل %1$s +تطابق +النموذج +الإجمالي +الرسائل +تنقيح +تنقيح الجلسات القديمة +حذف الجلسات الأقدم من عدد الأيام المحدد. +حد الأيام +تنقيح +فارغة +هل تريد حذف الجلسات الفارغة؟ +حذف %1$d من الجلسات الفارغة. سيتم الاحتفاظ بالجلسات النشطة والمؤرشفة. +حذف +هل تريد حذف الجلسة؟ +هل أنت متأكد من رغبتك في حذف \"%1$s\"؟ لا يمكن التراجع عن هذا الإجراء. +هل تريد حذف الجلسات؟ +هل أنت متأكد من رغبتك في حذف %1$d من الجلسات؟ لا يمكن التراجع عن هذا الإجراء. +تحديد الكل +إلغاء التحديد +حذف %1$d +تحديد +إعادة تسمية +تثبيت +إلغاء التثبيت +إخفاء +إظهار +إظهار الجلسات المخفية +إخفاء الجلسات المخفية +مخفية +جميع الجلسات مخفية حاليًا. اضغط أدناه لإظهار الجلسات المخفية. +مثبتة +جلسة مفرعة +إعادة تسمية الجلسة +عنوان الجلسة +لم يتم العثور على مجموعات أدوات +تحقق من التكوين أو أعد المحاولة +لا توجد واجهات خلفية قابلة للتهيئة لمجموعة الأدوات هذه +جاهز +يتطلب مفاتيح +يتطلب تسجيل الدخول +يتطلب الإعداد +نشط +استخدام هذه الواجهة الخلفية +يتطلب تسجيل الدخول إلى Nous Portal للتفعيل +لا يلزم مفتاح API +تم التعيين +لم يتم التعيين +حفظ +حذف +إزالة المفتاح؟ +سيؤدي هذا إلى مسح %1$s من البيئة. +تعديل %1$s +إظهار %1$s +إخفاء %1$s +مسح %1$s +تشغيل الإعداد +إعادة تشغيل الإعداد +قيد التشغيل… +مثبّت +لتثبيت التبعيات المفقودة لهذه الخلفية البرمجية +التبعيات مثبتة بالفعل +اكتمل الإعداد (رمز الخروج %1$d) +فشل الإعداد (رمز الخروج %1$d) +بحث: %1$s +استخراج: %1$s +حالة الـ Webhooks العامة +الاشتراكات +لم يتم تكوين أي اشتراكات webhook. +لا توجد اشتراكات +البحث في الاشتراكات… +إضافة اشتراك +إنشاء +حذف +إنشاء اشتراك +حذف الاشتراك +هل أنت متأكد من رغبتك في حذف \"%1$s\"؟ لا يمكن التراجع عن هذا الإجراء، وسيتم رفض جميع الأحداث المرسلة إلى هذا الـ URL. +الاسم * +الوصف +الأحداث +التسليم إلى +السر (Secret) +مثال: github-push +مفصولة بفواصل (مثال: push, pull_request) +log, telegram, discord, origin, all… +يتم إنشاؤه تلقائياً إذا تُرك فارغاً +لا توجد اشتراكات مطابقة +جرّب تعديل عبارة البحث. +الإعدادات +إعادة ضبط +حفظ +حفظ YAML +لا توجد ملفات تعريف متاحة +ستظهر ملفات تعريف الاتصال من Hermes هنا. +النموذج: %1$s +المزود: %1$s +%1$d مهارات +المسار: %1$s +تم إنشاؤه تلقائياً +تفعيل +تعديل الـ Soul +تعيين النموذج +استنساخ +تعديل الوصف +إخفاء الملف الشخصي +إلغاء إخفاء الملف الشخصي +إظهار الملفات الشخصية المخفية +إخفاء الملفات الشخصية المخفية +مخفي +جميع الملفات الشخصية مخفية حاليًا. انقر أدناه لإظهار الملفات الشخصية المخفية. +الملف الشخصي النشط +تعديل ملف الـ Soul: %1$s +تعيين النموذج: %1$s +استنساخ الملف الشخصي: %1$s +تعديل الوصف: %1$s +اسم الملف الشخصي الجديد +الوصف +لم يتم تقديم وصف. صِف دور هذا الملف الشخصي. +إعادة التسمية +وصف تلقائي +أمر الإعداد +نسخ +تم النسخ +إعادة تسمية الملف الشخصي: %1$s +حذف الملف الشخصي: %1$s +أمر الإعداد: %1$s +سيؤدي هذا إلى حذف الملف الشخصي \'%1$s\' وبياناته نهائيًا. +المسار: %1$s +يحتوي هذا الملف الشخصي على بوابة قيد التشغيل — سيؤدي حذفه إلى إيقافها. +شغّل هذا الأمر في Hermes CLI لتهيئة هذا الملف الشخصي. +الخطوة %1$d من 5 +الهوية +تهيئة النموذج +اختيار المهارات +خوادم MCP +المراجعة والإنشاء +رجوع +التالي +إنشاء ملف شخصي +اسم الملف الشخصي +الوصف +تحديد المزود (اختياري) +تحديد النموذج +تضمين حزمة المهارات الافتراضية +تضمين أدوات النظام الشائعة تلقائيًا +تحديد المهارات المحلية +البحث في الـ Hub +إضافة مهارات من Hermes Hub +المهارات المضافة: +نتائج البحث: +خوادم MCP المهيأة +إضافة خادم +اسم الخادم +بروتوكول النقل +إضافة خادم MCP +أمر Stdio +عنوان URL لخادم SSE +الأمر +الوسائط (مفصولة بفواصل) +عنوان URL لـ SSE +اسم الملف التعريفي +الوصف +المزود: %1$s +النموذج: %1$s +إعدادات النموذج +استخدام الإعدادات الافتراضية للنظام +تهيئة المهارات +الحزمة الافتراضية الكاملة +تم تحديد %1$d من المهارات المحلية +تمت إضافة %1$d من مهارات المركز (Hub) +خوادم MCP +لم تتم تهيئة أي خوادم MCP +لم تتم تهيئة أي خوادم MCP بعد +إضافة +لم يتم العثور على إضافات +تحقق من التهيئة أو أعد المحاولة +لا يوجد وصف مقدم. +تحديث +إلغاء التثبيت +تثبيت +إزالة +تثبيت إضافة +تثبيت إضافة من مصدر git/URL +إعادة التثبيت إجباريًا +تفعيل بعد التثبيت +مزودو الإضافات +تحديد مزودي الذاكرة والسياق النشطين +مزود الذاكرة +محرك السياق +(الإعدادات الافتراضية للتهيئة) +إزالة الإضافة +سيؤدي هذا إلى إزالة الإضافة \"%1$s\" من وكيلك. +المصادقة مطلوبة +أمر المصادقة: %1$s +فتحات لوحة التحكم: %1$s +إضافات لوحة التحكم المهجورة +إظهار في الشريط الجانبي +إخفاء من الشريط الجانبي +الفئة +المصدر + الإصدار + الحالة + الوصف + الوسوم + بروتوكول النقل + التسمية + URL + الأمر + متغيرات البيئة + الأدوات + الأحداث + تسليم إلى + المطالبة + المهارات + تاريخ الإنشاء + خطأ + مفعّل + معطّل + مستوى الثقة + بروتوكول النقل: %1$s + الأمر: + اختبار + اختبار الكل + استيراد JSON + استيراد خوادم MCP + الصق مقتطف JSON بتنسيق mcpServers الخاص بـ Claude Desktop / Cursor أو تعريف الخادم. + لصق من الحافظة + جارٍ الاستيراد… + سليم + يتطلب المصادقة + خطأ + جارٍ الاختبار… + إضافة خادم + رابط HTTP URL + لا توجد خوادم MCP + أضف خادمًا من الأعلى أو من الدليل. + أمر Stdio + اسم الخادم + URL (مثال: http://localhost:8000/sse) + الأمر (مثال: npx) + الوسائط + إضافة خادم + جارٍ الإضافة… + إعادة التشغيل + متغيرات البيئة + إضافة متغير بيئة + المفتاح + القيمة + لم يتم تكوين أي متغيرات بيئة. +الدليل +البحث في الدليل… +لا توجد عناصر في الدليل تطابق بحثك. +تثبيت +جارٍ التثبيت… +المصدر: %1$s +متغيرات البيئة المطلوبة +المصادقة +بلا +Header +OAuth +Bearer token +منح الإذن +مسار مصادقة MCP OAuth +الحالة: %1$s +خطأ: %1$s +تم فتح صفحة المصادقة في متصفحك. أكمل تسجيل الدخول، ثم عُد إلى هنا. +فتح المتصفح +البوابة تعمل +البوابة متوقفة +الإصدار: %1$s +عناصر التحكم +بدء +إيقاف +إعادة التشغيل +المنصات النشطة +قرص الوكيل ممتلئ تقريباً — قد يتعذر حفظ الرسائل والذكريات والإعدادات الجديدة +قرص الوكيل يوشك على الامتلاء — يُرجى مسح الجلسات القديمة أو زيادة سعة التخزين + · %1$d ميغابايت متوفرة +ذاكرة الوكيل على وشك النفاد وقد تتم إعادة تشغيله +أعاد الوكيل التشغيل بشكل غير متوقع — على الأرجح بسبب نفاد الذاكرة +ذاكرة الوكيل منخفضة +لا توجد مهام cron +ستظهر المهام المجدولة من Hermes هنا. +نشط +متوقف مؤقتاً +إيقاف مؤقت +استئناف +تشغيل +تعديل +إضافة مهمة cron +تعديل مهمة Cron +مهمة Cron جديدة +البدء من +مهمة فارغة +الاسم +الجدول الزمني +المطالبة (Prompt) +التسليم +المهارات +النموذج +المزود +عنوان URL الأساسي +البرنامج النصي +دليل العمل +بدون وكيل (برنامج نصي فقط) +استمرارية التشغيل +حقن المخرجات السابقة لهذه المهمة في كل عملية تشغيل +استمرارية +خطأ في الإرسال +فشل التشغيل +وضع المراقبة +متوقف +برنامج نصي +URL +برنامج المراقبة النصي +رابط المراقبة (URL) +يعمل الوكيل فقط عندما تتغير مخرجات المصدر +محظور (الإعدادات) +لا يوجد تغيير +مثال: 0 9 * * * أو كل ساعتين (every 2h) +مثال: origin أو local أو all أو telegram:chat_id +HH:MM +مهارة واحدة في كل سطر +حفظ +تجاهل التغييرات؟ +لديك تغييرات غير محفوظة. هل تريد تجاهلها؟ +حذف المهمة؟ +هل تريد حذف \"%1$s\"؟ لا يمكن التراجع عن هذا الإجراء. +لا توجد إنجازات بعد +أكمل المهام لإلغاء قفل الإنجازات. +التقدم: %1$d / %2$d +البحث في الإنجازات… +مفتوحة +مكتشفة +سرية +الإجمالي +خامل +جارٍ الفحص… +إعادة الفحص… +خطأ +%1$d عمليات فحص +مخزنة مؤقتًا +الكل +مفتوح +مكتشف +سري +أحدث الإنجازات المفتوحة +%1$d من أصل %2$d إنجازًا +إظهار المعايير +إخفاء المعايير +تم الفتح في %1$s +قائمة الإنجازات +إعادة فحص جميع الجلسات +إعادة ضبط جميع بيانات الإنجازات +هل تريد إعادة ضبط الإنجازات؟ +سيؤدي هذا إلى مسح جميع بيانات الفتح والاستكشاف. ستتم إعادة فحص الإنجازات من البداية في الفحص التالي. لا يمكن التراجع عن هذا الإجراء. +لا توجد مفاتيح بيئة مُبلّغ عنها +ستظهر مفاتيح البيئة من Hermes هنا. +غير مهيأ +تبديل مستوى الرؤية +بحث في المفاتيح… +لا توجد مفاتيح مطابقة +جرب استخدام مصطلح بحث آخر. +إضافة مفتاح جديد +اسم المفتاح +قيمة المفتاح +إضافة مفتاح +يلزم إعادة تشغيل البوابة (Gateway) +تسري تغييرات البيئة بعد إعادة تشغيل البوابة (Gateway). +إعادة تشغيل البوابة +حذف المفتاح +إضافة مفتاح بيئة جديد +هل تريد حذف المفتاح؟ +هل أنت متأكد من رغبتك في حذف مفتاح البيئة \"%1$s\"؟ ستسري التغييرات بعد إعادة تشغيل البوابة (Gateway). +حذف +إضافة مفتاح بيئة +نسخ القيمة +تم نسخ قيمة المفتاح إلى الحافظة +تمت التهيأة +لم يُعيّن +مثال: OPENAI_API_KEY +مثال: ...-sk +فتح وثائق المزوّد +الكل +دليل الملفات المُدارة الرئيسي +لا توجد ملفات هنا +هذا المجلد فارغ. أنشئ مجلداً أو حمّل ملفاً للبدء. +الانتقال للأعلى +تحميل ملف +مجلد جديد +إنشاء +حذف +مجلد جديد +اسم المجلد +my-folder +هل تريد الحذف؟ +هل أنت متأكد من رغبتك في حذف \"%1$s\"؟ لا يمكن التراجع عن هذا الإجراء. +لا توجد قنوات مهيأة +أضف منصات المراسلة في Hermes لعرضها هنا. +تهيئة +إعدادات التهيئة +حفظ الإعدادات +اختبار +إعادة التشغيل الآن +تم حفظ التغييرات. أعد تشغيل البوابة (Gateway) لتصبح سارية المفعول. +الموافقات المعلقة +المستخدمون المعتمدون +مسح الكل +موافقة +إلغاء الصلاحية +لا توجد طلبات اقتران +المستخدمون الذين يطلبون الوصول إلى قنوات المراسلة الخاصة بك سيظهرون هنا للموافقة عليهم. +لا يوجد مستخدمون معتمدون حتى الآن. +مستخدم غير معروف +منذ %1$d د +منذ %1$d س و%2$d د +الملفات المحفوظة (%1$d) +تسجيل الخروج +تسجيل الدخول والمصادقة +الاتصال بلوحة تحكم Hermes +رابط الخادم (URL) +رمز الجلسة (Session Token) +اسم المستخدم +كلمة المرور +اتصال +جارٍ فحص لوحة التحكم… +جارٍ التحقق من إعدادات لوحة التحكم… +رمز الجلسة مطلوب +اسم المستخدم مطلوب +كلمة المرور مطلوبة +تعذر الوصول إلى لوحة التحكم — تحقق من عنوان IP والمنفذ +تسجيل الدخول عبر OAuth (بوابة Nous) غير مدعوم في هذا الإصدار من التطبيق بعد. يرجى تبديل لوحة التحكم إلى المصادقة الأساسية (Basic Auth) أو وضع الرمز (Token). (المشكلة رقم 639#) +تستخدم لوحة التحكم هذه تسجيل الدخول عبر OAuth (بوابة Nous)، والذي سيتوفر قريباً في Hermes Mobile. +استخدام ملف تعريف مسجل دخوله مسبقاً: +الدخول باسم %1$s +Hermes Mobile +لوحة التحكم لـ Hermes Agent +تسجيل الدخول والمصادقة +لا يوجد موفرو OAuth +سيظهر موفرو خدمة OAuth المهيؤون هنا. قم بربط حساب لاستخدامه من التطبيق. +اتصال +كيفية الاتصال +قطع الاتصال +قيد التنفيذ… +إرسال الرمز +فتح المتصفح لمنح الإذن +تتم إدارة هذا الموفر بواسطة واجهة أسطر أوامر (CLI) خارجية. شغّل الأمر أدناه على الخادم المضيف، ثم أعد تحديث هذه القائمة. +قم بمنح الإذن في المتصفح، ثم الصق رمز التفويض (الجزء الذي يسبق الفاصل #) أدناه. +رمز التفويض +أدخل هذا الرمز في عنوان URL للتحقق على جهاز آخر، ثم انتظر — يتم تحديث هذه الشاشة تلقائيًا. +تنتهي الصلاحية خلال %1$s +تم ربط OAuth بنجاح. +إصلاح +تجاهل +تم +إغلاق +يتوفر إصدار جديد من Hermes Mobile %1$s +تحديث +التحديث إلى %1$s +ما الجديد +تنزيل وتثبيت +تذكيري لاحقًا +تخطي هذا الإصدار +لا توجد عمليات قيد التشغيل +ستظهر هنا العمليات التي تعمل في الخلفية لهذه الجلسة. +لا توجد جلسة محادثة نشطة +افتح جلسة محادثة أولاً — قائمة العمليات مرتبطة بالجلسة. +قيد التشغيل +إنهاء العملية؟ +هل تريد إيقاف \u201c%1$s\u201d؟ لا يمكن التراجع عن هذا الإجراء. +لا توجد تحليلات بعد +ستظهر إحصائيات الاستخدام هنا بمجرد أن يبدأ الوكيل بالعمل. +%1$d يومًا +التكلفة التقديرية +الجلسات +نداءات API +المدخلات +المخرجات +قراءة الذاكرة المؤقتة +التفكير المنطقي +تم النسخ +عرض بالكامل +عرض أقل +التكلفة اليومية التقديرية +جارٍ تحميل تحليلات الاستخدام… +لم يتم تسجيل أي تكلفة في هذه الفترة. +%1$d استخدام \u2022 %2$d%% من الإجمالي +%1$d نداء \u2022 %2$d%% من الإجمالي +إنهاء +إنهاء العملية +صورة المحادثة +إغلاق +حفظ على الجهاز +مشاركة +مشاركة الصورة +إدارة المزود… +لا توجد إعدادات للمزود +لا يحتوي هذا المزود على خيارات قابلة للتهيئة. +حفظ الإعدادات +التبعيات: %1$s · متغيرات البيئة المطلوبة: %2$s +الإعداد +تثبيت التبعيات المفقودة لمزود الخدمة هذا. +تشغيل الإعداد +اكتمل الإعداد بنجاح. +انتهى الإعداد مع وجود أخطاء. +مُعيَّن +غير مُعيَّن +مفتاح API (اتركه فارغاً للإبقاء عليه) +مفعل +معطل +تم حفظ الصورة في التنزيلات +تعذر حفظ الصورة +تعذر تحميل الصورة: %1$s +تعذر مشاركة الصورة +المكونات الأكثر استخداماً +لا تتوفر بيانات للنماذج +لا تتوفر بيانات للمهارات +لا تتوفر بيانات للأدوات +نظرة عامة على النماذج والمهارات والأدوات الأكثر نشاطاً لديك. +تُكتب بيانات الاعتماد في %1$s +ينتهي في: %1$s +البحث في القنوات… +إرفاق ملف +حفظ %1$s +تبديل النموذج (لهذه المحادثة) +مسح القيمة +نسق JSON غير صالح +لا توجد تطابقات +المسار: %1$s +إعادة التعيين إلى الإعداد الافتراضي +حفظ التغييرات +البحث في الإعدادات… +قراءة الذاكرة المؤقتة +كتابة الذاكرة المؤقتة +الإكمال (المخرجات) +مضغوط ×%1$s +المطالبة (المدخلات) +تعكس رموز المطالبة إجمالي السياق المستخدم في هذه الجلسة حتى الآن. +نافذة السياق +تشغيل GIF +تصفية المهام حسب العنوان أو الحالة أو المسؤول... +سجلات Hermes +البحث في السجلات... +لا توجد خوادم تطابق \"%1$s\" +البحث في خوادم MCP... +المهارات المكتسبة +الذكريات والحقائق +أحدث أحداث التحسين الذاتي للوكيل +الاستدلال · %1$s خطوة +تم تبديل النموذج +تم تبديل النموذج إلى %1$s +تم تبديل الشخصية +تمت المتابعة التلقائية للدور +اكتملت المهمة المفوّضة +تم استدعاء المهارة +إشعار النظام +تم الوصول إلى الحد الأقصى لاستدعاء الأدوات +ردّك +إضافة إعداد مسبق +إضافة نموذج مرجعي +اسم الإعداد المسبق الجديد +لا توجد إعدادات مسبقة مهيأة +الإعداد المسبق: +تظهر الإعدادات المسبقة كنماذج ضمن موفر مزيج الوكلاء (Mixture of Agents). +النماذج المرجعية +البحث في الموفرين... +تعيين كافتراضي +مزيج الوكلاء (Mixture of Agents) +المهام المساعدة +المهام المساعدة +نموذج عالي التكلفة +النموذج الرئيسي +مزيج الوكلاء (Mixture of Agents) +ينطبق على الجلسات الجديدة +جارٍ تحميل النماذج… +لا توجد نماذج متاحة. +البحث في النماذج والموفرين... +إعادة ضبط الكل إلى تلقائي +البحث في النماذج... +تعيين المساعد: %1$s +تعيين النموذج الرئيسي +إعدادات النموذج +سيؤدي هذا إلى إزالة تهيئة هذا النظام الأساسي نهائيًا. +إزالة %1$s؟ +إزالة النظام الأساسي +الإعداد عبر رمز QR +owner/repo أو owner/repo/subdir أو https://... +البحث في الإضافات... +صِف الغرض من هذا الملف التعريفي +الأمر: %1$s +إزالة الخادم +وسيلة النقل: %1$s +إنشاء ملف تعريفي +المزيد من الخيارات +عبر %1$s +المصدر: %1$s +خطة الوكيل +النص المباشر +لا توجد مهام أو عناصر خطة نشطة. +الملخص: %1$s +فحص المهمة والخطة +توجيه +إيقاف +إرشادات لتصحيح مسار الوكيل الفرعي… +قيد التشغيل +مكتمل +تم التوجيه +ملغى +فشل +%1$.1f ث +المصادقة مطلوبة +تنظيف نقاط الفحص +ترحيل الإعدادات +تشغيل المنسق +... %1$d أسطر إضافية +فحص حجم المطالبة (Prompt) +تدقيق أمني +تحديث المهارات +إضافة معرف المستخدم (User ID) +المستخدمون المسموح لهم: +متصل كـ ‎@%1$s +تنتهي صلاحية رمز QR خلال: %1$s +حفظ وإعادة التشغيل +جارٍ حفظ الإعدادات… +افتح Telegram وامسح رمز QR المعروض في التطبيق. +معرف مستخدم Telegram +المهام %1$d/%2$d + + ولا وكيل + وكيل واحد (%1$d) + وكيلان (%1$d) + %1$d وكلاء + %1$d وكيلاً + %1$d وكيل + + + لا يوجد أي وكيل قيد التشغيل + وكيل واحد قيد التشغيل (%1$d) + وكيلان قيد التشغيل (%1$d) + %1$d وكلاء قيد التشغيل + %1$d وكيلاً قيد التشغيل + %1$d وكيل قيد التشغيل + +فتح تفاصيل المهمة +المدة: %1$s +· محجوب +البحث في مجموعات الأدوات... +تشغيل الفيديو +حذف الاشتراك +ما الذي يقوم به هذا الـ Webhook... +رابط الاستلام (URL): %1$s +تم تكوين المفتاح السري +تفريغ الذاكرة (Dump) +بدأ %1$s +فشل %1$s: %2$s +إرسال رسالة إلى %1$s… (استخدم @bot للإشارة) +لا توجد رسائل بعد +أشر إلى بوت أو استخدم @all لبدء التعاون. +%1$s يفكّر الآن… +جارٍ الكتابة… +%1$d أعضاء +@all +المحادثة متوقفة مؤقتًا (تم الوصول إلى الحد الأقصى لعدد الردود) — أرسل ردًا للمتابعة +إعدادات المحادثة الجماعية +الحد الأقصى لرسائل البوت +الحد الأقصى لإجمالي ردود البوت لكل رسالة (%1$d) +الحد الأقصى لمرات التمرير +عدد المرات التي يمكن للبوتات فيها تفعيل بعضها البعض (%1$d) +حفظ +إعادة التعيين إلى الإعدادات الافتراضية +تعليمات / موجه الغرفة +قواعد مخصصة، أو أسلوب، أو سياق لهذه المجموعة (اختياري)… +إرشادات مخصصة لجميع البوتات في هذه الغرفة +الإعدادات +تم إيقاف النقاش +إيقاف التوليد + \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index fea9892d..a1e47513 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -240,6 +240,14 @@ Catppuccin AMOLED 블랙 (다크 전용) + + 언어 + 시스템 기본값 + English + العربية + 简体中文 + 한국어 + 프로필: %1$s 저장된 프로필 선택 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 552b6b27..0912dbd7 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -249,6 +249,7 @@ 语言 跟随系统 English + العربية 简体中文 한국어 配置:%1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 656ee4a8..9ab998eb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -276,6 +276,7 @@ Language System English + العربية 简体中文 한국어 From 2d0b6ae1a9cc7c23d2baaca90c4c006c1b54ec8e Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:01:19 +0300 Subject: [PATCH 18/20] fix(history): resolve session resume profile scoping and history pagination (#1040) --- .../notification/NotificationReplyReceiver.kt | 12 ++++- .../m57/hermescontrol/ui/chat/ChatScreen.kt | 2 +- .../hermescontrol/ui/chat/ChatViewModel.kt | 47 ++++++++++++++++++- .../ui/sessions/SessionsScreen.kt | 8 +++- .../ui/sessions/SessionsViewModel.kt | 5 -- 5 files changed, 64 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt b/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt index c082afaf..dde82529 100644 --- a/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt +++ b/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt @@ -8,6 +8,7 @@ import android.content.Intent import androidx.core.app.NotificationCompat import androidx.core.app.RemoteInput import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.session.ActiveSessionHolder import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.WsMethods @@ -124,11 +125,20 @@ open class NotificationReplyReceiver : BroadcastReceiver() { } private suspend fun resumeSession(storedSessionId: String): String { + val profile = AuthManager.activeProfileId.value + val params = + mutableMapOf( + "session_id" to storedSessionId, + "omit_messages" to true, + ) + if (!profile.isNullOrBlank()) { + params["profile"] = profile + } val result = HermesWsClient .request( WsMethods.SESSION_RESUME, - mapOf("session_id" to storedSessionId, "omit_messages" to true), + params, timeoutMs = REPLY_TIMEOUT_MS, ).await() as? Map<*, *> val runtimeSessionId = 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 7a09cfb5..7e3a16d2 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 @@ -231,7 +231,7 @@ fun ChatScreen( .collectLatest { firstVisibleIndex -> if (firstVisibleIndex > 2) { isOlderPagingArmed = true - } else if (isOlderPagingArmed) { + } else if (isOlderPagingArmed || state.messages.size <= 3) { val anchorId = state.messages.getOrNull(firstVisibleIndex)?.id val offset = scrollController.captureAnchorOffset() pagingAnchor.value = if (anchorId != null) anchorId to offset else null 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 a774705f..c96f4924 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 @@ -647,6 +647,8 @@ class ChatViewModel( viewModelScope.launch { ProfileSwitchCoordinator.switched .collect { _ -> + pendingGoneSessionNotice = false + sessionHasServerPresence = false resetSessionState(sessionId = null, title = "Hermes", isLoading = true) } } @@ -657,6 +659,8 @@ class ChatViewModel( viewModelScope.launch { ProfileSwitchCoordinator.connectionSwitched .collect { _ -> + pendingGoneSessionNotice = false + sessionHasServerPresence = false resetSessionState(sessionId = null, title = "Hermes", isLoading = true) } } @@ -2469,6 +2473,27 @@ class ChatViewModel( } is NetworkResult.Failure -> { + val errorMsg = result.error.message + val is404 = + errorMsg.contains("404", ignoreCase = true) || + errorMsg.contains("not found", ignoreCase = true) + if (is404) { + // A 404 from GET /api/sessions/{id}/messages indicates the session has no + // persisted rows in state.db (e.g. newly created/lazy unprompted session, or empty history). + // It is NOT a fatal error: keep the session open with empty transcript, clear loading, + // and mark hydrated cleanly. + _uiState.update { + if (!isCurrentHydration(sessionId, generation, requestSequence)) return@update it + it.copy( + isLoading = false, + isLoadingOlder = false, + hasOlderMessages = false, + ) + } + hydratedGeneration = generation + finishResumeWhenHydrated(generation) + return@launch + } if (hydratedGeneration == generation) hydratedGeneration = -1L _uiState.update { if (!isCurrentHydration(sessionId, generation, requestSequence)) return@update it @@ -2576,10 +2601,19 @@ class ChatViewModel( ) { val requestSequence = ++resumeRequestSequence activeResumeRequestSequence = requestSequence + val profile = AuthManager.activeProfileId.value + val params = + mutableMapOf( + "session_id" to sessionId, + "omit_messages" to true, + ) + if (!profile.isNullOrBlank()) { + params["profile"] = profile + } viewModelScope.launch(ioDispatcher) { wsClient.send( WsMethods.SESSION_RESUME, - mapOf("session_id" to sessionId, "omit_messages" to true), + params, onSent = { id -> trackSessionRequest( id = id, @@ -2653,6 +2687,17 @@ class ChatViewModel( private fun recoverGoneSession(sessionId: String) { if (_uiState.value.currentSessionId != sessionId) return if (sessionGoneRecoveryInFlight) return + if (_uiState.value.messages.isNotEmpty()) { + cancelResumeRetry() + _uiState.update { + it.copy( + isLoading = false, + isResumeRetrying = false, + resumeError = "Session not found on server (displaying cached messages)", + ) + } + return + } sessionGoneRecoveryInFlight = true cancelResumeRetry() _uiState.update { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt index bcefaa5b..fe711a88 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -804,9 +804,13 @@ fun SessionsScreen( derivedStateOf { val info = listState.layoutInfo val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1 - lastVisible >= 0 && - info.totalItemsCount >= AUTO_LOAD_THRESHOLD && + if (lastVisible < 0 || info.totalItemsCount == 0) { + false + } else if (info.totalItemsCount <= AUTO_LOAD_THRESHOLD) { + lastVisible >= info.totalItemsCount - 1 + } else { lastVisible >= info.totalItemsCount - AUTO_LOAD_THRESHOLD + } } } LaunchedEffect( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt index e8182e00..6ac54fe2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt @@ -216,11 +216,6 @@ class SessionsViewModel : val hasMore = receivedFullPage && addedNewItems && data.total > newSessions.size it.copy( isLoadingMore = false, - // distinctBy guards offset-pagination churn: if a new - // session lands on top between page loads, offsets - // shift and the next page can repeat an id we already - // have — LazyColumn would crash on the duplicate key. - // pinnedFirst keeps pinned sessions above the rest. sessions = newSessions, total = if (hasMore) data.total else newSessions.size, hasMore = hasMore, From 4668d482ef53a9c38b24c6ec56b3fd4e27cade6b Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:02:17 +0300 Subject: [PATCH 19/20] feat(i18n): add Weblate component config and translation docs (#1041) Co-authored-by: M57 --- .weblate.json | 14 ++++++++++++++ CONTRIBUTING.md | 10 ++++++++++ README.md | 4 ++++ 3 files changed, 28 insertions(+) create mode 100644 .weblate.json diff --git a/.weblate.json b/.weblate.json new file mode 100644 index 00000000..39ddb919 --- /dev/null +++ b/.weblate.json @@ -0,0 +1,14 @@ +[ + { + "name": "Hermes Mobile", + "slug": "hermes-mobile", + "project": "hermes-mobile", + "vcs": "git", + "branch": "dev", + "filemask": "app/src/main/res/values-*/strings.xml", + "template": "app/src/main/res/values/strings.xml", + "file_format": "android", + "new_base": "app/src/main/res/values/strings.xml", + "language_code_style": "android" + } +] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b780ce4f..9bf2da36 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,6 +67,16 @@ If you use AI coding tools (including agents) to contribute: --- +## Translations + +Translations for Hermes Mobile are managed through [Hosted Weblate](https://hosted.weblate.org/projects/hermes-mobile/hermes-mobile/). + +- Source strings are located in `app/src/main/res/values/strings.xml`. +- Localized strings are stored in `app/src/main/res/values-/strings.xml`. +- You can contribute translations directly on Weblate without modifying code or opening manual PRs. + +--- + ## Code Style We enforce Kotlin coding conventions and Jetpack Compose best practices. diff --git a/README.md b/README.md index 25d51a48..393f0875 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,10 @@ app/src/main/java/com/m57/hermescontrol/ Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for our branch workflow, code style guidelines, and PR checklist. +### Translations + +Help translate Hermes Mobile into your language on [Hosted Weblate](https://hosted.weblate.org/projects/hermes-mobile/hermes-mobile/)! + For developer-specific details, code conventions, and project architecture notes, refer to [AGENTS.md](AGENTS.md). --- From dbd7008ee39818f96b16ab4a9c79d94508c98c9d Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:31:42 +0300 Subject: [PATCH 20/20] feat(chat): support non-Google speech-to-text providers (fixes #1038) (#1042) --- app/src/main/AndroidManifest.xml | 6 ++ .../m57/hermescontrol/ui/chat/ChatScreen.kt | 48 +++++----- .../ui/chat/SpeechInputHelper.kt | 54 +++++++++++ .../ui/chat/SpeechInputHelperTest.kt | 92 +++++++++++++++++++ 4 files changed, 173 insertions(+), 27 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/SpeechInputHelper.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/SpeechInputHelperTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1fe12882..8da078e3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,6 +13,12 @@ + + + + + + ExternalActivityLifecycleGuard.externalActivityReturned() if (granted) { - if (SpeechRecognizer.isRecognitionAvailable(context)) { - val intent = - Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { - putExtra( - RecognizerIntent.EXTRA_LANGUAGE_MODEL, - RecognizerIntent.LANGUAGE_MODEL_FREE_FORM, - ) - putExtra( - RecognizerIntent.EXTRA_PROMPT, - micListeningPrompt, - ) - } + if (SpeechInputHelper.isSpeechInputAvailable(context)) { + val intent = SpeechInputHelper.createSpeechIntent(micListeningPrompt) isListening = true launchExternalActivity { - speechLauncher.launch(intent) + try { + speechLauncher.launch(intent) + } catch (_: ActivityNotFoundException) { + isListening = false + scrollScope.launch { + snackbarHostState.showSnackbar(sttNotAvailableMsg) + } + } } } else { scrollScope.launch { @@ -781,21 +778,18 @@ fun ChatScreen( Manifest.permission.RECORD_AUDIO, ) == PackageManager.PERMISSION_GRANTED ) { - if (SpeechRecognizer.isRecognitionAvailable(context)) { - val intent = - Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { - putExtra( - RecognizerIntent.EXTRA_LANGUAGE_MODEL, - RecognizerIntent.LANGUAGE_MODEL_FREE_FORM, - ) - putExtra( - RecognizerIntent.EXTRA_PROMPT, - micListeningPrompt, - ) - } + if (SpeechInputHelper.isSpeechInputAvailable(context)) { + val intent = SpeechInputHelper.createSpeechIntent(micListeningPrompt) isListening = true launchExternalActivity { - speechLauncher.launch(intent) + try { + speechLauncher.launch(intent) + } catch (_: ActivityNotFoundException) { + isListening = false + scrollScope.launch { + snackbarHostState.showSnackbar(sttNotAvailableMsg) + } + } } } else { scrollScope.launch { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/SpeechInputHelper.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/SpeechInputHelper.kt new file mode 100644 index 00000000..ea88fb1b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/SpeechInputHelper.kt @@ -0,0 +1,54 @@ +package com.m57.hermescontrol.ui.chat + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.speech.RecognizerIntent +import android.speech.SpeechRecognizer + +object SpeechInputHelper { + /** + * Checks if speech recognition is available on this device through any registered provider, + * including standard Android [SpeechRecognizer] services or offline/third-party apps + * that handle [RecognizerIntent.ACTION_RECOGNIZE_SPEECH] (e.g. FUTO Voice Input). + */ + fun isSpeechInputAvailable( + context: Context, + isRecognizerServiceAvailable: (Context) -> Boolean = { ctx -> + runCatching { SpeechRecognizer.isRecognitionAvailable(ctx) }.getOrDefault(false) + }, + ): Boolean { + val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH) + val activities = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + runCatching { + context.packageManager.queryIntentActivities( + intent, + PackageManager.ResolveInfoFlags.of(0L), + ) + }.getOrDefault(emptyList()) + } else { + @Suppress("DEPRECATION") + runCatching { + context.packageManager.queryIntentActivities(intent, 0) + }.getOrDefault(emptyList()) + } + return activities.isNotEmpty() || isRecognizerServiceAvailable(context) + } + + /** + * Builds the standard speech recognition intent with language model and prompt. + */ + fun createSpeechIntent(prompt: String): Intent = + Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra( + RecognizerIntent.EXTRA_LANGUAGE_MODEL, + RecognizerIntent.LANGUAGE_MODEL_FREE_FORM, + ) + putExtra( + RecognizerIntent.EXTRA_PROMPT, + prompt, + ) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/SpeechInputHelperTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/SpeechInputHelperTest.kt new file mode 100644 index 00000000..c6e5dee8 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/SpeechInputHelperTest.kt @@ -0,0 +1,92 @@ +package com.m57.hermescontrol.ui.chat + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ResolveInfo +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.unmockkConstructor +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class SpeechInputHelperTest { + @Before + fun setUp() { + mockkConstructor(Intent::class) + every { anyConstructed().setAction(any()) } answers { self as Intent } + every { anyConstructed().putExtra(any(), any()) } answers { self as Intent } + } + + @After + fun tearDown() { + unmockkConstructor(Intent::class) + } + + @Test + fun isSpeechInputAvailable_returnsTrue_whenPackageManagerFindsActivities() { + val context = mockk() + val packageManager = mockk() + val resolveInfo = mockk() + + every { context.packageManager } returns packageManager + every { + packageManager.queryIntentActivities(any(), any()) + } returns listOf(resolveInfo) + + val result = + SpeechInputHelper.isSpeechInputAvailable( + context = context, + isRecognizerServiceAvailable = { false }, + ) + assertTrue(result) + } + + @Test + fun isSpeechInputAvailable_returnsTrue_whenRecognitionServiceAvailableEvenIfNoActivities() { + val context = mockk() + val packageManager = mockk() + + every { context.packageManager } returns packageManager + every { + packageManager.queryIntentActivities(any(), any()) + } returns emptyList() + + val result = + SpeechInputHelper.isSpeechInputAvailable( + context = context, + isRecognizerServiceAvailable = { true }, + ) + assertTrue(result) + } + + @Test + fun isSpeechInputAvailable_returnsFalse_whenNoActivitiesAndNoRecognitionService() { + val context = mockk() + val packageManager = mockk() + + every { context.packageManager } returns packageManager + every { + packageManager.queryIntentActivities(any(), any()) + } returns emptyList() + + val result = + SpeechInputHelper.isSpeechInputAvailable( + context = context, + isRecognizerServiceAvailable = { false }, + ) + assertFalse(result) + } + + @Test + fun createSpeechIntent_constructsIntentWithActionAndPrompt() { + val prompt = "Listening..." + val intent = SpeechInputHelper.createSpeechIntent(prompt) + assertNotNull(intent) + } +}