fix(chat): surface all disconnected states in reconnect banner (#491) (#493)

* fix(chat): surface all disconnected states in reconnect banner (issue #491)

The connection-status banner only appeared for RECONNECTING/DISCONNECTED,
so NO_NETWORK and AUTH_EXPIRED left the user staring at a silent chat with
no feedback and no way to recover — the core of the 'reconnecting is not
user-friendly' complaint.

ChatTopBanner now shows for every non-connected, non-connecting state:
- RECONNECTING: spinner + 'Reconnecting to Hermes…' (auto, no button)
- DISCONNECTED: 'Disconnected from Hermes' + Reconnect
- NO_NETWORK: 'No network connection' + Reconnect (force a retry)
- AUTH_EXPIRED: 'Session expired' + 'Sign in again' -> AuthLoginScreen

The auto-reconnect engine (backoff, network-return trigger, in-flight
message queue in HermesWsClient) is untouched — this only makes the stuck
states visible and actionable.

* fix(chat): use LocalHermesStatusColors for reconnect banner (review fix)

Per AGENTS.md, semantic status colors must come from
LocalHermesStatusColors, never MaterialTheme.colorScheme. The reconnect
banner used errorContainer/onErrorContainer directly, which is both a
semantic-color violation and inconsistent with the rest of the app
(Navigation.kt maps DISCONNECTED/NO_NETWORK/AUTH_EXPIRED -> .error;
TelegramOnboardingDialog uses .errorContainer for error surfaces).

Switch Surface bg to errorContainer and foreground (spinner, text,
button content) to error via LocalHermesStatusColors.current.
This commit is contained in:
M57 authored and GitHub committed 2026-07-08 01:00:56 +03:00
1 parent 089eaf06e5
commit 739ff4f62b
2 files changed
+58 -23

No files matched your search

@@ -109,12 +109,14 @@ import androidx.core.content.FileProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.m57.hermescontrol.AuthLoginScreen
import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.ws.CommandCatalog
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.notification.NotificationHelper
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.StatusRed
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.HermesScaffold
@@ -360,6 +362,7 @@ fun ChatScreen(
ChatTopBanner(
connectionStatus = state.connectionStatus,
onReconnect = viewModel::reconnect,
onAuthExpired = { NavigationController.navigateTo(AuthLoginScreen) },
)
Box(
@@ -1337,18 +1340,20 @@ private fun ChatLifecycleEffects(
private fun ChatTopBanner(
connectionStatus: ConnectionStatus,
onReconnect: () -> Unit,
onAuthExpired: () -> Unit,
) {
val isShown =
connectionStatus != ConnectionStatus.CONNECTED &&
connectionStatus != ConnectionStatus.CONNECTING
androidx.compose.animation.AnimatedVisibility(
visible =
connectionStatus == ConnectionStatus.RECONNECTING ||
connectionStatus == ConnectionStatus.DISCONNECTED,
visible = isShown,
enter = androidx.compose.animation.expandVertically() + androidx.compose.animation.fadeIn(),
exit = androidx.compose.animation.shrinkVertically() + androidx.compose.animation.fadeOut(),
) {
androidx.compose.material3.Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
color = LocalHermesStatusColors.current.errorContainer,
contentColor = LocalHermesStatusColors.current.error,
) {
Row(
modifier =
@@ -1358,25 +1363,52 @@ private fun ChatTopBanner(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text =
if (connectionStatus == ConnectionStatus.RECONNECTING) {
stringResource(R.string.chat_status_reconnecting)
} else {
stringResource(R.string.chat_status_disconnected)
},
style = MaterialTheme.typography.bodyMedium,
)
if (connectionStatus == ConnectionStatus.DISCONNECTED) {
TextButton(
onClick = onReconnect,
colors =
androidx.compose.material3.ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
) {
Text(stringResource(R.string.chat_action_reconnect))
Row(verticalAlignment = Alignment.CenterVertically) {
if (connectionStatus == ConnectionStatus.RECONNECTING) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp).padding(end = 8.dp),
strokeWidth = 2.dp,
color = LocalHermesStatusColors.current.error,
)
}
Text(
text =
when (connectionStatus) {
ConnectionStatus.RECONNECTING -> stringResource(R.string.chat_status_reconnecting)
ConnectionStatus.DISCONNECTED -> stringResource(R.string.chat_status_disconnected)
ConnectionStatus.NO_NETWORK -> stringResource(R.string.chat_status_no_network)
ConnectionStatus.AUTH_EXPIRED -> stringResource(R.string.chat_status_auth_expired)
else -> ""
},
style = MaterialTheme.typography.bodyMedium,
)
}
when (connectionStatus) {
ConnectionStatus.DISCONNECTED,
ConnectionStatus.NO_NETWORK,
-> {
TextButton(
onClick = onReconnect,
colors =
androidx.compose.material3.ButtonDefaults.textButtonColors(
contentColor = LocalHermesStatusColors.current.error,
),
) {
Text(stringResource(R.string.chat_action_reconnect))
}
}
ConnectionStatus.AUTH_EXPIRED -> {
TextButton(
onClick = onAuthExpired,
colors =
androidx.compose.material3.ButtonDefaults.textButtonColors(
contentColor = LocalHermesStatusColors.current.error,
),
) {
Text(stringResource(R.string.chat_action_sign_in))
}
}
else -> { /* RECONNECTING: auto, no button */ }
}
}
}