diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/sessions/SessionLiveStatusIndicatorTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/sessions/SessionLiveStatusIndicatorTest.kt new file mode 100644 index 00000000..2a10a610 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/sessions/SessionLiveStatusIndicatorTest.kt @@ -0,0 +1,180 @@ +package com.m57.hermescontrol.ui.sessions + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.m57.hermescontrol.data.model.SessionInfo +import com.m57.hermescontrol.data.model.SessionLiveStatus +import com.m57.hermescontrol.theme.HermesControlTheme +import com.m57.hermescontrol.ui.sessions.components.SearchResultCard +import com.m57.hermescontrol.ui.sessions.components.SessionCard +import com.m57.hermescontrol.ui.sessions.components.SessionLiveStatusIndicator +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@MediumTest +class SessionLiveStatusIndicatorTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun workingStatus_displaysRunningIndicator() { + composeTestRule.setContent { + HermesControlTheme { + SessionLiveStatusIndicator( + liveStatus = SessionLiveStatus.WORKING, + sessionId = "sess-working", + ) + } + } + + composeTestRule.onNodeWithTag("session_live_status_sess-working").assertIsDisplayed() + composeTestRule.onNodeWithText("Running").assertIsDisplayed() + } + + @Test + fun waitingStatus_displaysNeedsInputIndicator() { + composeTestRule.setContent { + HermesControlTheme { + SessionLiveStatusIndicator( + liveStatus = SessionLiveStatus.WAITING, + sessionId = "sess-waiting", + ) + } + } + + composeTestRule.onNodeWithTag("session_live_status_sess-waiting").assertIsDisplayed() + composeTestRule.onNodeWithText("Needs input").assertIsDisplayed() + } + + @Test + fun sessionCard_withLiveStatus_displaysIndicator() { + val session = + SessionInfo( + id = "sess-card-working", + title = "Working session", + message_count = 5, + ) + + composeTestRule.setContent { + HermesControlTheme { + SessionCard( + session = session, + displayTitle = "Working session", + branchStem = null, + isFork = false, + forkDepth = 0, + query = "", + isSelecting = false, + isSelected = false, + isDeleting = false, + isPinned = false, + isHidden = false, + liveStatus = SessionLiveStatus.WORKING, + highlightBackground = Color.Transparent, + highlightForeground = Color.Transparent, + onCardClick = {}, + onToggleSelection = {}, + onSelect = {}, + onRename = {}, + onTogglePin = {}, + onToggleHide = {}, + onDelete = {}, + ) + } + } + + composeTestRule.onNodeWithTag("session_card_sess-card-working").assertIsDisplayed() + composeTestRule + .onNodeWithTag( + "session_live_status_sess-card-working", + useUnmergedTree = true, + ).assertIsDisplayed() + composeTestRule.onNodeWithText("Running", useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun sessionCard_withoutLiveStatus_hidesIndicator() { + val session = + SessionInfo( + id = "sess-card-idle", + title = "Idle session", + message_count = 2, + ) + + composeTestRule.setContent { + HermesControlTheme { + SessionCard( + session = session, + displayTitle = "Idle session", + branchStem = null, + isFork = false, + forkDepth = 0, + query = "", + isSelecting = false, + isSelected = false, + isDeleting = false, + isPinned = false, + isHidden = false, + liveStatus = null, + highlightBackground = Color.Transparent, + highlightForeground = Color.Transparent, + onCardClick = {}, + onToggleSelection = {}, + onSelect = {}, + onRename = {}, + onTogglePin = {}, + onToggleHide = {}, + onDelete = {}, + ) + } + } + + composeTestRule.onNodeWithTag("session_card_sess-card-idle").assertIsDisplayed() + composeTestRule.onNodeWithTag("session_live_status_sess-card-idle").assertDoesNotExist() + } + + @Test + fun searchResultCard_withLiveStatus_displaysIndicator() { + val session = + SessionInfo( + id = "sess-search-waiting", + preview = "Searching something", + source = "web", + ) + + composeTestRule.setContent { + HermesControlTheme { + SearchResultCard( + session = session, + query = "Searching", + isSelecting = false, + isSelected = false, + isDeleting = false, + liveStatus = SessionLiveStatus.WAITING, + highlightBackground = Color.Transparent, + highlightForeground = Color.Transparent, + onCardClick = {}, + onToggleSelection = {}, + onSelect = {}, + onRename = {}, + onDelete = {}, + ) + } + } + + composeTestRule.onNodeWithTag("session_card_sess-search-waiting").assertIsDisplayed() + composeTestRule + .onNodeWithTag( + "session_live_status_sess-search-waiting", + useUnmergedTree = true, + ).assertIsDisplayed() + composeTestRule.onNodeWithText("Needs input", useUnmergedTree = true).assertIsDisplayed() + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionLiveStatus.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionLiveStatus.kt new file mode 100644 index 00000000..3f201350 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionLiveStatus.kt @@ -0,0 +1,41 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * High-level live status for a session in History. + */ +enum class SessionLiveStatus { + WORKING, + WAITING, +} + +/** + * RPC response from `session.active_list`. + */ +@Serializable +data class ActiveSessionsResponse( + val sessions: List = emptyList(), +) + +/** + * Individual item in `session.active_list`. + */ +@Serializable +data class ActiveSessionItem( + val id: String? = null, + @SerialName("session_key") + val sessionKey: String? = null, + val status: String? = null, + @SerialName("last_active") + val lastActive: Double? = null, +) + +/** + * Parsed authoritative snapshot of live sessions. + */ +data class LiveSessionSnapshot( + val statusByStoredId: Map = emptyMap(), + val storedIdByRuntimeId: Map = emptyMap(), +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/SessionLiveStatusSource.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/SessionLiveStatusSource.kt new file mode 100644 index 00000000..31cc88b4 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/SessionLiveStatusSource.kt @@ -0,0 +1,169 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.model.ActiveSessionsResponse +import com.m57.hermescontrol.data.model.LiveSessionSnapshot +import com.m57.hermescontrol.data.model.SessionLiveStatus +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.serializer + +/** + * Interface for polling and observing live session statuses. + */ +interface SessionLiveStatusSource { + suspend fun fetchActiveSessionsSnapshot(): LiveSessionSnapshot? + + val events: Flow + val connectionStatus: StateFlow +} + +/** + * Decodes untyped RPC responses from `session.active_list` into a [LiveSessionSnapshot]. + */ +object SessionLiveStatusDecoder { + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + coerceInputValues = true + } + + fun decodeSnapshot(rawResult: Any?): LiveSessionSnapshot? { + if (rawResult == null) return null + val response = + try { + decodeResponse(rawResult) + } catch (_: Exception) { + return null + } ?: return null + + val statusByStoredId = mutableMapOf() + val storedIdByRuntimeId = mutableMapOf() + + for (item in response.sessions) { + val runtimeId = item.id?.trim() + val storedId = item.sessionKey?.trim() + if (runtimeId.isNullOrEmpty() || storedId.isNullOrEmpty()) { + continue + } + storedIdByRuntimeId[runtimeId] = storedId + when (item.status?.trim()?.lowercase()) { + "working" -> { + statusByStoredId[storedId] = SessionLiveStatus.WORKING + } + + "waiting" -> { + statusByStoredId[storedId] = SessionLiveStatus.WAITING + } + + else -> { + // idle, starting, unknown -> no live indicator + } + } + } + + return LiveSessionSnapshot( + statusByStoredId = statusByStoredId, + storedIdByRuntimeId = storedIdByRuntimeId, + ) + } + + private fun decodeResponse(result: Any): ActiveSessionsResponse? { + val element: JsonElement = + when (result) { + is JsonElement -> { + result + } + + is Map<*, *> -> { + anyToJsonElement(result) + } + + is List<*> -> { + JsonArray(result.map { anyToJsonElement(it) }) + } + + else -> { + val str = result.toString() + try { + json.parseToJsonElement(str) + } catch (_: Exception) { + return null + } + } + } + if (element !is JsonObject || !element.containsKey("sessions")) { + return null + } + return json.decodeFromJsonElement(serializer(), element) + } + + @Suppress("UNCHECKED_CAST") + internal fun anyToJsonElement(value: Any?): JsonElement = + when (value) { + null -> { + JsonNull + } + + is JsonElement -> { + value + } + + is Map<*, *> -> { + JsonObject( + (value as Map).mapValues { (_, v) -> anyToJsonElement(v) }, + ) + } + + is List<*> -> { + JsonArray(value.map { anyToJsonElement(it) }) + } + + is String -> { + JsonPrimitive(value) + } + + is Boolean -> { + JsonPrimitive(value) + } + + is Number -> { + JsonPrimitive(value) + } + + else -> { + JsonPrimitive(value.toString()) + } + } +} + +/** + * Production implementation of [SessionLiveStatusSource] backed by [HermesWsClient]. + */ +class HermesSessionLiveStatusSource( + private val rpcRequest: suspend (method: String, params: Map) -> Any? = { method, params -> + HermesWsClient.request(method, params).await() + }, + eventsProvider: () -> Flow = { HermesWsClient.events }, + connectionStatusProvider: () -> StateFlow = { HermesWsClient.connectionStatus }, +) : SessionLiveStatusSource { + override val events: Flow by lazy { eventsProvider() } + override val connectionStatus: StateFlow by lazy { connectionStatusProvider() } + + override suspend fun fetchActiveSessionsSnapshot(): LiveSessionSnapshot? = + try { + val raw = rpcRequest(WsMethods.SESSION_ACTIVE_LIST, emptyMap()) + SessionLiveStatusDecoder.decodeSnapshot(raw) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionLiveStatusReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionLiveStatusReducer.kt new file mode 100644 index 00000000..0fa4223b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionLiveStatusReducer.kt @@ -0,0 +1,156 @@ +package com.m57.hermescontrol.ui.sessions + +import com.m57.hermescontrol.data.model.LiveSessionSnapshot +import com.m57.hermescontrol.data.model.SessionLiveStatus +import com.m57.hermescontrol.data.ws.WsEvent + +/** + * Transient state for tracking live active sessions in History. + */ +data class SessionLiveTrackingState( + val liveStatuses: Map = emptyMap(), + val storedIdByRuntimeId: Map = emptyMap(), +) + +/** + * Pure reducer managing live active session transitions. + */ +object SessionLiveStatusReducer { + fun applySnapshot( + state: SessionLiveTrackingState, + snapshot: LiveSessionSnapshot, + ): SessionLiveTrackingState = + SessionLiveTrackingState( + liveStatuses = snapshot.statusByStoredId, + storedIdByRuntimeId = snapshot.storedIdByRuntimeId, + ) + + fun applyWsEvent( + state: SessionLiveTrackingState, + event: WsEvent, + ): SessionLiveTrackingState = + when (event) { + is WsEvent.SessionInfo -> reduceSessionInfo(state, event) + is WsEvent.MessageStart -> reduceMessageStart(state, event) + is WsEvent.MessageComplete -> reduceMessageComplete(state, event) + is WsEvent.MessageDone -> reduceMessageDone(state, event) + is WsEvent.ApprovalRequest -> reduceApprovalRequest(state, event) + is WsEvent.ClarifyRequest -> reduceClarifyRequest(state, event) + else -> state + } + + fun clear(): SessionLiveTrackingState = SessionLiveTrackingState() + + private fun reduceSessionInfo( + state: SessionLiveTrackingState, + event: WsEvent.SessionInfo, + ): SessionLiveTrackingState { + val data = event.data ?: return state + val storedId = (data["stored_session_id"] as? String)?.trim()?.takeIf { it.isNotEmpty() } + val runtimeId = (data["session_id"] as? String)?.trim()?.takeIf { it.isNotEmpty() } + val running = data["running"] as? Boolean + + val targetStoredId = storedId ?: runtimeId?.let { state.storedIdByRuntimeId[it] } + if (targetStoredId == null && runtimeId == null) { + return state + } + + val oldStoredId = runtimeId?.let { state.storedIdByRuntimeId[it] } + val nextMapping = + if (runtimeId != null && targetStoredId != null) { + state.storedIdByRuntimeId + (runtimeId to targetStoredId) + } else { + state.storedIdByRuntimeId + } + + if (targetStoredId == null) { + return state.copy(storedIdByRuntimeId = nextMapping) + } + + val nextStatuses = state.liveStatuses.toMutableMap() + if (oldStoredId != null && oldStoredId != targetStoredId) { + nextStatuses.remove(oldStoredId) + } + + when (running) { + true -> { + nextStatuses[targetStoredId] = SessionLiveStatus.WORKING + } + + false -> { + nextStatuses.remove(targetStoredId) + } + + null -> { + // If running is not specified, leave status untouched + } + } + + return state.copy( + liveStatuses = nextStatuses, + storedIdByRuntimeId = nextMapping, + ) + } + + private fun reduceMessageStart( + state: SessionLiveTrackingState, + event: WsEvent.MessageStart, + ): SessionLiveTrackingState { + val runtimeId = event.sessionId?.trim()?.takeIf { it.isNotEmpty() } ?: return state + val storedId = state.storedIdByRuntimeId[runtimeId] ?: return state + if (state.liveStatuses[storedId] == SessionLiveStatus.WORKING) return state + return state.copy( + liveStatuses = state.liveStatuses + (storedId to SessionLiveStatus.WORKING), + ) + } + + private fun reduceMessageComplete( + state: SessionLiveTrackingState, + event: WsEvent.MessageComplete, + ): SessionLiveTrackingState { + val runtimeId = event.sessionId?.trim()?.takeIf { it.isNotEmpty() } + val storedId = + event.storedSessionId?.trim()?.takeIf { it.isNotEmpty() } + ?: runtimeId?.let { state.storedIdByRuntimeId[it] } + ?: return state + + if (!state.liveStatuses.containsKey(storedId)) return state + return state.copy( + liveStatuses = state.liveStatuses - storedId, + ) + } + + private fun reduceMessageDone( + state: SessionLiveTrackingState, + event: WsEvent.MessageDone, + ): SessionLiveTrackingState { + val runtimeId = event.sessionId?.trim()?.takeIf { it.isNotEmpty() } ?: return state + val storedId = state.storedIdByRuntimeId[runtimeId] ?: return state + if (!state.liveStatuses.containsKey(storedId)) return state + return state.copy( + liveStatuses = state.liveStatuses - storedId, + ) + } + + private fun reduceApprovalRequest( + state: SessionLiveTrackingState, + event: WsEvent.ApprovalRequest, + ): SessionLiveTrackingState { + val runtimeId = event.sessionId?.trim()?.takeIf { it.isNotEmpty() } ?: return state + val storedId = state.storedIdByRuntimeId[runtimeId] ?: return state + return state.copy( + liveStatuses = state.liveStatuses + (storedId to SessionLiveStatus.WAITING), + ) + } + + private fun reduceClarifyRequest( + state: SessionLiveTrackingState, + event: WsEvent.ClarifyRequest, + ): SessionLiveTrackingState { + val runtimeId = event.sessionId?.trim()?.takeIf { it.isNotEmpty() } ?: return state + val storedId = state.storedIdByRuntimeId[runtimeId] ?: return state + return state.copy( + liveStatuses = state.liveStatuses + (storedId to SessionLiveStatus.WAITING), + ) + } +} 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 9e5b1bbf..edfc8465 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 @@ -72,6 +72,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -274,6 +275,13 @@ fun SessionsScreen( viewModel.loadSessions() } + DisposableEffect(Unit) { + viewModel.startLiveStatusTracking() + onDispose { + viewModel.stopLiveStatusTracking() + } + } + // Toast effect ToastEffect( toastMessage = state.toastMessage, @@ -612,6 +620,7 @@ fun SessionsScreen( isSelecting = state.isSelecting, isSelected = session.id in state.selectedIds, isDeleting = session.id in state.deletingSessionIds, + liveStatus = state.liveStatuses[session.id], highlightBackground = primaryContainer, highlightForeground = onPrimaryContainer, onCardClick = { @@ -760,6 +769,7 @@ fun SessionsScreen( isDeleting = session.id in state.deletingSessionIds, isPinned = session.pinned == true, isHidden = session.hidden == true, + liveStatus = state.liveStatuses[session.id], highlightBackground = primaryContainer, highlightForeground = onPrimaryContainer, onCardClick = { 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 11f0d107..6b053b09 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 @@ -5,12 +5,18 @@ import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.data.model.BulkDeleteRequest import com.m57.hermescontrol.data.model.PruneRequest import com.m57.hermescontrol.data.model.SessionInfo +import com.m57.hermescontrol.data.model.SessionLiveStatus import com.m57.hermescontrol.data.model.SessionRenameRequest import com.m57.hermescontrol.data.model.SessionSearchResult 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.ws.ChangeEventHub import com.m57.hermescontrol.data.ws.ChangeEvents +import com.m57.hermescontrol.data.ws.ConnectionStatus +import com.m57.hermescontrol.data.ws.HermesSessionLiveStatusSource +import com.m57.hermescontrol.data.ws.SessionLiveStatusSource +import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.refreshOnChange import com.m57.hermescontrol.ui.common.safeLaunchLoad @@ -19,6 +25,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.util.Locale @@ -90,6 +97,7 @@ data class SessionsUiState( val searchError: String? = null, val showHidden: Boolean = false, val pinnedExpanded: Boolean = true, + val liveStatuses: Map = emptyMap(), ) { val isSearchMode: Boolean get() = searchQuery.isNotBlank() @@ -103,8 +111,9 @@ data class SessionsUiState( get() = displaySessions.filter { it.pinned == true } } -class SessionsViewModel : - ViewModel(), +class SessionsViewModel( + private val liveStatusSource: SessionLiveStatusSource = HermesSessionLiveStatusSource(), +) : ViewModel(), ToastHost { private val _uiState = MutableStateFlow(SessionsUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -112,6 +121,11 @@ class SessionsViewModel : private var loadJob: Job? = null private var pageJob: Job? = null private var statsJob: Job? = null + private var trackingJob: Job? = null + private var trackingGeneration: Long = 0 + private var liveTrackingState = SessionLiveTrackingState() + private var liveStatusRefreshInFlight = false + private var liveStatusRefreshPending = false private var generation: Long = 0 private var rawPaginationOffset: Int = 0 @@ -172,6 +186,7 @@ class SessionsViewModel : const val SEARCH_DEBOUNCE_MS = 300L const val MAX_STITCH_PER_PAGE = 8 const val MAX_STITCH_ROUNDS = 3 + const val LIVE_STATUS_POLL_INTERVAL_MS = 30_000L } private val resolvedParentCache = mutableMapOf() @@ -259,6 +274,9 @@ class SessionsViewModel : val section = _uiState.value.section pageJob?.cancel() loadEmptyCount() + if (trackingJob?.isActive == true) { + refreshLiveStatuses() + } loadJob = safeLaunchLoad( currentJob = loadJob, @@ -863,4 +881,106 @@ class SessionsViewModel : override fun clearToast() { _uiState.update { it.copy(toastMessage = null) } } + + // ── Live Session Status Tracking (issue #1101) ─────────────────────── + + fun startLiveStatusTracking() { + if (trackingJob?.isActive == true) return + trackingJob = + viewModelScope.launch { + // Connection status observer: non-connected clears immediately, CONNECTED rehydrates. + launch { + liveStatusSource.connectionStatus.collect { status -> + if (status != ConnectionStatus.CONNECTED) { + trackingGeneration++ + liveTrackingState = SessionLiveStatusReducer.clear() + _uiState.update { it.copy(liveStatuses = emptyMap()) } + } else { + requestLiveStatusSnapshot(++trackingGeneration) + } + } + } + + // Live WebSocket events observer. + launch { + liveStatusSource.events.collect { event -> + liveTrackingState = SessionLiveStatusReducer.applyWsEvent(liveTrackingState, event) + _uiState.update { it.copy(liveStatuses = liveTrackingState.liveStatuses) } + } + } + + // Change events observer: sessions.changed triggers a snapshot refresh. + launch { + ChangeEventHub.events + .filter { it.type == ChangeEvents.SESSIONS } + .collect { + requestLiveStatusSnapshot(trackingGeneration) + } + } + + // Visible-only 30s poll backstop + launch { + while (true) { + delay(LIVE_STATUS_POLL_INTERVAL_MS) + requestLiveStatusSnapshot(trackingGeneration) + } + } + } + } + + fun stopLiveStatusTracking() { + trackingGeneration++ + trackingJob?.cancel() + trackingJob = null + liveStatusRefreshInFlight = false + liveStatusRefreshPending = false + liveTrackingState = SessionLiveStatusReducer.clear() + _uiState.update { it.copy(liveStatuses = emptyMap()) } + } + + fun refreshLiveStatuses() { + requestLiveStatusSnapshot(trackingGeneration) + } + + private fun requestLiveStatusSnapshot(gen: Long) { + if (trackingJob?.isActive != true || + gen != trackingGeneration || + liveStatusSource.connectionStatus.value != ConnectionStatus.CONNECTED + ) { + return + } + if (liveStatusRefreshInFlight) { + liveStatusRefreshPending = true + return + } + liveStatusRefreshInFlight = true + viewModelScope.launch { + try { + val snapshot = liveStatusSource.fetchActiveSessionsSnapshot() + if (trackingJob?.isActive == true && + gen == trackingGeneration && + liveStatusSource.connectionStatus.value == ConnectionStatus.CONNECTED && + snapshot != null + ) { + liveTrackingState = SessionLiveStatusReducer.applySnapshot(liveTrackingState, snapshot) + _uiState.update { it.copy(liveStatuses = liveTrackingState.liveStatuses) } + } + } finally { + liveStatusRefreshInFlight = false + if (liveStatusRefreshPending && + trackingJob?.isActive == true && + gen == trackingGeneration && + liveStatusSource.connectionStatus.value == ConnectionStatus.CONNECTED + ) { + liveStatusRefreshPending = false + requestLiveStatusSnapshot(gen) + } + } + } + } + + override fun onCleared() { + super.onCleared() + stopLiveStatusTracking() + } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SearchResultCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SearchResultCard.kt index 054703d4..b5806b9c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SearchResultCard.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SearchResultCard.kt @@ -37,6 +37,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.SessionInfo +import com.m57.hermescontrol.data.model.SessionLiveStatus +import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalSpacing import com.m57.hermescontrol.ui.common.StatusBadge import com.m57.hermescontrol.ui.common.StatusBadgeType @@ -56,6 +58,7 @@ fun SearchResultCard( isSelecting: Boolean, isSelected: Boolean, isDeleting: Boolean, + liveStatus: SessionLiveStatus? = null, highlightBackground: Color, highlightForeground: Color, onCardClick: () -> Unit, @@ -65,6 +68,7 @@ fun SearchResultCard( onDelete: () -> Unit, ) { val spacing = LocalSpacing.current + val statusColors = LocalHermesStatusColors.current val snippet = session.preview?.takeIf { it.isNotBlank() } ?: stringResource(R.string.history_untitled) val cleanSnippet = cleanSearchSnippet(snippet) val playedAt = formatPlayedAt(session.started_at) @@ -86,6 +90,10 @@ fun SearchResultCard( border = if (isSelected) { BorderStroke(2.dp, MaterialTheme.colorScheme.primary) + } else if (!isSelecting && liveStatus == SessionLiveStatus.WORKING) { + BorderStroke(2.dp, statusColors.success) + } else if (!isSelecting && liveStatus == SessionLiveStatus.WAITING) { + BorderStroke(2.dp, statusColors.warning) } else { null }, @@ -153,6 +161,12 @@ fun SearchResultCard( horizontalArrangement = Arrangement.spacedBy(spacing.xs), verticalArrangement = Arrangement.spacedBy(spacing.xs), ) { + if (liveStatus != null) { + SessionLiveStatusIndicator( + liveStatus = liveStatus, + sessionId = session.id, + ) + } session.source?.let { src -> StatusBadge( text = sourceLabel(src), diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt index 33b5ea7d..108de012 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt @@ -46,6 +46,7 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.SessionInfo +import com.m57.hermescontrol.data.model.SessionLiveStatus import com.m57.hermescontrol.data.model.SessionTreeItem import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalSpacing @@ -146,6 +147,7 @@ fun SessionCard( isDeleting: Boolean, isPinned: Boolean, isHidden: Boolean = false, + liveStatus: SessionLiveStatus? = null, highlightBackground: Color, highlightForeground: Color, onCardClick: () -> Unit, @@ -158,7 +160,6 @@ fun SessionCard( ) { val spacing = LocalSpacing.current val statusColors = LocalHermesStatusColors.current - val isActive = session.status?.lowercase() == "active" || session.status?.lowercase() == "streaming" val srcIcon = sourceIcon(session.source) var menuExpanded by remember { mutableStateOf(false) } @@ -176,10 +177,12 @@ fun SessionCard( containerColor = MaterialTheme.colorScheme.surfaceContainer, ), border = - if (isActive && !isSelecting) { - BorderStroke(2.dp, statusColors.success) - } else if (isSelected) { + if (isSelected) { BorderStroke(2.dp, MaterialTheme.colorScheme.primary) + } else if (!isSelecting && liveStatus == SessionLiveStatus.WORKING) { + BorderStroke(2.dp, statusColors.success) + } else if (!isSelecting && liveStatus == SessionLiveStatus.WAITING) { + BorderStroke(2.dp, statusColors.warning) } else { null }, @@ -270,11 +273,11 @@ fun SessionCard( status = StatusBadgeType.NEUTRAL, ) } - if (!session.status.isNullOrBlank()) { + if (liveStatus != null) { Spacer(modifier = Modifier.width(spacing.sm)) - StatusBadge( - text = session.status, - status = if (isActive) StatusBadgeType.SUCCESS else StatusBadgeType.NEUTRAL, + SessionLiveStatusIndicator( + liveStatus = liveStatus, + sessionId = session.id, ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionLiveStatusIndicator.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionLiveStatusIndicator.kt new file mode 100644 index 00000000..112493f2 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionLiveStatusIndicator.kt @@ -0,0 +1,112 @@ +package com.m57.hermescontrol.ui.sessions.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.SessionLiveStatus +import com.m57.hermescontrol.theme.LocalHermesStatusColors +import com.m57.hermescontrol.theme.LocalSpacing + +@Composable +fun SessionLiveStatusIndicator( + liveStatus: SessionLiveStatus, + sessionId: String, + modifier: Modifier = Modifier, +) { + val spacing = LocalSpacing.current + val statusColors = LocalHermesStatusColors.current + + val (bgColor, fgColor, textRes, descRes) = + when (liveStatus) { + SessionLiveStatus.WORKING -> { + Tuple4( + statusColors.successContainer, + statusColors.success, + R.string.sessions_live_status_running, + R.string.sessions_live_status_running_desc, + ) + } + + SessionLiveStatus.WAITING -> { + Tuple4( + statusColors.warningContainer, + statusColors.warning, + R.string.sessions_live_status_waiting, + R.string.sessions_live_status_waiting_desc, + ) + } + } + + val text = stringResource(textRes) + val description = stringResource(descRes) + + Surface( + modifier = + modifier + .testTag("session_live_status_$sessionId") + .semantics { contentDescription = description }, + shape = RoundedCornerShape(8.dp), + color = bgColor, + ) { + Row( + modifier = + Modifier.padding( + horizontal = spacing.sm, + vertical = 2.dp, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + when (liveStatus) { + SessionLiveStatus.WORKING -> { + CircularProgressIndicator( + modifier = Modifier.size(10.dp), + strokeWidth = 1.5.dp, + color = fgColor, + ) + } + + SessionLiveStatus.WAITING -> { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null, + tint = fgColor, + modifier = Modifier.size(12.dp), + ) + } + } + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = fgColor, + fontWeight = FontWeight.SemiBold, + ) + } + } +} + +private data class Tuple4( + val first: A, + val second: B, + val third: C, + val fourth: D, +) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 8e05c1b1..1fabe321 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -543,6 +543,10 @@ جميع الجلسات مخفية حاليًا. اضغط أدناه لإظهار الجلسات المخفية. مثبتة جلسة مفرعة +قيد التشغيل +بانتظار إدخال +الجلسة قيد التشغيل +الجلسة بانتظار إدخال من المستخدم إعادة تسمية الجلسة عنوان الجلسة لم يتم العثور على مجموعات أدوات diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 39b4ad51..631c47b6 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -423,6 +423,10 @@ 숨겨진 세션 숨기기 숨김 모든 세션이 현재 숨겨져 있습니다. 아래를 탭하여 숨겨진 세션을 표시하세요. + 실행 중 + 입력 대기 중 + 세션이 실행 중입니다 + 세션이 사용자 입력을 대기하고 있습니다 툴셋이 없습니다 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index a6c25201..3745a59c 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -534,6 +534,10 @@ 所有会话当前均已隐藏。点击下方以显示已隐藏的会话。 已置顶 分支会话 + 运行中 + 等待输入 + 会话正在运行中 + 会话等待用户输入 重命名会话 会话标题 没有工具集数据 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 21d1b61d..8b4897ef 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -619,6 +619,10 @@ All sessions are currently hidden. Tap below to show hidden sessions. Pinned Forked session + Running + Needs input + Session is running + Session needs user input Rename Session Session title diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/SessionLiveStatusSourceTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/SessionLiveStatusSourceTest.kt new file mode 100644 index 00000000..4a32ef87 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/SessionLiveStatusSourceTest.kt @@ -0,0 +1,168 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.model.LiveSessionSnapshot +import com.m57.hermescontrol.data.model.SessionLiveStatus +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +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 SessionLiveStatusSourceTest { + @Test + fun `decodeSnapshot decodes working and waiting statuses by stored id`() { + val payload = + mapOf( + "sessions" to + listOf( + mapOf( + "id" to "runtime-1", + "session_key" to "stored-1", + "status" to "working", + ), + mapOf( + "id" to "runtime-2", + "session_key" to "stored-2", + "status" to "waiting", + ), + ), + ) + + val snapshot = SessionLiveStatusDecoder.decodeSnapshot(payload) + + assertNotNull(snapshot) + assertEquals(SessionLiveStatus.WORKING, snapshot!!.statusByStoredId["stored-1"]) + assertEquals(SessionLiveStatus.WAITING, snapshot.statusByStoredId["stored-2"]) + assertEquals("stored-1", snapshot.storedIdByRuntimeId["runtime-1"]) + assertEquals("stored-2", snapshot.storedIdByRuntimeId["runtime-2"]) + } + + @Test + fun `decodeSnapshot ignores idle and starting for status but keeps runtime mapping`() { + val payload = + mapOf( + "sessions" to + listOf( + mapOf( + "id" to "runtime-idle", + "session_key" to "stored-idle", + "status" to "idle", + ), + mapOf( + "id" to "runtime-start", + "session_key" to "stored-start", + "status" to "starting", + ), + ), + ) + + val snapshot = SessionLiveStatusDecoder.decodeSnapshot(payload) + + assertNotNull(snapshot) + assertTrue(snapshot!!.statusByStoredId.isEmpty()) + assertEquals("stored-idle", snapshot.storedIdByRuntimeId["runtime-idle"]) + assertEquals("stored-start", snapshot.storedIdByRuntimeId["runtime-start"]) + } + + @Test + fun `decodeSnapshot skips items with blank id or session_key`() { + val payload = + mapOf( + "sessions" to + listOf( + mapOf( + "id" to "", + "session_key" to "stored-1", + "status" to "working", + ), + mapOf( + "id" to "runtime-2", + "session_key" to " ", + "status" to "working", + ), + mapOf( + "id" to "runtime-3", + "session_key" to "stored-3", + "status" to "working", + ), + ), + ) + + val snapshot = SessionLiveStatusDecoder.decodeSnapshot(payload) + + assertNotNull(snapshot) + assertEquals(1, snapshot!!.statusByStoredId.size) + assertEquals(SessionLiveStatus.WORKING, snapshot.statusByStoredId["stored-3"]) + assertEquals(1, snapshot.storedIdByRuntimeId.size) + assertEquals("stored-3", snapshot.storedIdByRuntimeId["runtime-3"]) + } + + @Test + fun `decodeSnapshot returns authoritative empty snapshot on empty sessions list`() { + val payload = mapOf("sessions" to emptyList>()) + + val snapshot = SessionLiveStatusDecoder.decodeSnapshot(payload) + + assertNotNull(snapshot) + assertTrue(snapshot!!.statusByStoredId.isEmpty()) + assertTrue(snapshot.storedIdByRuntimeId.isEmpty()) + } + + @Test + fun `decodeSnapshot returns null for malformed or missing sessions field`() { + assertNull(SessionLiveStatusDecoder.decodeSnapshot(null)) + assertNull(SessionLiveStatusDecoder.decodeSnapshot(mapOf("other" to "data"))) + assertNull(SessionLiveStatusDecoder.decodeSnapshot("invalid-json-string")) + } + + @Test + fun `HermesSessionLiveStatusSource fetches and decodes snapshot via RPC`() = + runBlocking { + var capturedMethod: String? = null + var capturedParams: Map? = null + + val source = + HermesSessionLiveStatusSource( + rpcRequest = { method, params -> + capturedMethod = method + capturedParams = params + mapOf( + "sessions" to + listOf( + mapOf( + "id" to "rt-1", + "session_key" to "st-1", + "status" to "working", + ), + ), + ) + }, + eventsProvider = { MutableSharedFlow() }, + connectionStatusProvider = { MutableStateFlow(ConnectionStatus.CONNECTED) }, + ) + + val snapshot = source.fetchActiveSessionsSnapshot() + + assertEquals(WsMethods.SESSION_ACTIVE_LIST, capturedMethod) + assertTrue(capturedParams!!.isEmpty()) + assertNotNull(snapshot) + assertEquals(SessionLiveStatus.WORKING, snapshot!!.statusByStoredId["st-1"]) + } + + @Test + fun `HermesSessionLiveStatusSource returns null when RPC throws`() = + runBlocking { + val source = + HermesSessionLiveStatusSource( + rpcRequest = { _, _ -> throw RuntimeException("RPC error") }, + eventsProvider = { MutableSharedFlow() }, + connectionStatusProvider = { MutableStateFlow(ConnectionStatus.CONNECTED) }, + ) + + val snapshot = source.fetchActiveSessionsSnapshot() + assertNull(snapshot) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionLiveStatusReducerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionLiveStatusReducerTest.kt new file mode 100644 index 00000000..3f4528bc --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionLiveStatusReducerTest.kt @@ -0,0 +1,239 @@ +package com.m57.hermescontrol.ui.sessions + +import com.m57.hermescontrol.data.model.LiveSessionSnapshot +import com.m57.hermescontrol.data.model.SessionLiveStatus +import com.m57.hermescontrol.data.ws.WsEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SessionLiveStatusReducerTest { + @Test + fun `snapshot replacement seeds WORKING and WAITING by stored ID`() { + val snapshot = + LiveSessionSnapshot( + statusByStoredId = + mapOf( + "stored-1" to SessionLiveStatus.WORKING, + "stored-2" to SessionLiveStatus.WAITING, + ), + storedIdByRuntimeId = + mapOf( + "rt-1" to "stored-1", + "rt-2" to "stored-2", + ), + ) + + val state = SessionLiveStatusReducer.applySnapshot(SessionLiveTrackingState(), snapshot) + + assertEquals(SessionLiveStatus.WORKING, state.liveStatuses["stored-1"]) + assertEquals(SessionLiveStatus.WAITING, state.liveStatuses["stored-2"]) + assertEquals("stored-1", state.storedIdByRuntimeId["rt-1"]) + assertEquals("stored-2", state.storedIdByRuntimeId["rt-2"]) + } + + @Test + fun `later successful empty snapshot removes previously live rows`() { + val initial = + SessionLiveTrackingState( + liveStatuses = mapOf("stored-1" to SessionLiveStatus.WORKING), + storedIdByRuntimeId = mapOf("rt-1" to "stored-1"), + ) + + val state = + SessionLiveStatusReducer.applySnapshot( + initial, + LiveSessionSnapshot(emptyMap(), emptyMap()), + ) + + assertTrue(state.liveStatuses.isEmpty()) + assertTrue(state.storedIdByRuntimeId.isEmpty()) + } + + @Test + fun `runtime ID reuse moves activity from old stored ID to new stored ID`() { + val initial = + SessionLiveTrackingState( + liveStatuses = mapOf("stored-old" to SessionLiveStatus.WORKING), + storedIdByRuntimeId = mapOf("rt-1" to "stored-old"), + ) + + val event = + WsEvent.SessionInfo( + data = + mapOf( + "session_id" to "rt-1", + "stored_session_id" to "stored-new", + "running" to true, + ), + ) + + val state = SessionLiveStatusReducer.applyWsEvent(initial, event) + + assertNull(state.liveStatuses["stored-old"]) + assertEquals(SessionLiveStatus.WORKING, state.liveStatuses["stored-new"]) + assertEquals("stored-new", state.storedIdByRuntimeId["rt-1"]) + } + + @Test + fun `session info with stored_session_id and running=true marks WORKING`() { + val initial = SessionLiveTrackingState() + + val event = + WsEvent.SessionInfo( + data = + mapOf( + "session_id" to "rt-1", + "stored_session_id" to "stored-1", + "running" to true, + ), + ) + + val state = SessionLiveStatusReducer.applyWsEvent(initial, event) + + assertEquals(SessionLiveStatus.WORKING, state.liveStatuses["stored-1"]) + assertEquals("stored-1", state.storedIdByRuntimeId["rt-1"]) + } + + @Test + fun `session info with running=false clears that stored row`() { + val initial = + SessionLiveTrackingState( + liveStatuses = mapOf("stored-1" to SessionLiveStatus.WORKING), + storedIdByRuntimeId = mapOf("rt-1" to "stored-1"), + ) + + val event = + WsEvent.SessionInfo( + data = + mapOf( + "session_id" to "rt-1", + "stored_session_id" to "stored-1", + "running" to false, + ), + ) + + val state = SessionLiveStatusReducer.applyWsEvent(initial, event) + + assertNull(state.liveStatuses["stored-1"]) + assertEquals("stored-1", state.storedIdByRuntimeId["rt-1"]) + } + + @Test + fun `message start marks WORKING only when runtime-to-stored identity is known`() { + val initial = + SessionLiveTrackingState( + liveStatuses = emptyMap(), + storedIdByRuntimeId = mapOf("rt-known" to "stored-known"), + ) + + // Unknown runtime id -> no mutation + val unknownEvent = WsEvent.MessageStart(sessionId = "rt-unknown") + val state1 = SessionLiveStatusReducer.applyWsEvent(initial, unknownEvent) + assertTrue(state1.liveStatuses.isEmpty()) + + // Known runtime id -> marks WORKING + val knownEvent = WsEvent.MessageStart(sessionId = "rt-known") + val state2 = SessionLiveStatusReducer.applyWsEvent(state1, knownEvent) + assertEquals(SessionLiveStatus.WORKING, state2.liveStatuses["stored-known"]) + } + + @Test + fun `message complete and message done clear only matching runtime`() { + val initial = + SessionLiveTrackingState( + liveStatuses = + mapOf( + "stored-1" to SessionLiveStatus.WORKING, + "stored-2" to SessionLiveStatus.WORKING, + ), + storedIdByRuntimeId = + mapOf( + "rt-1" to "stored-1", + "rt-2" to "stored-2", + ), + ) + + val completeEvent = WsEvent.MessageComplete(text = "done", sessionId = "rt-1") + val state1 = SessionLiveStatusReducer.applyWsEvent(initial, completeEvent) + + assertNull(state1.liveStatuses["stored-1"]) + assertEquals(SessionLiveStatus.WORKING, state1.liveStatuses["stored-2"]) + + val doneEvent = WsEvent.MessageDone(sessionId = "rt-2") + val state2 = SessionLiveStatusReducer.applyWsEvent(state1, doneEvent) + + assertNull(state2.liveStatuses["stored-2"]) + assertTrue(state2.liveStatuses.isEmpty()) + } + + @Test + fun `approval request and clarify request mark WAITING for known runtime`() { + val initial = + SessionLiveTrackingState( + liveStatuses = mapOf("stored-1" to SessionLiveStatus.WORKING), + storedIdByRuntimeId = mapOf("rt-1" to "stored-1"), + ) + + val approvalEvent = + WsEvent.ApprovalRequest( + command = "rm -rf", + description = "delete", + patternKeys = null, + sessionId = "rt-1", + requestId = "req-1", + choices = listOf("allow", "deny"), + allowPermanent = false, + smartDenied = false, + ) + + val state = SessionLiveStatusReducer.applyWsEvent(initial, approvalEvent) + assertEquals(SessionLiveStatus.WAITING, state.liveStatuses["stored-1"]) + + val clarifyEvent = + WsEvent.ClarifyRequest( + text = "which option?", + options = listOf("a", "b"), + sessionId = "rt-1", + ) + + val state2 = SessionLiveStatusReducer.applyWsEvent(initial, clarifyEvent) + assertEquals(SessionLiveStatus.WAITING, state2.liveStatuses["stored-1"]) + } + + @Test + fun `unknown or missing IDs never mutate another session`() { + val initial = + SessionLiveTrackingState( + liveStatuses = mapOf("stored-1" to SessionLiveStatus.WORKING), + storedIdByRuntimeId = mapOf("rt-1" to "stored-1"), + ) + + val state1 = SessionLiveStatusReducer.applyWsEvent(initial, WsEvent.MessageStart(sessionId = null)) + assertEquals(initial, state1) + + val state2 = SessionLiveStatusReducer.applyWsEvent(initial, WsEvent.MessageDone(sessionId = "other")) + assertEquals(initial, state2) + + val state3 = + SessionLiveStatusReducer.applyWsEvent( + initial, + WsEvent.SessionInfo(data = null), + ) + assertEquals(initial, state3) + } + + @Test + fun `clear resets state to empty`() { + val initial = + SessionLiveTrackingState( + liveStatuses = mapOf("stored-1" to SessionLiveStatus.WORKING), + storedIdByRuntimeId = mapOf("rt-1" to "stored-1"), + ) + + val cleared = SessionLiveStatusReducer.clear() + assertTrue(cleared.liveStatuses.isEmpty()) + assertTrue(cleared.storedIdByRuntimeId.isEmpty()) + } +} 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 cc4bcb60..66d7b0c1 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 @@ -1,19 +1,31 @@ package com.m57.hermescontrol.ui.sessions +import com.m57.hermescontrol.data.model.LiveSessionSnapshot import com.m57.hermescontrol.data.model.SessionInfo import com.m57.hermescontrol.data.model.SessionListResponse +import com.m57.hermescontrol.data.model.SessionLiveStatus import com.m57.hermescontrol.data.model.SessionSearchResponse import com.m57.hermescontrol.data.model.SessionSearchResult import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.HermesApiService +import com.m57.hermescontrol.data.ws.ChangeEventHub +import com.m57.hermescontrol.data.ws.ChangeEvents +import com.m57.hermescontrol.data.ws.ConnectionStatus +import com.m57.hermescontrol.data.ws.SessionLiveStatusSource +import com.m57.hermescontrol.data.ws.WsEvent import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject import io.mockk.unmockkAll +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle @@ -24,6 +36,7 @@ import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -34,8 +47,25 @@ class SessionsViewModelTest { private val testDispatcher = StandardTestDispatcher() private val mockApi = mockk(relaxed = true) - private fun createViewModel(): SessionsViewModel { - val vm = SessionsViewModel() + private class FakeSessionLiveStatusSource : SessionLiveStatusSource { + val eventsFlow = MutableSharedFlow(extraBufferCapacity = 16) + val connectionStatusFlow = MutableStateFlow(ConnectionStatus.CONNECTED) + var snapshotToReturn: LiveSessionSnapshot? = null + var fetchCallCount = 0 + var snapshotDeferred: CompletableDeferred? = null + + override suspend fun fetchActiveSessionsSnapshot(): LiveSessionSnapshot? { + fetchCallCount++ + val deferred = snapshotDeferred + return if (deferred != null) deferred.await() else snapshotToReturn + } + + override val events: Flow = eventsFlow + override val connectionStatus: StateFlow = connectionStatusFlow + } + + private fun createViewModel(source: SessionLiveStatusSource = FakeSessionLiveStatusSource()): SessionsViewModel { + val vm = SessionsViewModel(liveStatusSource = source) testDispatcher.scheduler.advanceUntilIdle() return vm } @@ -548,4 +578,219 @@ class SessionsViewModelTest { vm.togglePinnedExpanded() assertTrue(vm.uiState.value.pinnedExpanded) } + + // ── Live Session Status Tracking (issue #1101) ─────────────────────── + + @Test + fun `initial snapshot hydration updates liveStatuses`() { + val source = FakeSessionLiveStatusSource() + source.snapshotToReturn = + LiveSessionSnapshot( + statusByStoredId = mapOf("stored-1" to SessionLiveStatus.WORKING), + storedIdByRuntimeId = mapOf("rt-1" to "stored-1"), + ) + + val vm = createViewModel(source) + vm.startLiveStatusTracking() + testDispatcher.scheduler.runCurrent() + + assertEquals(SessionLiveStatus.WORKING, vm.uiState.value.liveStatuses["stored-1"]) + assertEquals(1, source.fetchCallCount) + vm.stopLiveStatusTracking() + } + + @Test + fun `successful absence reaping clears statuses not in snapshot`() { + val source = FakeSessionLiveStatusSource() + source.snapshotToReturn = + LiveSessionSnapshot( + statusByStoredId = mapOf("stored-1" to SessionLiveStatus.WORKING), + storedIdByRuntimeId = mapOf("rt-1" to "stored-1"), + ) + + val vm = createViewModel(source) + vm.startLiveStatusTracking() + testDispatcher.scheduler.runCurrent() + assertEquals(SessionLiveStatus.WORKING, vm.uiState.value.liveStatuses["stored-1"]) + + // Second snapshot returns empty + source.snapshotToReturn = LiveSessionSnapshot(emptyMap(), emptyMap()) + vm.refreshLiveStatuses() + testDispatcher.scheduler.runCurrent() + + assertTrue( + vm.uiState.value.liveStatuses + .isEmpty(), + ) + assertEquals(2, source.fetchCallCount) + vm.stopLiveStatusTracking() + } + + @Test + fun `working to idle event transition via SessionInfo updates liveStatuses`() { + val source = FakeSessionLiveStatusSource() + source.snapshotToReturn = + LiveSessionSnapshot( + statusByStoredId = mapOf("stored-1" to SessionLiveStatus.WORKING), + storedIdByRuntimeId = mapOf("rt-1" to "stored-1"), + ) + + val vm = createViewModel(source) + vm.startLiveStatusTracking() + testDispatcher.scheduler.runCurrent() + + assertEquals(SessionLiveStatus.WORKING, vm.uiState.value.liveStatuses["stored-1"]) + + // Send running = false + source.eventsFlow.tryEmit( + WsEvent.SessionInfo( + data = + mapOf( + "session_id" to "rt-1", + "stored_session_id" to "stored-1", + "running" to false, + ), + ), + ) + testDispatcher.scheduler.runCurrent() + + assertNull(vm.uiState.value.liveStatuses["stored-1"]) + vm.stopLiveStatusTracking() + } + + @Test + fun `disconnect clears immediately and reconnect rehydrates`() { + val source = FakeSessionLiveStatusSource() + source.snapshotToReturn = + LiveSessionSnapshot( + statusByStoredId = mapOf("stored-1" to SessionLiveStatus.WORKING), + storedIdByRuntimeId = mapOf("rt-1" to "stored-1"), + ) + + val vm = createViewModel(source) + vm.startLiveStatusTracking() + testDispatcher.scheduler.runCurrent() + assertEquals(SessionLiveStatus.WORKING, vm.uiState.value.liveStatuses["stored-1"]) + + // Disconnect + source.connectionStatusFlow.value = ConnectionStatus.DISCONNECTED + testDispatcher.scheduler.runCurrent() + assertTrue( + vm.uiState.value.liveStatuses + .isEmpty(), + ) + + // Reconnect + source.snapshotToReturn = + LiveSessionSnapshot( + statusByStoredId = mapOf("stored-1" to SessionLiveStatus.WAITING), + storedIdByRuntimeId = mapOf("rt-1" to "stored-1"), + ) + source.connectionStatusFlow.value = ConnectionStatus.CONNECTED + testDispatcher.scheduler.runCurrent() + + assertEquals(SessionLiveStatus.WAITING, vm.uiState.value.liveStatuses["stored-1"]) + vm.stopLiveStatusTracking() + } + + @Test + fun `late pre-disconnect response is ignored`() { + val source = FakeSessionLiveStatusSource() + val deferred = CompletableDeferred() + source.snapshotDeferred = deferred + + val vm = createViewModel(source) + vm.startLiveStatusTracking() + testDispatcher.scheduler.runCurrent() + + // Disconnect before deferred completes + source.connectionStatusFlow.value = ConnectionStatus.DISCONNECTED + testDispatcher.scheduler.runCurrent() + + // Complete the late response + deferred.complete( + LiveSessionSnapshot( + statusByStoredId = mapOf("stored-1" to SessionLiveStatus.WORKING), + storedIdByRuntimeId = mapOf("rt-1" to "stored-1"), + ), + ) + testDispatcher.scheduler.runCurrent() + + // Must remain empty because the response was from the previous generation + assertTrue( + vm.uiState.value.liveStatuses + .isEmpty(), + ) + vm.stopLiveStatusTracking() + } + + @Test + fun `repeated start is idempotent`() { + val source = FakeSessionLiveStatusSource() + source.snapshotToReturn = LiveSessionSnapshot() + + val vm = createViewModel(source) + vm.startLiveStatusTracking() + vm.startLiveStatusTracking() + testDispatcher.scheduler.runCurrent() + + assertEquals(1, source.fetchCallCount) + vm.stopLiveStatusTracking() + } + + @Test + fun `stop cancels ticker and clears state`() { + val source = FakeSessionLiveStatusSource() + source.snapshotToReturn = + LiveSessionSnapshot( + statusByStoredId = mapOf("stored-1" to SessionLiveStatus.WORKING), + storedIdByRuntimeId = mapOf("rt-1" to "stored-1"), + ) + + val vm = createViewModel(source) + vm.startLiveStatusTracking() + testDispatcher.scheduler.runCurrent() + assertEquals(1, vm.uiState.value.liveStatuses.size) + + vm.stopLiveStatusTracking() + testDispatcher.scheduler.runCurrent() + assertTrue( + vm.uiState.value.liveStatuses + .isEmpty(), + ) + + // Advancing time past the 30s poll interval should NOT trigger another fetch + testDispatcher.scheduler.advanceTimeBy(60_000) + testDispatcher.scheduler.runCurrent() + assertEquals(1, source.fetchCallCount) + } + + @Test + fun `change bursts coalesce into at most one trailing request`() { + val source = FakeSessionLiveStatusSource() + val deferred1 = CompletableDeferred() + source.snapshotDeferred = deferred1 + + val vm = createViewModel(source) + vm.startLiveStatusTracking() + testDispatcher.scheduler.runCurrent() + assertEquals(1, source.fetchCallCount) + + // Multiple change events while request 1 is in flight + source.snapshotDeferred = CompletableDeferred() + ChangeEventHub.emit(WsEvent.ChangeEvent(ChangeEvents.SESSIONS, null)) + ChangeEventHub.emit(WsEvent.ChangeEvent(ChangeEvents.SESSIONS, null)) + testDispatcher.scheduler.runCurrent() + + // Call count should still be 1 (in-flight) + assertEquals(1, source.fetchCallCount) + + // Complete request 1 + deferred1.complete(LiveSessionSnapshot()) + testDispatcher.scheduler.runCurrent() + + // Request 2 was coalesced and launched as the trailing request + assertEquals(2, source.fetchCallCount) + vm.stopLiveStatusTracking() + } }