This commit is contained in:
+180
@@ -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.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -274,6 +275,13 @@ fun SessionsScreen(
|
|||||||
viewModel.loadSessions()
|
viewModel.loadSessions()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
viewModel.startLiveStatusTracking()
|
||||||
|
onDispose {
|
||||||
|
viewModel.stopLiveStatusTracking()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Toast effect
|
// Toast effect
|
||||||
ToastEffect(
|
ToastEffect(
|
||||||
toastMessage = state.toastMessage,
|
toastMessage = state.toastMessage,
|
||||||
@@ -612,6 +620,7 @@ fun SessionsScreen(
|
|||||||
isSelecting = state.isSelecting,
|
isSelecting = state.isSelecting,
|
||||||
isSelected = session.id in state.selectedIds,
|
isSelected = session.id in state.selectedIds,
|
||||||
isDeleting = session.id in state.deletingSessionIds,
|
isDeleting = session.id in state.deletingSessionIds,
|
||||||
|
liveStatus = state.liveStatuses[session.id],
|
||||||
highlightBackground = primaryContainer,
|
highlightBackground = primaryContainer,
|
||||||
highlightForeground = onPrimaryContainer,
|
highlightForeground = onPrimaryContainer,
|
||||||
onCardClick = {
|
onCardClick = {
|
||||||
@@ -760,6 +769,7 @@ fun SessionsScreen(
|
|||||||
isDeleting = session.id in state.deletingSessionIds,
|
isDeleting = session.id in state.deletingSessionIds,
|
||||||
isPinned = session.pinned == true,
|
isPinned = session.pinned == true,
|
||||||
isHidden = session.hidden == true,
|
isHidden = session.hidden == true,
|
||||||
|
liveStatus = state.liveStatuses[session.id],
|
||||||
highlightBackground = primaryContainer,
|
highlightBackground = primaryContainer,
|
||||||
highlightForeground = onPrimaryContainer,
|
highlightForeground = onPrimaryContainer,
|
||||||
onCardClick = {
|
onCardClick = {
|
||||||
|
|||||||
@@ -5,12 +5,18 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.m57.hermescontrol.data.model.BulkDeleteRequest
|
import com.m57.hermescontrol.data.model.BulkDeleteRequest
|
||||||
import com.m57.hermescontrol.data.model.PruneRequest
|
import com.m57.hermescontrol.data.model.PruneRequest
|
||||||
import com.m57.hermescontrol.data.model.SessionInfo
|
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.SessionRenameRequest
|
||||||
import com.m57.hermescontrol.data.model.SessionSearchResult
|
import com.m57.hermescontrol.data.model.SessionSearchResult
|
||||||
import com.m57.hermescontrol.data.remote.ApiClient
|
import com.m57.hermescontrol.data.remote.ApiClient
|
||||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
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.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.ToastHost
|
||||||
import com.m57.hermescontrol.ui.common.refreshOnChange
|
import com.m57.hermescontrol.ui.common.refreshOnChange
|
||||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||||
@@ -19,6 +25,7 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -90,6 +97,7 @@ data class SessionsUiState(
|
|||||||
val searchError: String? = null,
|
val searchError: String? = null,
|
||||||
val showHidden: Boolean = false,
|
val showHidden: Boolean = false,
|
||||||
val pinnedExpanded: Boolean = true,
|
val pinnedExpanded: Boolean = true,
|
||||||
|
val liveStatuses: Map<String, SessionLiveStatus> = emptyMap(),
|
||||||
) {
|
) {
|
||||||
val isSearchMode: Boolean get() = searchQuery.isNotBlank()
|
val isSearchMode: Boolean get() = searchQuery.isNotBlank()
|
||||||
|
|
||||||
@@ -103,8 +111,9 @@ data class SessionsUiState(
|
|||||||
get() = displaySessions.filter { it.pinned == true }
|
get() = displaySessions.filter { it.pinned == true }
|
||||||
}
|
}
|
||||||
|
|
||||||
class SessionsViewModel :
|
class SessionsViewModel(
|
||||||
ViewModel(),
|
private val liveStatusSource: SessionLiveStatusSource = HermesSessionLiveStatusSource(),
|
||||||
|
) : ViewModel(),
|
||||||
ToastHost {
|
ToastHost {
|
||||||
private val _uiState = MutableStateFlow(SessionsUiState())
|
private val _uiState = MutableStateFlow(SessionsUiState())
|
||||||
val uiState: StateFlow<SessionsUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<SessionsUiState> = _uiState.asStateFlow()
|
||||||
@@ -112,6 +121,11 @@ class SessionsViewModel :
|
|||||||
private var loadJob: Job? = null
|
private var loadJob: Job? = null
|
||||||
private var pageJob: Job? = null
|
private var pageJob: Job? = null
|
||||||
private var statsJob: 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 generation: Long = 0
|
||||||
private var rawPaginationOffset: Int = 0
|
private var rawPaginationOffset: Int = 0
|
||||||
|
|
||||||
@@ -172,6 +186,7 @@ class SessionsViewModel :
|
|||||||
const val SEARCH_DEBOUNCE_MS = 300L
|
const val SEARCH_DEBOUNCE_MS = 300L
|
||||||
const val MAX_STITCH_PER_PAGE = 8
|
const val MAX_STITCH_PER_PAGE = 8
|
||||||
const val MAX_STITCH_ROUNDS = 3
|
const val MAX_STITCH_ROUNDS = 3
|
||||||
|
const val LIVE_STATUS_POLL_INTERVAL_MS = 30_000L
|
||||||
}
|
}
|
||||||
|
|
||||||
private val resolvedParentCache = mutableMapOf<String, SessionInfo>()
|
private val resolvedParentCache = mutableMapOf<String, SessionInfo>()
|
||||||
@@ -259,6 +274,9 @@ class SessionsViewModel :
|
|||||||
val section = _uiState.value.section
|
val section = _uiState.value.section
|
||||||
pageJob?.cancel()
|
pageJob?.cancel()
|
||||||
loadEmptyCount()
|
loadEmptyCount()
|
||||||
|
if (trackingJob?.isActive == true) {
|
||||||
|
refreshLiveStatuses()
|
||||||
|
}
|
||||||
loadJob =
|
loadJob =
|
||||||
safeLaunchLoad(
|
safeLaunchLoad(
|
||||||
currentJob = loadJob,
|
currentJob = loadJob,
|
||||||
@@ -863,4 +881,106 @@ class SessionsViewModel :
|
|||||||
override fun clearToast() {
|
override fun clearToast() {
|
||||||
_uiState.update { it.copy(toastMessage = null) }
|
_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 androidx.compose.ui.unit.sp
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.data.model.SessionInfo
|
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.theme.LocalSpacing
|
||||||
import com.m57.hermescontrol.ui.common.StatusBadge
|
import com.m57.hermescontrol.ui.common.StatusBadge
|
||||||
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
||||||
@@ -56,6 +58,7 @@ fun SearchResultCard(
|
|||||||
isSelecting: Boolean,
|
isSelecting: Boolean,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
isDeleting: Boolean,
|
isDeleting: Boolean,
|
||||||
|
liveStatus: SessionLiveStatus? = null,
|
||||||
highlightBackground: Color,
|
highlightBackground: Color,
|
||||||
highlightForeground: Color,
|
highlightForeground: Color,
|
||||||
onCardClick: () -> Unit,
|
onCardClick: () -> Unit,
|
||||||
@@ -65,6 +68,7 @@ fun SearchResultCard(
|
|||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val spacing = LocalSpacing.current
|
val spacing = LocalSpacing.current
|
||||||
|
val statusColors = LocalHermesStatusColors.current
|
||||||
val snippet = session.preview?.takeIf { it.isNotBlank() } ?: stringResource(R.string.history_untitled)
|
val snippet = session.preview?.takeIf { it.isNotBlank() } ?: stringResource(R.string.history_untitled)
|
||||||
val cleanSnippet = cleanSearchSnippet(snippet)
|
val cleanSnippet = cleanSearchSnippet(snippet)
|
||||||
val playedAt = formatPlayedAt(session.started_at)
|
val playedAt = formatPlayedAt(session.started_at)
|
||||||
@@ -86,6 +90,10 @@ fun SearchResultCard(
|
|||||||
border =
|
border =
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
|
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 {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
@@ -153,6 +161,12 @@ fun SearchResultCard(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
|
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||||
verticalArrangement = Arrangement.spacedBy(spacing.xs),
|
verticalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||||
) {
|
) {
|
||||||
|
if (liveStatus != null) {
|
||||||
|
SessionLiveStatusIndicator(
|
||||||
|
liveStatus = liveStatus,
|
||||||
|
sessionId = session.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
session.source?.let { src ->
|
session.source?.let { src ->
|
||||||
StatusBadge(
|
StatusBadge(
|
||||||
text = sourceLabel(src),
|
text = sourceLabel(src),
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import androidx.compose.ui.text.withStyle
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.data.model.SessionInfo
|
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.data.model.SessionTreeItem
|
||||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
import com.m57.hermescontrol.theme.LocalSpacing
|
import com.m57.hermescontrol.theme.LocalSpacing
|
||||||
@@ -146,6 +147,7 @@ fun SessionCard(
|
|||||||
isDeleting: Boolean,
|
isDeleting: Boolean,
|
||||||
isPinned: Boolean,
|
isPinned: Boolean,
|
||||||
isHidden: Boolean = false,
|
isHidden: Boolean = false,
|
||||||
|
liveStatus: SessionLiveStatus? = null,
|
||||||
highlightBackground: Color,
|
highlightBackground: Color,
|
||||||
highlightForeground: Color,
|
highlightForeground: Color,
|
||||||
onCardClick: () -> Unit,
|
onCardClick: () -> Unit,
|
||||||
@@ -158,7 +160,6 @@ fun SessionCard(
|
|||||||
) {
|
) {
|
||||||
val spacing = LocalSpacing.current
|
val spacing = LocalSpacing.current
|
||||||
val statusColors = LocalHermesStatusColors.current
|
val statusColors = LocalHermesStatusColors.current
|
||||||
val isActive = session.status?.lowercase() == "active" || session.status?.lowercase() == "streaming"
|
|
||||||
val srcIcon = sourceIcon(session.source)
|
val srcIcon = sourceIcon(session.source)
|
||||||
var menuExpanded by remember { mutableStateOf(false) }
|
var menuExpanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -176,10 +177,12 @@ fun SessionCard(
|
|||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
),
|
),
|
||||||
border =
|
border =
|
||||||
if (isActive && !isSelecting) {
|
if (isSelected) {
|
||||||
BorderStroke(2.dp, statusColors.success)
|
|
||||||
} else if (isSelected) {
|
|
||||||
BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
|
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 {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
@@ -270,11 +273,11 @@ fun SessionCard(
|
|||||||
status = StatusBadgeType.NEUTRAL,
|
status = StatusBadgeType.NEUTRAL,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!session.status.isNullOrBlank()) {
|
if (liveStatus != null) {
|
||||||
Spacer(modifier = Modifier.width(spacing.sm))
|
Spacer(modifier = Modifier.width(spacing.sm))
|
||||||
StatusBadge(
|
SessionLiveStatusIndicator(
|
||||||
text = session.status,
|
liveStatus = liveStatus,
|
||||||
status = if (isActive) StatusBadgeType.SUCCESS else StatusBadgeType.NEUTRAL,
|
sessionId = session.id,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+112
@@ -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,
|
||||||
|
)
|
||||||
@@ -543,6 +543,10 @@
|
|||||||
<string name="sessions_all_hidden_desc">جميع الجلسات مخفية حاليًا. اضغط أدناه لإظهار الجلسات المخفية.</string>
|
<string name="sessions_all_hidden_desc">جميع الجلسات مخفية حاليًا. اضغط أدناه لإظهار الجلسات المخفية.</string>
|
||||||
<string name="sessions_pinned_indicator">مثبتة</string>
|
<string name="sessions_pinned_indicator">مثبتة</string>
|
||||||
<string name="sessions_fork_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_title">إعادة تسمية الجلسة</string>
|
||||||
<string name="sessions_rename_hint">عنوان الجلسة</string>
|
<string name="sessions_rename_hint">عنوان الجلسة</string>
|
||||||
<string name="toolsets_empty_title">لم يتم العثور على مجموعات أدوات</string>
|
<string name="toolsets_empty_title">لم يتم العثور على مجموعات أدوات</string>
|
||||||
|
|||||||
@@ -423,6 +423,10 @@
|
|||||||
<string name="sessions_hide_hidden">숨겨진 세션 숨기기</string>
|
<string name="sessions_hide_hidden">숨겨진 세션 숨기기</string>
|
||||||
<string name="sessions_hidden_badge">숨김</string>
|
<string name="sessions_hidden_badge">숨김</string>
|
||||||
<string name="sessions_all_hidden_desc">모든 세션이 현재 숨겨져 있습니다. 아래를 탭하여 숨겨진 세션을 표시하세요.</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 -->
|
<!-- Toolsets Screen -->
|
||||||
<string name="toolsets_empty_title">툴셋이 없습니다</string>
|
<string name="toolsets_empty_title">툴셋이 없습니다</string>
|
||||||
|
|||||||
@@ -534,6 +534,10 @@
|
|||||||
<string name="sessions_all_hidden_desc">所有会话当前均已隐藏。点击下方以显示已隐藏的会话。</string>
|
<string name="sessions_all_hidden_desc">所有会话当前均已隐藏。点击下方以显示已隐藏的会话。</string>
|
||||||
<string name="sessions_pinned_indicator">已置顶</string>
|
<string name="sessions_pinned_indicator">已置顶</string>
|
||||||
<string name="sessions_fork_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_title">重命名会话</string>
|
||||||
<string name="sessions_rename_hint">会话标题</string>
|
<string name="sessions_rename_hint">会话标题</string>
|
||||||
<string name="toolsets_empty_title">没有工具集数据</string>
|
<string name="toolsets_empty_title">没有工具集数据</string>
|
||||||
|
|||||||
@@ -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_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_pinned_indicator">Pinned</string>
|
||||||
<string name="sessions_fork_indicator">Forked session</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_title">Rename Session</string>
|
||||||
<string name="sessions_rename_hint">Session title</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
|
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.SessionInfo
|
||||||
import com.m57.hermescontrol.data.model.SessionListResponse
|
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.SessionSearchResponse
|
||||||
import com.m57.hermescontrol.data.model.SessionSearchResult
|
import com.m57.hermescontrol.data.model.SessionSearchResult
|
||||||
import com.m57.hermescontrol.data.remote.ApiClient
|
import com.m57.hermescontrol.data.remote.ApiClient
|
||||||
import com.m57.hermescontrol.data.remote.HermesApiService
|
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.coEvery
|
||||||
import io.mockk.coVerify
|
import io.mockk.coVerify
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import io.mockk.mockkObject
|
import io.mockk.mockkObject
|
||||||
import io.mockk.unmockkAll
|
import io.mockk.unmockkAll
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
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.StandardTestDispatcher
|
||||||
import kotlinx.coroutines.test.advanceTimeBy
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
@@ -24,6 +36,7 @@ import org.junit.After
|
|||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNotNull
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -34,8 +47,25 @@ class SessionsViewModelTest {
|
|||||||
private val testDispatcher = StandardTestDispatcher()
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
private val mockApi = mockk<HermesApiService>(relaxed = true)
|
private val mockApi = mockk<HermesApiService>(relaxed = true)
|
||||||
|
|
||||||
private fun createViewModel(): SessionsViewModel {
|
private class FakeSessionLiveStatusSource : SessionLiveStatusSource {
|
||||||
val vm = SessionsViewModel()
|
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()
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
return vm
|
return vm
|
||||||
}
|
}
|
||||||
@@ -548,4 +578,219 @@ class SessionsViewModelTest {
|
|||||||
vm.togglePinnedExpanded()
|
vm.togglePinnedExpanded()
|
||||||
assertTrue(vm.uiState.value.pinnedExpanded)
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user