feat(sessions): show live status for active sessions in History (#1101) (#1104)

This commit is contained in:
M57 authored and GitHub committed 2026-09-13 14:45:33 +03:00
1 parent 52e0104cd2
commit 7e8af8db01
16 files changed
+1485 -12

No files matched your search

@@ -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()
}
}
@@ -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<ActiveSessionItem> = 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<String, SessionLiveStatus> = emptyMap(),
val storedIdByRuntimeId: Map<String, String> = emptyMap(),
)
@@ -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<WsEvent>
val connectionStatus: StateFlow<ConnectionStatus>
}
/**
* 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<String, SessionLiveStatus>()
val storedIdByRuntimeId = mutableMapOf<String, String>()
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<ActiveSessionsResponse>(), element)
}
@Suppress("UNCHECKED_CAST")
internal fun anyToJsonElement(value: Any?): JsonElement =
when (value) {
null -> {
JsonNull
}
is JsonElement -> {
value
}
is Map<*, *> -> {
JsonObject(
(value as Map<String, Any?>).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<String, Any>) -> Any? = { method, params ->
HermesWsClient.request(method, params).await()
},
eventsProvider: () -> Flow<WsEvent> = { HermesWsClient.events },
connectionStatusProvider: () -> StateFlow<ConnectionStatus> = { HermesWsClient.connectionStatus },
) : SessionLiveStatusSource {
override val events: Flow<WsEvent> by lazy { eventsProvider() }
override val connectionStatus: StateFlow<ConnectionStatus> 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
}
}
@@ -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<String, SessionLiveStatus> = emptyMap(),
val storedIdByRuntimeId: Map<String, String> = 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),
)
}
}
@@ -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 = {
@@ -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<String, SessionLiveStatus> = 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<SessionsUiState> = _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<String, SessionInfo>()
@@ -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()
}
}
@@ -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),
@@ -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,
)
}
}
@@ -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<A, B, C, D>(
val first: A,
val second: B,
val third: C,
val fourth: D,
)
+4
View File
@@ -543,6 +543,10 @@
<string name="sessions_all_hidden_desc">جميع الجلسات مخفية حاليًا. اضغط أدناه لإظهار الجلسات المخفية.</string>
<string name="sessions_pinned_indicator">مثبتة</string>
<string name="sessions_fork_indicator">جلسة مفرعة</string>
<string name="sessions_live_status_running">قيد التشغيل</string>
<string name="sessions_live_status_waiting">بانتظار إدخال</string>
<string name="sessions_live_status_running_desc">الجلسة قيد التشغيل</string>
<string name="sessions_live_status_waiting_desc">الجلسة بانتظار إدخال من المستخدم</string>
<string name="sessions_rename_title">إعادة تسمية الجلسة</string>
<string name="sessions_rename_hint">عنوان الجلسة</string>
<string name="toolsets_empty_title">لم يتم العثور على مجموعات أدوات</string>
+4
View File
@@ -423,6 +423,10 @@
<string name="sessions_hide_hidden">숨겨진 세션 숨기기</string>
<string name="sessions_hidden_badge">숨김</string>
<string name="sessions_all_hidden_desc">모든 세션이 현재 숨겨져 있습니다. 아래를 탭하여 숨겨진 세션을 표시하세요.</string>
<string name="sessions_live_status_running">실행 중</string>
<string name="sessions_live_status_waiting">입력 대기 중</string>
<string name="sessions_live_status_running_desc">세션이 실행 중입니다</string>
<string name="sessions_live_status_waiting_desc">세션이 사용자 입력을 대기하고 있습니다</string>
<!-- Toolsets Screen -->
<string name="toolsets_empty_title">툴셋이 없습니다</string>
+4
View File
@@ -534,6 +534,10 @@
<string name="sessions_all_hidden_desc">所有会话当前均已隐藏。点击下方以显示已隐藏的会话。</string>
<string name="sessions_pinned_indicator">已置顶</string>
<string name="sessions_fork_indicator">分支会话</string>
<string name="sessions_live_status_running">运行中</string>
<string name="sessions_live_status_waiting">等待输入</string>
<string name="sessions_live_status_running_desc">会话正在运行中</string>
<string name="sessions_live_status_waiting_desc">会话等待用户输入</string>
<string name="sessions_rename_title">重命名会话</string>
<string name="sessions_rename_hint">会话标题</string>
<string name="toolsets_empty_title">没有工具集数据</string>
+4
View File
@@ -619,6 +619,10 @@
<string name="sessions_all_hidden_desc">All sessions are currently hidden. Tap below to show hidden sessions.</string>
<string name="sessions_pinned_indicator">Pinned</string>
<string name="sessions_fork_indicator">Forked session</string>
<string name="sessions_live_status_running">Running</string>
<string name="sessions_live_status_waiting">Needs input</string>
<string name="sessions_live_status_running_desc">Session is running</string>
<string name="sessions_live_status_waiting_desc">Session needs user input</string>
<string name="sessions_rename_title">Rename Session</string>
<string name="sessions_rename_hint">Session title</string>
@@ -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<Map<String, Any>>())
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<String, Any>? = 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)
}
}
@@ -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())
}
}
@@ -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<HermesApiService>(relaxed = true)
private fun createViewModel(): SessionsViewModel {
val vm = SessionsViewModel()
private class FakeSessionLiveStatusSource : SessionLiveStatusSource {
val eventsFlow = MutableSharedFlow<WsEvent>(extraBufferCapacity = 16)
val connectionStatusFlow = MutableStateFlow(ConnectionStatus.CONNECTED)
var snapshotToReturn: LiveSessionSnapshot? = null
var fetchCallCount = 0
var snapshotDeferred: CompletableDeferred<LiveSessionSnapshot?>? = null
override suspend fun fetchActiveSessionsSnapshot(): LiveSessionSnapshot? {
fetchCallCount++
val deferred = snapshotDeferred
return if (deferred != null) deferred.await() else snapshotToReturn
}
override val events: Flow<WsEvent> = eventsFlow
override val connectionStatus: StateFlow<ConnectionStatus> = 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<LiveSessionSnapshot?>()
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<LiveSessionSnapshot?>()
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()
}
}