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,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,
)