diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt index 53ec6d52..2bacb4a1 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt @@ -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( diff --git a/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt b/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt index e8de9905..f812b30a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt +++ b/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsScreen.kt new file mode 100644 index 00000000..0ad2f690 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsScreen.kt @@ -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, + 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, + ) + } + } + } + } +} 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 new file mode 100644 index 00000000..14fa4968 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt @@ -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 = 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 + 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 + 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 { 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 = _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) } + } +} diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index e6f35caf..13a85fcd 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -63,6 +63,14 @@ 시스템 설정 프로필 + Bots + 현재 활동 중 + 봇을 찾을 수 없음 + 봇 모드를 사용하려면 에이전트 프로필을 생성하세요. + 봇 검색… + 숨김 + 숨겨진 봇 표시 + 숨겨진 봇 숨기기 웹훅 게이트웨이 툴셋 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 95c33248..57f49fd8 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -67,6 +67,14 @@ 系统 设置 配置 + Bots + 当前活跃 + 未找到智能体 + 创建智能体配置文件以开始使用 Bot 模式。 + 搜索智能体… + 已隐藏 + 显示隐藏的智能体 + 隐藏不显示的智能体 Webhook 网关 工具集 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0ca9b26f..795594fc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -80,6 +80,14 @@ System Settings Profiles + Bots + Active Now + No Bots Found + Create an agent profile to start using Bot Mode. + Search bots… + Hidden + Show hidden bots + Hide hidden bots Webhooks Gateway Toolsets 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 new file mode 100644 index 00000000..6afce94d --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/bots/BotsViewModelTest.kt @@ -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) + } +}