feat(#527): surface gateway error + background.complete WebSocket events (#550)

Mobile was silently dropping two event types the gateway emits. This adds
parsing + UI surfacing for both, bringing mobile to parity with desktop
(which shows gateway errors as red toasts).

- `error` (backend crash) -> WsEvent.GatewayError, surfaced in the existing
  error banner (mirrors how RpcError is handled).
- `background.complete` (scheduled job finished) -> WsEvent.BackgroundComplete,
  surfaced as a non-blocking snackbar in ChatScreen.

Changes:
- WsEvent.kt: add GatewayError(message?) + BackgroundComplete(data?) sealed subtypes.
- EventParser.kt: handle "error" + "background.complete"; unknowns still fall
  through to Unknown (no behavior change for existing event types).
- ChatWsEventReducer.kt: onGatewayError (sets errorMessage, clears loading) +
  onBackgroundComplete (sets backgroundCompleteMessage); mirrors onRpcError.
- ChatViewModel.kt: backgroundCompleteMessage state field, clearBackgroundComplete(),
  handleWsEvent branches (ViewModel owns the snackbar trigger).
- ChatScreen.kt: pass backgroundCompleteMessage to ChatLifecycleEffects + a
  LaunchedEffect that shows the snackbar and calls clearBackgroundComplete().
- EventParserTest.kt: cover both new events (incl. missing-message case).

Pure additive; sealed class + exhaustive when() preserved. Verified:
compileDebugKotlin + ktlintCheck green; EventParserTest passes.

Closes #527

Co-authored-by: M57 <m57@hermes-control.app>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-11 15:18:49 +03:00
1 parent 480d29e481
commit cf893585b1
6 files changed
+160

No files matched your search

@@ -111,6 +111,17 @@ object EventParser {
WsEvent.StatusUpdate(status, payload)
}
"error" -> {
val message =
payload?.get("message") as? String
?: payload?.get("error") as? String
WsEvent.GatewayError(message)
}
"background.complete" -> {
WsEvent.BackgroundComplete(payload)
}
"session.updated" -> {
WsEvent.SessionUpdated(payload)
}
@@ -125,6 +125,33 @@ sealed class WsEvent {
val sessionId: String?,
) : WsEvent()
// ── Gateway-level errors ───────────────────────────────────────────
/**
* Backend/unhandled failure surfaced by the gateway.
*
* Desktop shows these as red toasts (the gateway also writes
* `[gateway.error]` lines to the console). Mobile was previously
* dropping this event, so a crashing turn just silently stopped with no
* explanation. Issue #527 surfaces it in the existing error banner.
*/
data class GatewayError(
val message: String?,
) : WsEvent()
// ── Background job completion ──────────────────────────────────────
/**
* A scheduled/background job finished on the gateway.
*
* Desktop shows a "Background job finished" toast. Mobile surfaces this as
* a non-blocking snackbar. The payload may carry `label`/`name` describing
* the job. Issue #527.
*/
data class BackgroundComplete(
val data: Map<String, Any?>?,
) : WsEvent()
// ── Fallback ─────────────────────────────────────────────────────────
data class Unknown(
@@ -277,6 +277,7 @@ fun ChatScreen(
streamingMessage = streamingState.streamingMessage,
isThinking = streamingState.isThinking,
errorMessage = state.errorMessage,
backgroundCompleteMessage = state.backgroundCompleteMessage,
isSearchActive = state.isSearchActive,
currentSearchMatchIndex = state.currentSearchMatchIndex,
searchMatchIndices = state.searchMatchIndices,
@@ -1390,6 +1391,7 @@ private fun ChatLifecycleEffects(
streamingMessage: ChatMessage?,
isThinking: Boolean,
errorMessage: String?,
backgroundCompleteMessage: String?,
isSearchActive: Boolean,
currentSearchMatchIndex: Int,
searchMatchIndices: List<Int>,
@@ -1487,6 +1489,14 @@ private fun ChatLifecycleEffects(
}
}
// Show background-complete as a non-blocking snackbar (issue #527)
LaunchedEffect(backgroundCompleteMessage) {
backgroundCompleteMessage?.let { message ->
snackbarHostState.showSnackbar(message)
viewModel.clearBackgroundComplete()
}
}
// Clarify dialog
clarifyRequest?.let { clarify ->
ClarifyDialog(
@@ -56,6 +56,8 @@ data class ChatUiState(
/** Standalone streaming message — rendered after the main list. */
val streamingMessage: ChatMessage? = null,
val errorMessage: String? = null,
// Background job completion toast (issue #527) — non-blocking snackbar
val backgroundCompleteMessage: String? = null,
val clarifyRequest: ClarifyUi? = null,
// Sudo / secret prompts — surfaced as dialogs (issue #524)
val sudoPrompt: SudoPromptUi? = null,
@@ -367,6 +369,15 @@ class ChatViewModel(
handleSecretRequest(event)
}
is WsEvent.GatewayError -> {
// Reducer already set errorMessage; no extra VM work needed.
}
is WsEvent.BackgroundComplete -> {
// Reducer already set backgroundCompleteMessage; the UI observes
// it via a LaunchedEffect and triggers the snackbar.
}
else -> { /* reducer handles these */ }
}
}
@@ -1033,6 +1044,10 @@ class ChatViewModel(
_uiState.update { it.copy(errorMessage = null) }
}
fun clearBackgroundComplete() {
_uiState.update { it.copy(backgroundCompleteMessage = null) }
}
// ── Approval flow ───────────────────────────────────────────────────
private fun handleApprovalRequest(event: WsEvent.ApprovalRequest) {
@@ -71,6 +71,10 @@ object ChatWsEventReducer {
is WsEvent.RpcError -> onRpcError(state, streamingState, event)
is WsEvent.GatewayError -> onGatewayError(state, streamingState, event)
is WsEvent.BackgroundComplete -> onBackgroundComplete(state, streamingState, event)
is WsEvent.SessionUpdated -> onSessionUpdated(state, streamingState)
is WsEvent.StatusUpdate -> onStatusUpdate(state, streamingState)
@@ -379,6 +383,44 @@ object ChatWsEventReducer {
streamingState = streamingState,
)
// ── GatewayError ──────────────────────────────────────────────────
// Backend/unhandled failure surfaced by the gateway (issue #527).
// Mirrors onRpcError: surfaces the message in the existing error banner.
private fun onGatewayError(
state: ChatUiState,
streamingState: StreamingState,
event: WsEvent.GatewayError,
): ReducerResult =
ReducerResult(
state =
state.copy(
isLoading = false,
errorMessage = "⚠️ Backend error: ${event.message ?: "Unknown gateway error"}",
),
streamingState = streamingState,
)
// ── BackgroundComplete ────────────────────────────────────────────
// A scheduled/background job finished (issue #527). The ViewModel turns
// this into a non-blocking snackbar. No state mutation beyond passing it
// through; the ViewModel owns the snackbar trigger.
private fun onBackgroundComplete(
state: ChatUiState,
streamingState: StreamingState,
event: WsEvent.BackgroundComplete,
): ReducerResult {
val message =
when (val label = event.data?.get("label")) {
is String -> label
else -> event.data?.get("name") as? String
}?.let { "✅ Background job finished: $it" }
?: "✅ Background job finished"
return ReducerResult(
state = state.copy(backgroundCompleteMessage = message),
streamingState = streamingState,
)
}
// ── SessionUpdated / StatusUpdate / Unknown ───────────────────────
private fun onSessionUpdated(