feat(#396): native approval UI for dangerous command, memory, and skill write requests (#397)

* feat(ws): add ApprovalRequest event type and parser

* feat(model): add ApprovalInfo field to ChatMessage

* feat(viewmodel): handle approval.request events and respondToApproval

* feat(ui): render Approve/Deny buttons for approval request messages

* test: add approval request event and respond RPC tests

* fix: add ApprovalRequest branch to ChatWsEventReducer exhaustive when

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-06-26 15:53:11 +03:00
1 parent fafba3231b
commit cac15147f4
8 files changed
+285 -3

No files matched your search

@@ -105,6 +105,15 @@ object EventParser {
WsEvent.SessionUpdated(payload)
}
"approval.request" -> {
val command = payload?.get("command") as? String
val description = payload?.get("description") as? String
@Suppress("UNCHECKED_CAST")
val patternKeys = (payload?.get("pattern_keys") as? List<*>)?.filterIsInstance<String>()
WsEvent.ApprovalRequest(command, description, patternKeys, sessionId)
}
else -> {
Log.w(TAG, "Unknown event type: $eventType")
WsEvent.Unknown(rawJson)
@@ -85,6 +85,15 @@ sealed class WsEvent {
val error: JsonRpcError,
) : WsEvent()
// ── Approval request ────────────────────────────────────────────────
data class ApprovalRequest(
val command: String?,
val description: String?,
val patternKeys: List<String>?,
val sessionId: String?,
) : WsEvent()
// ── Fallback ─────────────────────────────────────────────────────────
data class Unknown(
@@ -26,17 +26,22 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Error
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -95,6 +100,7 @@ fun ChatBubble(
isDarkTheme: Boolean,
searchQuery: String = "",
isCurrentMatch: Boolean = false,
onRespondApproval: (String) -> Unit = {},
modifier: Modifier = Modifier,
) {
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
@@ -125,7 +131,11 @@ fun ChatBubble(
}
MessageRole.SYSTEM -> {
SystemBubble(message, modifier)
SystemBubble(
message = message,
onRespondApproval = onRespondApproval,
modifier = modifier,
)
}
MessageRole.TOOL -> {
@@ -415,14 +425,15 @@ private fun AssistantBubble(
@Composable
private fun SystemBubble(
message: ChatMessage,
onRespondApproval: (String) -> Unit = {},
modifier: Modifier = Modifier,
) {
Box(
Column(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
contentAlignment = Alignment.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = message.content,
@@ -432,6 +443,54 @@ private fun SystemBubble(
color = SystemMessageColor,
),
)
// Approval action buttons
if (message.approvalInfo != null) {
Spacer(Modifier.height(8.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilledTonalButton(
onClick = { onRespondApproval("approve") },
modifier =
Modifier
.height(36.dp)
.testTag("approve_button"),
colors =
ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
),
) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(4.dp))
Text("Approve")
}
FilledTonalButton(
onClick = { onRespondApproval("deny") },
modifier =
Modifier
.height(36.dp)
.testTag("deny_button"),
colors =
ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
),
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(4.dp))
Text("Deny")
}
}
}
}
}
@@ -2,6 +2,17 @@ package com.m57.hermescontrol.ui.chat
import java.util.UUID
/**
* Metadata for a [ChatMessage] that represents an approval request.
* When present, the UI renders Approve/Deny buttons inline.
* Transient — not persisted to SQLite.
*/
data class ApprovalInfo(
val command: String?,
val description: String?,
val patternKeys: List<String>?,
)
data class ChatMessage(
val id: String = UUID.randomUUID().toString(),
val role: MessageRole,
@@ -10,6 +21,7 @@ data class ChatMessage(
val isStreaming: Boolean = false,
val toolName: String? = null,
val toolStatus: ToolStatus? = null,
val approvalInfo: ApprovalInfo? = null,
)
enum class MessageRole {
@@ -1119,6 +1119,7 @@ private fun ChatMessageList(
isDarkTheme = isDark,
searchQuery = if (isSearchActive) searchQuery else "",
isCurrentMatch = isCurrentMatch,
onRespondApproval = viewModel::respondToApproval,
)
}
}
@@ -269,6 +269,10 @@ class ChatViewModel(
}
}
is WsEvent.ApprovalRequest -> {
handleApprovalRequest(event)
}
else -> { /* reducer handles these */ }
}
}
@@ -437,6 +441,14 @@ class ChatViewModel(
WsMethods.COMMAND_DISPATCH -> {
handleDispatchResult(result)
}
WsMethods.APPROVAL_RESPOND -> {
val map = result as? Map<*, *>
val resolved = (map?.get("resolved") as? Number)?.toInt() ?: 0
if (resolved > 0) {
addSystemMessage("✅ Approval submitted")
}
}
}
}
@@ -947,6 +959,63 @@ class ChatViewModel(
_uiState.update { it.copy(errorMessage = null) }
}
// ── Approval flow ───────────────────────────────────────────────────
private fun handleApprovalRequest(event: WsEvent.ApprovalRequest) {
val description = event.description ?: event.command ?: "Unknown command"
val content = "⚠️ **Approval Required**\n$description"
val msg =
ChatMessage(
role = MessageRole.SYSTEM,
content = content,
approvalInfo =
ApprovalInfo(
command = event.command,
description = event.description,
patternKeys = event.patternKeys,
),
)
_uiState.update { state ->
state.copy(
messages = state.messages + msg,
isAgentTyping = false,
)
}
}
fun respondToApproval(action: String) {
val state = _uiState.value
val approvalMsg = state.messages.lastOrNull { it.approvalInfo != null } ?: return
val sessionId = state.currentSessionId ?: return
// Clear buttons immediately
_uiState.update { s ->
s.copy(
messages =
s.messages.map {
if (it.id == approvalMsg.id) {
it.copy(approvalInfo = null)
} else {
it
}
},
)
}
viewModelScope.launch(Dispatchers.IO) {
wsClient.send(
method = WsMethods.APPROVAL_RESPOND,
params =
mapOf(
"session_id" to sessionId,
"choice" to action,
"all" to false,
),
onSent = { id -> trackRequest(id, WsMethods.APPROVAL_RESPOND) },
)
}
}
fun reconnect() {
_uiState.update {
it.copy(
@@ -72,6 +72,9 @@ object ChatWsEventReducer {
// RpcResult is handled by the ViewModel (needs pending request context)
is WsEvent.RpcResult -> ReducerResult(state)
// ApprovalRequest is handled by the ViewModel (needs active session + WS client)
is WsEvent.ApprovalRequest -> ReducerResult(state)
}
// ── GatewayReady ──────────────────────────────────────────────────