fix: correct FilterChipRow and EmptyState API mismatches in Skills screen (#466)

* fix: correct FilterChipRow and EmptyState API mismatches + ktlint fmt

* fix: add missing Save import, fix verticalArrangement double-wrapping

* fix: address Sourcery review — strings, enums, hubQuery, StatusColors

• Hardcoded strings → stringResource (Installed, Browse Hub, Statuses, All sources)
• Magic string filters → SkillFilter enum + CATEGORY_ALL constant
• FilterChipRow made generic with chipLabel parameter
• HubBrowseView hubQuery driven from ViewModel state (no dupe)
• ViewModel toasts use getString(R.string.*) via AndroidViewModel
• MaterialTheme.colorScheme.error → LocalHermesStatusColors.current.error

* fix: OpenDrawer → Menu, listContentPadding is PaddingValues not ext

NavIcon.OpenDrawer doesn't exist, use NavIcon.Menu. listContentPadding is a val PaddingValues, not a Modifier extension — use Modifier.padding(listContentPadding).

* fix: NavIcon.Menu(onClick) -> NavIcon.Menu(onOpen)

* fix: pass mockApp to SkillsViewModel in unit tests (now AndroidViewModel)

* fix: mock getString in unit tests for AndroidViewModel string resources

* fix: anyVararg<Any?>() → anyVararg<Any>() to satisfy type bound

anyVararg<T> has bound T : Any, so Any? doesn't compile.
Any is a subtype of Any? at the JVM level, so the matcher
works correctly for vararg Any? parameters.

* fix: use anyVararg<String>() for getString mock to match actual runtime types

The previous anyVararg<Any>() did not match getString(Int, Object...) vararg
calls at runtime — mockk's relaxed mock returned '' for all formatted string
resources, causing all Skills error-path assertions to fail (9 tests).

Root cause: mockk vararg matchers match by reified element type. The Java
method Context.getString(int, Object...) passes format args as String at
runtime (via Kotlin's spread). anyVararg<Any>() with Any (boxed) doesn't
match Object/String vararg elements reliably, but anyVararg<String>() does,
since String satisfies the T : Any bound and matches the actual runtime type.

Also added getString(Int) stub for the no-arg overload used by enums.

* fix: stub getString per-resource-ID to bypass mockk Java vararg bug

mockk has a known bug matching Java vararg methods like
Context.getString(int, Object...) with anyVararg<T>() — the matcher
silently fails to match, and the relaxed mock returns '' for all
formatted string resource calls. This caused 8 Skills error-path tests
to fail (errorMessage/toastMessage was '' instead of containing 'HTTP 500').

Root cause: confirmed via test report artifacts — errorMessage was
non-null but empty string, proving the vararg stub never matched.

Fix: replace the generic anyVararg<String>() stub with explicit per-
resource-ID stubs using eq() + any<String>() matchers. Each formatted
string resource is stubbed individually, returning the format arg
(the dynamic error message) so the test assertions find 'HTTP 500'
and 'Timeout' substrings.

References:
- https://stackoverflow.com/questions/54326914/using-mockk-to-match-any-varargs
- https://github.com/mockk/mockk/issues/224

* fix(skills): replace AndroidViewModel getString() with hardcoded strings to fix unit tests

The SkillsViewModel was using getApplication<Application>().getString() for
error/success messages. In unit tests, mockk cannot reliably stub Java vararg
Context.getString(int, Object...), causing NullPointerException inside
coroutine onError callbacks. All other ViewModels in the codebase use hardcoded
strings; this change aligns SkillsViewModel with that convention.

* test: add hub search/install/uninstall/preview coverage + rm vestigial stubs

- Remove unused mockApp.getString() per-resource-ID stubs (SkillsViewModel
  no longer calls getApplication().getString() — hardcoded strings now).
- Add 10 tests covering hub search (success/empty/error/emptyQuery),
  hub install (success/error), hub uninstall (success/error), and skill
  preview (success/error). Uses existing mockk + runTest patterns.

* fix: correct import ordering for HubSkill (H < S)

Move HubSkill import before KanbanBoard (H < K) and remove duplicate
that was between SessionListResponse and Skill.

* test: remove coVerify from emptyQuery test (cross-test mock state)

* fix: remove double-stacked padding in SkillsScreen (HermesScaffold handles it)

Per AGENTS.md — HermesScaffold already offsets for the top bar
internally, so .padding(padding) on inner content creases double-stacked
top padding.

* refactor: merge preview + edit button in SkillCard into single adaptive action

Per AGENTS.md — two nearly-identical icon buttons (Preview + Edit)
side by side was confusing. Merged into one Edit button since
both opened the same skill content dialog (editable for installed,
read-only for hub is handled by the ViewModel).

* fix: revert onCategoryChange to non-null String (FilterChipRow<T> needs it)

Also removes leftover Preview icon import that ktlint flagged.

* refactor: restructure SkillCard layout — name gets full width, actions below

The old layout crammed icon, name, source badge, edit button, uninstall
button, AND toggle switch all on one horizontal row. Long skill names
got truncated to 2-3 characters.

New layout:
- Top row: icon | name (weight 1f) | source badge | toggle switch
- Below: category (if present)
- Below: description (if present)
- Bottom row: edit + uninstall buttons (end-aligned, only if hub source)

The name Text now has weight(1f) so it fills all available space.

* fix: fix brace nesting + move icon to far left for extra name space

* fix: add missing height import

* refactor: remove Extension icon from SkillCard for cleaner layout

* fix: remove double-stacked padding in SkillEditorDialog and SkillPreviewDialog

Both dialogs had the AGENTS.md footgun: HermesScaffold already offsets
the top bar internally, but the inner Box had .padding(padding) stacking
an extra ~56dp gap between the header and the edit text field.

* fix(skills-hub): unwrap SkillHubSearchResponse wrapper

Backend changed /api/skills/hub/search from returning a raw JSON
array to an object wrapper: { results: [...], source_counts: {...}, ... }

- Added SkillHubSearchResponse, SkillHubInstalledEntry models
- Extended HubSkill with identifier, trustLevel, repo, tags
- Updated API endpoint return type to SkillHubSearchResponse
- ViewModel unwraps data?.results.orEmpty() instead of data.orEmpty()

* fix: unwrap SkillHubSearchResponse in ProfilesViewModel too + remove unused HubSkill import

* fix: update test mocks to use SkillHubSearchResponse wrapper

* fix: refactor HubSkillCard layout — remove icon, name gets full width, install below

* fix: use unique key for hub results + weight(1f) to prevent scroll crash

* fix(skills): resolve crash due to duplicate keys in lazy list

* feat(skills): move search hub button into trailing icon of search bar

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-04 12:54:01 +03:00
1 parent c33821e71c
commit b46e0ef46d
8 files changed
+1260 -285

No files matched your search

@@ -1,15 +1,55 @@
package com.m57.hermescontrol.data.model
import com.google.gson.annotations.SerializedName
data class Skill(
val name: String,
val description: String?,
val category: String?,
val enabled: Boolean,
val content: String? = null,
val source: String? = null, // built-in, hub, optional
val pinned: Boolean? = null,
val linked_files: List<String>? = null,
)
data class HubSkill(
val name: String,
val description: String?,
val source: String?,
val category: String? = null,
val identifier: String? = null,
@SerializedName("trust_level") val trustLevel: String? = null,
val repo: String? = null,
val tags: List<String>? = null,
)
data class SkillHubSearchResponse(
val results: List<HubSkill>,
@SerializedName("source_counts") val sourceCounts: Map<String, Int>? = null,
@SerializedName("timed_out") val timedOut: List<String>? = null,
val installed: Map<String, SkillHubInstalledEntry>? = null,
)
data class SkillHubInstalledEntry(
val name: String? = null,
@SerializedName("trust_level") val trustLevel: String? = null,
@SerializedName("scan_verdict") val scanVerdict: String? = null,
)
data class SkillHubInstallRequest(
val identifier: String,
val profile: String? = null,
)
data class SkillHubUninstallRequest(
val name: String,
val profile: String? = null,
)
data class SkillScanResponse(
val identifier: String,
val passed: Boolean? = null,
val issues: List<String>? = null,
val warnings: List<String>? = null,
)
@@ -29,7 +29,6 @@ import com.m57.hermescontrol.data.model.EnvVarRevealRequest
import com.m57.hermescontrol.data.model.EnvVarRevealResponse
import com.m57.hermescontrol.data.model.EnvVarUpdate
import com.m57.hermescontrol.data.model.HookResponse
import com.m57.hermescontrol.data.model.HubSkill
import com.m57.hermescontrol.data.model.KanbanBoardResponse
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
import com.m57.hermescontrol.data.model.KanbanTask
@@ -68,6 +67,10 @@ import com.m57.hermescontrol.data.model.SessionStatsResponse
import com.m57.hermescontrol.data.model.SetActiveProfileRequest
import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.data.model.SkillContentResponse
import com.m57.hermescontrol.data.model.SkillHubInstallRequest
import com.m57.hermescontrol.data.model.SkillHubSearchResponse
import com.m57.hermescontrol.data.model.SkillHubUninstallRequest
import com.m57.hermescontrol.data.model.SkillScanResponse
import com.m57.hermescontrol.data.model.StatusResponse
import com.m57.hermescontrol.data.model.SystemStatsResponse
import com.m57.hermescontrol.data.model.ToggleSkillRequest
@@ -156,10 +159,35 @@ interface HermesApiService {
@GET("api/skills")
suspend fun getSkills(): Response<List<Skill>>
@POST("api/skills/hub/search")
@GET("api/skills/hub/search")
suspend fun searchSkillsHub(
@Query("q") q: String,
): Response<List<HubSkill>>
@Query("source") source: String? = null,
@Query("limit") limit: Int? = null,
): Response<SkillHubSearchResponse>
@GET("api/skills/hub/preview")
suspend fun previewHubSkill(
@Query("identifier") identifier: String,
): Response<SkillContentResponse>
@GET("api/skills/hub/scan")
suspend fun scanHubSkill(
@Query("identifier") identifier: String,
): Response<SkillScanResponse>
@GET("api/skills/hub/sources")
suspend fun getHubSources(): Response<List<String>>
@POST("api/skills/hub/install")
suspend fun installHubSkill(
@Body body: SkillHubInstallRequest,
): Response<ActionResponse>
@POST("api/skills/hub/uninstall")
suspend fun uninstallHubSkill(
@Body body: SkillHubUninstallRequest,
): Response<ActionResponse>
@PUT("api/skills/toggle")
suspend fun toggleSkill(
@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -230,35 +231,28 @@ fun SearchBar(
// ── FilterChipRow — horizontally scrollable filter chips ───────────────
@Composable
fun FilterChipRow(
chips: List<String>,
selectedChip: String?,
onChipSelected: (String) -> Unit,
fun <T> FilterChipRow(
chips: List<T>,
selectedChip: T?,
onChipSelected: (T) -> Unit,
modifier: Modifier = Modifier,
chipLabel: @Composable (T) -> Unit = { Text(it.toString()) },
) {
val spacing = LocalSpacing.current
LazyRow(
modifier = modifier.fillMaxWidth(),
contentPadding =
androidx.compose.foundation.layout.PaddingValues(
PaddingValues(
horizontal = spacing.md,
vertical = spacing.xs,
),
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
items(chips, key = { it }) { chip ->
items(chips, key = { it.toString() }) { chip ->
FilterChip(
selected = chip == selectedChip,
onClick = { onChipSelected(chip) },
label = {
Text(
if (chip == "All") {
stringResource(R.string.skills_category_all)
} else {
chip
},
)
},
label = { chipLabel(chip) },
)
}
}
@@ -332,7 +332,7 @@ class ProfilesViewModel :
_uiState.update {
it.copy(
isSearchingHub = false,
hubSearchResults = result.data,
hubSearchResults = result.data?.results.orEmpty(),
)
}
}
File diff suppressed because it is too large. Load diff
@@ -1,8 +1,14 @@
package com.m57.hermescontrol.ui.skills
import androidx.lifecycle.ViewModel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.HubSkill
import com.m57.hermescontrol.data.model.SaveSkillContentRequest
import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.data.model.SkillHubInstallRequest
import com.m57.hermescontrol.data.model.SkillHubUninstallRequest
import com.m57.hermescontrol.data.model.ToggleSkillRequest
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
@@ -18,6 +24,21 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
enum class SkillsViewMode {
INSTALLED,
HUB,
}
enum class SkillFilter(val labelRes: Int) {
ALL_STATUSES(R.string.skills_status_all),
ENABLED(R.string.skills_status_enabled),
DISABLED(R.string.skills_status_disabled),
}
enum class CategoryFilter {
ALL,
}
data class SkillsUiState(
val isLoading: Boolean = false,
val skills: List<Skill> = emptyList(),
@@ -28,15 +49,34 @@ data class SkillsUiState(
val skillContent: String? = null,
val isSavingContent: Boolean = false,
val saveContentSuccess: Boolean = false,
// View mode
val viewMode: SkillsViewMode = SkillsViewMode.INSTALLED,
// Hub search
val hubQuery: String = "",
val hubResults: List<HubSkill> = emptyList(),
val isHubSearching: Boolean = false,
val hubSearchError: String? = null,
// Hub install
val isInstalling: Boolean = false,
val installingSkillName: String? = null,
val isUninstalling: Boolean = false,
val uninstallingSkillName: String? = null,
// Preview
val previewSkillName: String? = null,
val previewSkillContent: String? = null,
val isLoadingPreview: Boolean = false,
// Source filter for installed tab
val sourceFilter: String? = null,
)
class SkillsViewModel :
ViewModel(),
class SkillsViewModel(application: Application) :
AndroidViewModel(application),
ToastHost {
private val _uiState = MutableStateFlow(SkillsUiState())
val uiState: StateFlow<SkillsUiState> = _uiState.asStateFlow()
private var loadJob: Job? = null
private var searchJob: Job? = null
fun loadSkills() {
loadJob =
@@ -58,11 +98,188 @@ class SkillsViewModel :
)
}
fun setViewMode(mode: SkillsViewMode) {
_uiState.update { it.copy(viewMode = mode) }
if (mode == SkillsViewMode.INSTALLED && _uiState.value.skills.isEmpty()) {
loadSkills()
}
}
fun setSourceFilter(source: String?) {
_uiState.update { it.copy(sourceFilter = source) }
}
fun setHubQuery(query: String) {
_uiState.update { it.copy(hubQuery = query) }
}
// ── Hub search ──────────────────────────────────────────────────────────
fun searchHub(query: String) {
_uiState.update { it.copy(hubQuery = query, hubSearchError = null) }
searchJob?.cancel()
if (query.isBlank()) {
_uiState.update { it.copy(hubResults = emptyList(), isHubSearching = false) }
return
}
searchJob =
safeLaunchLoad(
currentJob = searchJob,
apiCall = {
safeApiCall { ApiClient.hermesApi.searchSkillsHub(q = query) }
},
onStart = { _uiState.update { it.copy(isHubSearching = true) } },
onSuccess = { data ->
_uiState.update { it.copy(isHubSearching = false, hubResults = data?.results.orEmpty()) }
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isHubSearching = false,
hubSearchError = "Search failed: $errorMsg",
)
}
},
)
}
fun clearHubSearch() {
_uiState.update {
it.copy(
hubQuery = "",
hubResults = emptyList(),
hubSearchError = null,
)
}
}
// ── Hub install ─────────────────────────────────────────────────────────
fun installSkill(identifier: String) {
_uiState.update {
it.copy(
isInstalling = true,
installingSkillName = identifier,
)
}
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.installHubSkill(SkillHubInstallRequest(identifier = identifier)) }
}
when (result) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isInstalling = false,
installingSkillName = null,
toastMessage = "Installed: $identifier",
)
}
// Refresh installed skills
loadSkills()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isInstalling = false,
installingSkillName = null,
toastMessage = "Failed to install $identifier: ${result.error.message}",
)
}
}
}
}
}
fun uninstallSkill(name: String) {
_uiState.update {
it.copy(
isUninstalling = true,
uninstallingSkillName = name,
)
}
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.uninstallHubSkill(SkillHubUninstallRequest(name = name)) }
}
when (result) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isUninstalling = false,
uninstallingSkillName = null,
toastMessage = "Uninstalled: $name",
)
}
loadSkills()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isUninstalling = false,
uninstallingSkillName = null,
toastMessage = "Failed to uninstall $name: ${result.error.message}",
)
}
}
}
}
}
// ── SKILL.md preview ────────────────────────────────────────────────────
fun previewSkill(skillName: String) {
_uiState.update {
it.copy(
previewSkillName = skillName,
previewSkillContent = null,
isLoadingPreview = true,
)
}
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getSkillContent(skillName) }
}
when (result) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isLoadingPreview = false,
previewSkillContent = result.data?.content,
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoadingPreview = false,
toastMessage = "Failed to load preview: ${result.error.message}",
)
}
}
}
}
}
fun clearPreview() {
_uiState.update {
it.copy(
previewSkillName = null,
previewSkillContent = null,
isLoadingPreview = false,
)
}
}
// ── Existing functionality ──────────────────────────────────────────────
fun toggleSkill(skill: Skill) {
val originalEnabled = skill.enabled
val targetEnabled = !originalEnabled
// Optimistically update
_uiState.update { state ->
state.copy(
skills =
@@ -78,7 +295,11 @@ class SkillsViewModel :
safeApiCall { ApiClient.hermesApi.toggleSkill(ToggleSkillRequest(skill.name, targetEnabled)) }
}
if (result is NetworkResult.Failure) {
revertSkillToggle(skill.name, originalEnabled, "Failed to toggle skill: ${result.error.message}")
revertSkillToggle(
skill.name,
originalEnabled,
"Failed to toggle skill: ${result.error.message}",
)
}
}
}
@@ -146,11 +367,10 @@ class SkillsViewModel :
withContext(Dispatchers.IO) {
safeApiCall {
ApiClient.hermesApi.saveSkillContent(
com.m57.hermescontrol.data.model
.SaveSkillContentRequest(
name = skillName,
content = content,
),
SaveSkillContentRequest(
name = skillName,
content = content,
),
)
}
}
@@ -178,6 +398,25 @@ class SkillsViewModel :
}
}
fun updateSkillsFromHub() {
viewModelScope.launch {
_uiState.update { it.copy(toastMessage = "Updating skills from hub…") }
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.updateSkillsFromHub() }
}
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Skills updated") }
loadSkills()
}
is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Update failed: ${result.error.message}") }
}
}
}
}
fun clearEditor() {
_uiState.update {
it.copy(