From 0038606c05f46fd35ac1cfbd6137a9fca5f81720 Mon Sep 17 00:00:00 2001 From: M57 Date: Fri, 19 Jun 2026 12:07:44 +0300 Subject: [PATCH 1/3] feat: add connection status indicator (Issue #28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a colored dot next to the '⚡ Hermes' brand header in the navigation drawer: - Green: connected - Yellow: connecting / reconnecting - Red: disconnected Uses a StateFlow on HermesWsClient that tracks the WebSocket connection lifecycle and is collected in the drawer header. --- .../java/com/m57/hermescontrol/Navigation.kt | 47 +++++++++++++++---- .../hermescontrol/data/ws/HermesWsClient.kt | 28 +++++++++-- 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/Navigation.kt b/app/src/main/java/com/m57/hermescontrol/Navigation.kt index f727abcb..71d16e2d 100644 --- a/app/src/main/java/com/m57/hermescontrol/Navigation.kt +++ b/app/src/main/java/com/m57/hermescontrol/Navigation.kt @@ -1,5 +1,8 @@ package com.m57.hermescontrol +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -7,7 +10,10 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Chat @@ -39,12 +45,15 @@ import androidx.compose.material3.NavigationDrawerItemDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -53,6 +62,8 @@ import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.ui.NavDisplay import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.ws.ConnectionStatus +import com.m57.hermescontrol.data.ws.HermesWsClient import kotlinx.coroutines.launch import com.m57.hermescontrol.ui.achievements.AchievementsScreen as AchievementsScreenContent import com.m57.hermescontrol.ui.channels.ChannelsScreen as ChannelsScreenContent @@ -188,16 +199,34 @@ fun MainNavigation() { ModalDrawerSheet( modifier = Modifier.verticalScroll(rememberScrollState()), ) { - // Brand header - Text( - text = "⚡ Hermes", + // Brand header with connection status + val connectionStatus by HermesWsClient.connectionStatus.collectAsState() + val statusColor = when (connectionStatus) { + ConnectionStatus.CONNECTED -> Color(0xFF4CAF50) // green + ConnectionStatus.CONNECTING, + ConnectionStatus.RECONNECTING -> Color(0xFFFFC107) // yellow + ConnectionStatus.DISCONNECTED -> Color(0xFFF44336) // red + } + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 20.dp, top = 20.dp, end = 16.dp, bottom = 4.dp), - style = - MaterialTheme.typography.headlineSmall.copy( - fontWeight = FontWeight.Bold, - ), - color = MaterialTheme.colorScheme.primary, - ) + ) { + Text( + text = "⚡ Hermes", + style = + MaterialTheme.typography.headlineSmall.copy( + fontWeight = FontWeight.Bold, + ), + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Box( + modifier = + Modifier + .size(10.dp) + .background(color = statusColor, shape = CircleShape), + ) + } Text( text = "AI Agent Control", modifier = Modifier.padding(start = 20.dp, bottom = 12.dp, end = 16.dp), diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt index 650c2e47..f178fa67 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt @@ -10,8 +10,11 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import okhttp3.OkHttpClient import okhttp3.Request @@ -22,6 +25,16 @@ import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger +/** + * Connection status for the WebSocket client. + */ +enum class ConnectionStatus { + DISCONNECTED, + CONNECTING, + CONNECTED, + RECONNECTING, +} + /** * WebSocket client for the Hermes Dashboard JSON-RPC 2.0 interface. * @@ -74,6 +87,12 @@ object HermesWsClient { /** Collect this from ViewModels to receive all parsed [WsEvent]s. */ val events: SharedFlow = _events.asSharedFlow() + // ── Connection status flow ────────────────────────────────────────── + private val _connectionStatus = MutableStateFlow(ConnectionStatus.DISCONNECTED) + + /** Observable connection status: DISCONNECTED / CONNECTING / CONNECTED / RECONNECTING */ + val connectionStatus: StateFlow = _connectionStatus.asStateFlow() + // ── Callbacks (legacy / convenience) ───────────────────────────────── var onMessage: ((WsEvent) -> Unit)? = null @@ -93,20 +112,19 @@ object HermesWsClient { } intentionalClose.set(false) currentBackoff = INITIAL_BACKOFF_MS + _connectionStatus.value = ConnectionStatus.CONNECTING openSocket() } /** Cleanly close the WebSocket and stop auto-reconnect. */ fun disconnect() { intentionalClose.set(true) - // B4 (Jun 18 2026, kanban t_2b834d90): also cancel any pending - // reconnect coroutine so a scheduled openSocket() can't fire after - // the user explicitly disconnected. reconnectJob?.cancel() reconnectJob = null webSocket?.close(1000, "Client closed") webSocket = null connected.set(false) + _connectionStatus.value = ConnectionStatus.DISCONNECTED } // ── Send helpers ───────────────────────────────────────────────────── @@ -156,6 +174,7 @@ object HermesWsClient { if (intentionalClose.get()) return if (!AuthManager.isAutoReconnect()) { if (BuildConfig.DEBUG) Log.d(TAG, "Auto-reconnect disabled") + _connectionStatus.value = ConnectionStatus.DISCONNECTED return } val delay = currentBackoff @@ -205,6 +224,7 @@ object HermesWsClient { ) { Log.i(TAG, "WebSocket opened") connected.set(true) + _connectionStatus.value = ConnectionStatus.CONNECTED currentBackoff = INITIAL_BACKOFF_MS onConnected?.invoke() } @@ -244,6 +264,7 @@ object HermesWsClient { Log.i(TAG, "WebSocket closed: $code $reason") connected.set(false) onDisconnected?.invoke(reason) + _connectionStatus.value = ConnectionStatus.RECONNECTING scheduleReconnect() } @@ -256,6 +277,7 @@ object HermesWsClient { connected.set(false) onError?.invoke(t) onDisconnected?.invoke(t.message) + _connectionStatus.value = ConnectionStatus.RECONNECTING scheduleReconnect() } } From a2c9bb638a7ad5a7f887d0e02bb7793ef5d23980 Mon Sep 17 00:00:00 2001 From: ronia Date: Fri, 19 Jun 2026 12:19:29 +0300 Subject: [PATCH 2/3] style: fix ktlint errors in Navigation.kt --- .../java/com/m57/hermescontrol/Navigation.kt | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/Navigation.kt b/app/src/main/java/com/m57/hermescontrol/Navigation.kt index 71d16e2d..dec259ba 100644 --- a/app/src/main/java/com/m57/hermescontrol/Navigation.kt +++ b/app/src/main/java/com/m57/hermescontrol/Navigation.kt @@ -45,8 +45,8 @@ import androidx.compose.material3.NavigationDrawerItemDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.rememberDrawerState -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -201,12 +201,14 @@ fun MainNavigation() { ) { // Brand header with connection status val connectionStatus by HermesWsClient.connectionStatus.collectAsState() - val statusColor = when (connectionStatus) { - ConnectionStatus.CONNECTED -> Color(0xFF4CAF50) // green - ConnectionStatus.CONNECTING, - ConnectionStatus.RECONNECTING -> Color(0xFFFFC107) // yellow - ConnectionStatus.DISCONNECTED -> Color(0xFFF44336) // red - } + val statusColor = + when (connectionStatus) { + ConnectionStatus.CONNECTED -> Color(0xFF4CAF50) // green + ConnectionStatus.CONNECTING, + ConnectionStatus.RECONNECTING, + -> Color(0xFFFFC107) // yellow + ConnectionStatus.DISCONNECTED -> Color(0xFFF44336) // red + } Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 20.dp, top = 20.dp, end = 16.dp, bottom = 4.dp), From 71d3b975af9417f2fd80a40bfad723b44a5ed110 Mon Sep 17 00:00:00 2001 From: ronia Date: Fri, 19 Jun 2026 12:31:43 +0300 Subject: [PATCH 3/3] style: fix ktlint import ordering (LaunchedEffect before collectAsState) --- app/src/main/java/com/m57/hermescontrol/Navigation.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/m57/hermescontrol/Navigation.kt b/app/src/main/java/com/m57/hermescontrol/Navigation.kt index dec259ba..22f908a0 100644 --- a/app/src/main/java/com/m57/hermescontrol/Navigation.kt +++ b/app/src/main/java/com/m57/hermescontrol/Navigation.kt @@ -46,8 +46,8 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope