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:
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(
|
||||
|
||||
Reference in new issue
Block a user