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
2026-07-08 01:00:56 +03:00
committed by GitHub
parent 089eaf06e5
commit 739ff4f62b
2 changed files with 58 additions and 23 deletions
@@ -109,12 +109,14 @@ import androidx.core.content.FileProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.m57.hermescontrol.AuthLoginScreen
import com.m57.hermescontrol.NavigationController import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.ws.CommandCatalog import com.m57.hermescontrol.data.ws.CommandCatalog
import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.notification.NotificationHelper import com.m57.hermescontrol.notification.NotificationHelper
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.StatusRed import com.m57.hermescontrol.theme.StatusRed
import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.HermesScaffold
@@ -360,6 +362,7 @@ fun ChatScreen(
ChatTopBanner( ChatTopBanner(
connectionStatus = state.connectionStatus, connectionStatus = state.connectionStatus,
onReconnect = viewModel::reconnect, onReconnect = viewModel::reconnect,
onAuthExpired = { NavigationController.navigateTo(AuthLoginScreen) },
) )
Box( Box(
@@ -1337,18 +1340,20 @@ private fun ChatLifecycleEffects(
private fun ChatTopBanner( private fun ChatTopBanner(
connectionStatus: ConnectionStatus, connectionStatus: ConnectionStatus,
onReconnect: () -> Unit, onReconnect: () -> Unit,
onAuthExpired: () -> Unit,
) { ) {
val isShown =
connectionStatus != ConnectionStatus.CONNECTED &&
connectionStatus != ConnectionStatus.CONNECTING
androidx.compose.animation.AnimatedVisibility( androidx.compose.animation.AnimatedVisibility(
visible = visible = isShown,
connectionStatus == ConnectionStatus.RECONNECTING ||
connectionStatus == ConnectionStatus.DISCONNECTED,
enter = androidx.compose.animation.expandVertically() + androidx.compose.animation.fadeIn(), enter = androidx.compose.animation.expandVertically() + androidx.compose.animation.fadeIn(),
exit = androidx.compose.animation.shrinkVertically() + androidx.compose.animation.fadeOut(), exit = androidx.compose.animation.shrinkVertically() + androidx.compose.animation.fadeOut(),
) { ) {
androidx.compose.material3.Surface( androidx.compose.material3.Surface(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.errorContainer, color = LocalHermesStatusColors.current.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer, contentColor = LocalHermesStatusColors.current.error,
) { ) {
Row( Row(
modifier = modifier =
@@ -1358,26 +1363,53 @@ private fun ChatTopBanner(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
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(
text = text =
if (connectionStatus == ConnectionStatus.RECONNECTING) { when (connectionStatus) {
stringResource(R.string.chat_status_reconnecting) ConnectionStatus.RECONNECTING -> stringResource(R.string.chat_status_reconnecting)
} else { ConnectionStatus.DISCONNECTED -> stringResource(R.string.chat_status_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, style = MaterialTheme.typography.bodyMedium,
) )
if (connectionStatus == ConnectionStatus.DISCONNECTED) { }
when (connectionStatus) {
ConnectionStatus.DISCONNECTED,
ConnectionStatus.NO_NETWORK,
-> {
TextButton( TextButton(
onClick = onReconnect, onClick = onReconnect,
colors = colors =
androidx.compose.material3.ButtonDefaults.textButtonColors( androidx.compose.material3.ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.onErrorContainer, contentColor = LocalHermesStatusColors.current.error,
), ),
) { ) {
Text(stringResource(R.string.chat_action_reconnect)) 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 */ }
}
} }
} }
} }
+3
View File
@@ -82,11 +82,14 @@
<string name="chat_action_search">Search</string> <string name="chat_action_search">Search</string>
<string name="chat_action_close_search">Close search</string> <string name="chat_action_close_search">Close search</string>
<string name="chat_action_reconnect">Reconnect</string> <string name="chat_action_reconnect">Reconnect</string>
<string name="chat_action_sign_in">Sign in again</string>
<string name="chat_empty_title">Ready to chat</string> <string name="chat_empty_title">Ready to chat</string>
<string name="chat_empty_subtitle">Send a message to start a conversation</string> <string name="chat_empty_subtitle">Send a message to start a conversation</string>
<string name="chat_status_connecting">Connecting to Hermes\u2026</string> <string name="chat_status_connecting">Connecting to Hermes\u2026</string>
<string name="chat_status_reconnecting">Reconnecting to Hermes\u2026</string> <string name="chat_status_reconnecting">Reconnecting to Hermes\u2026</string>
<string name="chat_status_disconnected">Disconnected from Hermes</string> <string name="chat_status_disconnected">Disconnected from Hermes</string>
<string name="chat_status_no_network">No network connection</string>
<string name="chat_status_auth_expired">Session expired \u2014 please sign in again</string>
<string name="chat_thinking">Thinking\u2026</string> <string name="chat_thinking">Thinking\u2026</string>
<string name="chat_thinking_param">Thinking: %1$s\u2026</string> <string name="chat_thinking_param">Thinking: %1$s\u2026</string>
<string name="chat_input_placeholder_not_connected">Not connected\u2026</string> <string name="chat_input_placeholder_not_connected">Not connected\u2026</string>