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