feat(ui): add BotsScreen and BotsViewModel with active presence strip (#977)

This commit is contained in:
M57 authored and GitHub committed 2026-08-27 01:49:31 +03:00
1 parent 6af349cdf2
commit 48c9039e02
8 files changed
+699

No files matched your search

@@ -21,6 +21,8 @@ import kotlinx.serialization.Serializable
@Serializable data object ProfilesScreen : NavKey
@Serializable data object BotsScreen : NavKey
@Serializable data object ToolsetsScreen : NavKey
@Serializable data class ToolsetDetailKey(
@@ -25,6 +25,7 @@ import androidx.compose.material.icons.filled.Psychology
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Sensors
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SmartToy
import androidx.compose.material.icons.filled.ViewKanban
import androidx.compose.material.icons.filled.Webhook
import androidx.compose.runtime.Composable
@@ -34,6 +35,7 @@ import com.m57.hermescontrol.ui.common.NeurologyIcon
import com.m57.hermescontrol.ui.achievements.AchievementsScreen as AchievementsScreenContent
import com.m57.hermescontrol.ui.analytics.AnalyticsScreen as AnalyticsScreenContent
import com.m57.hermescontrol.ui.billing.BillingScreen as BillingScreenContent
import com.m57.hermescontrol.ui.bots.BotsScreen as BotsScreenContent
import com.m57.hermescontrol.ui.channels.ChannelsScreen as ChannelsScreenContent
import com.m57.hermescontrol.ui.chat.ChatScreen as ChatScreenContent
import com.m57.hermescontrol.ui.config.ConfigScreen as ConfigScreenContent
@@ -96,6 +98,12 @@ object ScreenRegistry {
Icons.Filled.AccountCircle,
DrawerSection.CONVERSE,
) { sessionId, openDrawer -> ProfilesScreenContent(onOpenDrawer = openDrawer) },
ScreenDefinition(
BotsScreen,
R.string.screen_bots,
Icons.Filled.SmartToy,
DrawerSection.CONVERSE,
) { sessionId, openDrawer -> BotsScreenContent(onOpenDrawer = openDrawer) },
ScreenDefinition(
CronJobsScreen,
R.string.screen_cron,
@@ -0,0 +1,388 @@
package com.m57.hermescontrol.ui.bots
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.ProfileInfo
import com.m57.hermescontrol.ui.common.BotAvatar
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.SkeletonListState
import com.m57.hermescontrol.ui.common.ToastEffect
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BotsScreen(
modifier: Modifier = Modifier,
onOpenDrawer: (() -> Unit)? = null,
viewModel: BotsViewModel = viewModel { BotsViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
var isSearchActive by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
ToastEffect(
toastMessage = state.toastMessage,
onClearToast = { viewModel.clearToast() },
)
HermesScaffold(
modifier = modifier,
title = {
if (isSearchActive) {
OutlinedTextField(
value = state.searchQuery,
onValueChange = { viewModel.setSearchQuery(it) },
placeholder = { Text(stringResource(R.string.bots_search_placeholder)) },
singleLine = true,
trailingIcon = {
IconButton(
onClick = {
if (state.searchQuery.isNotEmpty()) {
viewModel.setSearchQuery("")
} else {
isSearchActive = false
}
},
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(R.string.action_cancel),
)
}
},
modifier =
Modifier
.fillMaxWidth()
.testTag("bots_search_field"),
)
} else {
Text(
text = stringResource(R.string.screen_bots),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
}
},
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
actions = {
if (!isSearchActive) {
IconButton(
onClick = { isSearchActive = true },
modifier = Modifier.testTag("bots_action_search"),
) {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = stringResource(R.string.bots_search_placeholder),
)
}
}
if (state.hasHiddenBots) {
IconButton(
onClick = { viewModel.toggleShowHidden() },
modifier = Modifier.testTag("bots_action_toggle_hidden"),
) {
Icon(
imageVector =
if (state.showHidden) {
Icons.Filled.VisibilityOff
} else {
Icons.Filled.Visibility
},
contentDescription =
if (state.showHidden) {
stringResource(R.string.bots_hide_hidden)
} else {
stringResource(R.string.bots_show_hidden)
},
)
}
}
},
isRefreshing = state.isRefreshing,
onRefresh = { viewModel.loadBots(isRefresh = true) },
) {
when {
state.isLoading && state.profiles.isEmpty() -> {
SkeletonListState()
}
state.errorMessage != null && state.profiles.isEmpty() -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadBots() },
)
}
state.displayProfiles.isEmpty() -> {
EmptyState(
title = stringResource(R.string.bots_empty_title),
subtitle = stringResource(R.string.bots_empty_description),
modifier = Modifier.fillMaxSize(),
)
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (state.activeNowBots.isNotEmpty() && !isSearchActive) {
item(key = "active_now_strip") {
ActiveNowStrip(
activeBots = state.activeNowBots,
onSelectBot = { bot ->
scope.launch {
viewModel.selectBot(bot)
val canonicalId =
bot.canonical_session?.resolved_id
?: bot.canonical_session?.id
if (!canonicalId.isNullOrBlank()) {
NavigationController.openChatSession(canonicalId)
} else {
NavigationController.navigateTo(com.m57.hermescontrol.ChatScreen)
}
}
},
)
}
}
items(
items = state.displayProfiles,
key = { it.name },
) { profile ->
BotCard(
profile = profile,
isActiveProfile = profile.name == state.activeProfileName,
onClick = {
scope.launch {
viewModel.selectBot(profile)
val canonicalId =
profile.canonical_session?.resolved_id
?: profile.canonical_session?.id
if (!canonicalId.isNullOrBlank()) {
NavigationController.openChatSession(canonicalId)
} else {
NavigationController.navigateTo(com.m57.hermescontrol.ChatScreen)
}
}
},
)
}
}
}
}
}
}
@Composable
private fun ActiveNowStrip(
activeBots: List<ProfileInfo>,
onSelectBot: (ProfileInfo) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.fillMaxWidth()) {
Text(
text = stringResource(R.string.bots_active_now),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = 6.dp),
)
Row(
modifier =
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
activeBots.forEach { bot ->
Surface(
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier =
Modifier
.clip(RoundedCornerShape(20.dp))
.clickable { onSelectBot(bot) }
.testTag("bot_active_chip_${bot.name}"),
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
BotAvatar(
name = bot.name,
avatar = bot.botMeta()?.avatar,
size = 24.dp,
isActive = true,
showPresence = true,
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = bot.effectiveTitle,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
)
}
}
}
}
Spacer(modifier = Modifier.height(4.dp))
}
}
@Composable
private fun BotCard(
profile: ProfileInfo,
isActiveProfile: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val botMeta = profile.botMeta()
val isHidden = profile.isHidden
val hasWorker = profile.worker_session != null
Card(
shape = RoundedCornerShape(12.dp),
colors =
CardDefaults.cardColors(
containerColor =
if (isActiveProfile) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
} else {
MaterialTheme.colorScheme.surface
},
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
modifier =
modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.testTag("bot_card_${profile.name}"),
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
BotAvatar(
name = profile.name,
avatar = botMeta?.avatar,
size = 44.dp,
isActive = isActiveProfile || hasWorker,
showPresence = true,
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = profile.effectiveTitle,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (isHidden) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Text(
text = stringResource(R.string.bots_hidden_badge),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
}
if (profile.effectiveTitle != profile.name) {
Text(
text = "@${profile.name}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (profile.effectiveDescription.isNotBlank()) {
Text(
text = profile.effectiveDescription,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
val preview = profile.canonical_session?.preview ?: profile.last_session?.preview
if (!preview.isNullOrBlank()) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = preview,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
@@ -0,0 +1,138 @@
package com.m57.hermescontrol.ui.bots
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.ProfileInfo
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.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class BotsUiState(
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val profiles: List<ProfileInfo> = emptyList(),
val activeProfileName: String? = null,
val searchQuery: String = "",
val showHidden: Boolean = false,
val errorMessage: String? = null,
val toastMessage: String? = null,
) {
val hasHiddenBots: Boolean
get() = profiles.any { it.isHidden }
val activeNowBots: List<ProfileInfo>
get() {
val nowSeconds = System.currentTimeMillis() / 1000
return profiles.filter { profile ->
profile.worker_session != null ||
profile.name == activeProfileName ||
((profile.canonical_session?.last_active ?: 0L) > nowSeconds - 90) ||
((profile.last_session?.last_active ?: 0L) > nowSeconds - 90)
}
}
val displayProfiles: List<ProfileInfo>
get() {
val query = searchQuery.trim().lowercase()
return profiles
.filter { profile ->
if (!showHidden && profile.isHidden) return@filter false
if (query.isBlank()) return@filter true
profile.name.lowercase().contains(query) ||
profile.effectiveTitle.lowercase().contains(query) ||
profile.effectiveDescription.lowercase().contains(query)
}
.sortedWith(
compareByDescending<ProfileInfo> { it.name == activeProfileName }
.thenByDescending {
it.canonical_session?.last_active
?: it.last_session?.last_active
?: 0L
}
.thenBy { it.name },
)
}
}
class BotsViewModel(
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
autoLoad: Boolean = true,
) : ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(BotsUiState())
val uiState: StateFlow<BotsUiState> = _uiState.asStateFlow()
init {
if (autoLoad) {
loadBots()
}
}
fun loadBots(isRefresh: Boolean = false) {
_uiState.update {
if (isRefresh) {
it.copy(isRefreshing = true, errorMessage = null)
} else {
it.copy(isLoading = true, errorMessage = null)
}
}
viewModelScope.launch(ioDispatcher) {
val profilesResult = safeApiCall { ApiClient.hermesApi.getProfiles() }
val activeResult = safeApiCall { ApiClient.hermesApi.getActiveProfile() }
if (profilesResult is NetworkResult.Success) {
val activeName = (activeResult as? NetworkResult.Success)?.data?.active
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
profiles = profilesResult.data.profiles.orEmpty(),
activeProfileName = activeName ?: it.activeProfileName,
errorMessage = null,
)
}
} else {
val err =
(profilesResult as? NetworkResult.Failure)?.error?.message
?: "Failed to load bots"
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
errorMessage = err,
)
}
}
}
}
fun setSearchQuery(query: String) {
_uiState.update { it.copy(searchQuery = query) }
}
fun toggleShowHidden() {
_uiState.update { it.copy(showHidden = !it.showHidden) }
}
suspend fun selectBot(bot: ProfileInfo): Boolean {
val result = ProfileSwitchCoordinator.switchProfile(bot.name)
return result is NetworkResult.Success
}
fun showToast(message: String) {
_uiState.update { it.copy(toastMessage = message) }
}
override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}
}
+8
View File
@@ -63,6 +63,14 @@
<string name="screen_system">시스템</string>
<string name="screen_settings">설정</string>
<string name="screen_profiles">프로필</string>
<string name="screen_bots">Bots</string>
<string name="bots_active_now">현재 활동 중</string>
<string name="bots_empty_title">봇을 찾을 수 없음</string>
<string name="bots_empty_description">봇 모드를 사용하려면 에이전트 프로필을 생성하세요.</string>
<string name="bots_search_placeholder">봇 검색…</string>
<string name="bots_hidden_badge">숨김</string>
<string name="bots_show_hidden">숨겨진 봇 표시</string>
<string name="bots_hide_hidden">숨겨진 봇 숨기기</string>
<string name="screen_webhooks">웹훅</string>
<string name="screen_gateway">게이트웨이</string>
<string name="screen_toolsets">툴셋</string>
+8
View File
@@ -67,6 +67,14 @@
<string name="screen_system">系统</string>
<string name="screen_settings">设置</string>
<string name="screen_profiles">配置</string>
<string name="screen_bots">Bots</string>
<string name="bots_active_now">当前活跃</string>
<string name="bots_empty_title">未找到智能体</string>
<string name="bots_empty_description">创建智能体配置文件以开始使用 Bot 模式。</string>
<string name="bots_search_placeholder">搜索智能体…</string>
<string name="bots_hidden_badge">已隐藏</string>
<string name="bots_show_hidden">显示隐藏的智能体</string>
<string name="bots_hide_hidden">隐藏不显示的智能体</string>
<string name="screen_webhooks">Webhook</string>
<string name="screen_gateway">网关</string>
<string name="screen_toolsets">工具集</string>
+8
View File
@@ -80,6 +80,14 @@
<string name="screen_system">System</string>
<string name="screen_settings">Settings</string>
<string name="screen_profiles">Profiles</string>
<string name="screen_bots">Bots</string>
<string name="bots_active_now">Active Now</string>
<string name="bots_empty_title">No Bots Found</string>
<string name="bots_empty_description">Create an agent profile to start using Bot Mode.</string>
<string name="bots_search_placeholder">Search bots…</string>
<string name="bots_hidden_badge">Hidden</string>
<string name="bots_show_hidden">Show hidden bots</string>
<string name="bots_hide_hidden">Hide hidden bots</string>
<string name="screen_webhooks">Webhooks</string>
<string name="screen_gateway">Gateway</string>
<string name="screen_toolsets">Toolsets</string>
@@ -0,0 +1,139 @@
package com.m57.hermescontrol.ui.bots
import com.m57.hermescontrol.data.model.ActiveProfileResponse
import com.m57.hermescontrol.data.model.BotAvatarMeta
import com.m57.hermescontrol.data.model.BotRosterMeta
import com.m57.hermescontrol.data.model.CanonicalSessionInfo
import com.m57.hermescontrol.data.model.ProfileInfo
import com.m57.hermescontrol.data.model.ProfileWorkerSummary
import com.m57.hermescontrol.data.model.ProfilesResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.HermesApiService
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkObject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.encodeToJsonElement
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
import retrofit2.Response
@OptIn(ExperimentalCoroutinesApi::class)
class BotsViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var mockApi: HermesApiService
private val json = Json { ignoreUnknownKeys = true }
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
mockkObject(ApiClient)
mockApi = mockk(relaxed = true)
every { ApiClient.hermesApi } returns mockApi
}
@After
fun tearDown() {
unmockkObject(ApiClient)
Dispatchers.resetMain()
}
@Test
fun testLoadBotsAndFilterActiveNow() =
runTest(testDispatcher) {
val nowSeconds = System.currentTimeMillis() / 1000
val bot1Meta =
BotRosterMeta(
title = "Literature Scout",
description = "Arxiv searcher",
avatar = BotAvatarMeta(shape = "hexagon", color = "#4A90E2", icon = "science"),
hidden = false,
)
val bot2Meta =
BotRosterMeta(
title = "Code Reviewer",
description = "Kotlin linter",
avatar = BotAvatarMeta(shape = "square", color = "#FF5C5C", icon = "code"),
hidden = true,
)
val profiles =
listOf(
ProfileInfo(
name = "default",
is_default = true,
canonical_session =
CanonicalSessionInfo(
id = "canon-def",
last_active = nowSeconds - 20,
),
),
ProfileInfo(
name = "scout",
ui_meta = mapOf("hermes-bots" to json.encodeToJsonElement(bot1Meta)),
worker_session =
ProfileWorkerSummary(
id = "worker-1",
source = "kanban",
last_active = nowSeconds - 10,
),
),
ProfileInfo(
name = "reviewer",
ui_meta = mapOf("hermes-bots" to json.encodeToJsonElement(bot2Meta)),
canonical_session =
CanonicalSessionInfo(
id = "canon-rev",
last_active = nowSeconds - 500,
),
),
)
coEvery { mockApi.getProfiles() } returns Response.success(ProfilesResponse(profiles))
coEvery { mockApi.getActiveProfile() } returns Response.success(ActiveProfileResponse(active = "default"))
val viewModel = BotsViewModel(ioDispatcher = testDispatcher, autoLoad = false)
viewModel.loadBots()
advanceUntilIdle()
val state = viewModel.uiState.value
assertFalse(state.isLoading)
assertEquals(3, state.profiles.size)
assertEquals("default", state.activeProfileName)
assertTrue(state.hasHiddenBots)
// Active now includes default (active profile) and scout (active worker)
val activeNowNames = state.activeNowBots.map { it.name }
assertTrue(activeNowNames.contains("default"))
assertTrue(activeNowNames.contains("scout"))
assertFalse(activeNowNames.contains("reviewer"))
// Hidden filtering: reviewer is hidden by default
val displayed = state.displayProfiles.map { it.name }
assertEquals(listOf("default", "scout"), displayed)
// 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 }
assertEquals(listOf("default", "reviewer", "scout"), displayedWithHidden)
// Search filter
viewModel.setSearchQuery("arxiv")
val searchResults = viewModel.uiState.value.displayProfiles.map { it.name }
assertEquals(listOf("scout"), searchResults)
}
}