diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt index 72a38fac..c9cc5b0d 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt @@ -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) } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt index 4d0d234c..01335f4a 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt @@ -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?, + ) : WsEvent() + // ── Fallback ───────────────────────────────────────────────────────── data class Unknown( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 2d9e7d9d..953a2fd9 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -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, @@ -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( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 8052c309..6c58030c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -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) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index 6c828d43..2bb65f78 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -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( diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt index 089ee9d4..6298352d 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt @@ -423,6 +423,61 @@ class EventParserTest { assertEquals(mapOf("session_id" to "sess-1"), (event as WsEvent.SessionUpdated).data) } + @Test + fun testParseGatewayError_returnsGatewayErrorEvent() { + val response = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = mapOf("type" to "error", "payload" to mapOf("message" to "boom")), + ) + val event = EventParser.parse(response, "") + assertTrue(event is WsEvent.GatewayError) + assertEquals("boom", (event as WsEvent.GatewayError).message) + } + + @Test + fun testParseGatewayError_missingMessage_returnsNullMessage() { + val response = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = mapOf("type" to "error", "payload" to mapOf()), + ) + val event = EventParser.parse(response, "") + assertTrue(event is WsEvent.GatewayError) + assertEquals(null, (event as WsEvent.GatewayError).message) + } + + @Test + fun testParseBackgroundComplete_returnsBackgroundCompleteEvent() { + val response = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = + mapOf( + "type" to "background.complete", + "payload" to mapOf("label" to "nightly backup"), + ), + ) + val event = EventParser.parse(response, "") + assertTrue(event is WsEvent.BackgroundComplete) + assertEquals( + "nightly backup", + (event as WsEvent.BackgroundComplete).data?.get("label"), + ) + } + @Test fun testParseUnknownType_returnsUnknownEvent() { val response =