feat(vault): credential vault status and mid-turn auth prompt handling (#1090) (#1097)

This commit is contained in:
M57 authored and GitHub committed 2026-09-13 00:20:07 +03:00
1 parent d774a63663
commit 69cd34d289
23 files changed
+2224 -53

No files matched your search

@@ -7,6 +7,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
/**
* Owns sudo and secret prompt state, timeouts, dismissal, and response RPCs.
@@ -177,4 +179,234 @@ class ChatCredentialPromptsDelegate(
) { id -> trackRequest(id, WsMethods.SECRET_RESPOND) }
}
}
// ── Vault prompts (issue #1090) ───────────────────────────────────────
/**
* The agent needs to unlock a password manager (1Password, Bitwarden).
* Surface an interactive prompt card in chat and reply via vault.unlock.respond.
*/
fun handleVaultUnlockRequest(event: WsEvent.VaultUnlockRequest) {
uiState.update {
it.copy(
vaultUnlockPrompt =
VaultUnlockPromptUi(
requestId = event.requestId,
sessionId = event.sessionId,
backend = event.backend,
displayName = event.displayName,
),
isAgentTyping = false,
)
}
}
fun handleVaultUnlockExpire(event: WsEvent.VaultUnlockExpire) {
uiState.update { state ->
val current = state.vaultUnlockPrompt ?: return@update state
if (event.requestId != null && current.requestId != null &&
event.requestId != current.requestId
) {
return@update state
}
state.copy(vaultUnlockPrompt = null)
}
}
/**
* Cancel/dismiss → send empty password (keeps manager locked, unblocks turn).
*/
fun dismissVaultUnlock() {
val prompt = uiState.value.vaultUnlockPrompt ?: return
val sessionId = prompt.sessionId ?: uiState.value.currentSessionId
uiState.update { it.copy(vaultUnlockPrompt = null) }
if (sessionId == null) return
scope.launch(ioDispatcher) {
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"password" to "",
)
prompt.requestId?.let { id -> params["request_id"] = id }
wsSend(
WsMethods.VAULT_UNLOCK_RESPOND,
params,
) { id -> trackRequest(id, WsMethods.VAULT_UNLOCK_RESPOND) }
}
}
fun respondToVaultUnlock(password: String) {
val prompt = uiState.value.vaultUnlockPrompt ?: return
val sessionId = prompt.sessionId ?: uiState.value.currentSessionId ?: return
if (password.isBlank()) return
uiState.update { it.copy(vaultUnlockPrompt = null) }
scope.launch(ioDispatcher) {
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"password" to password,
)
prompt.requestId?.let { id -> params["request_id"] = id }
wsSend(
WsMethods.VAULT_UNLOCK_RESPOND,
params,
) { id -> trackRequest(id, WsMethods.VAULT_UNLOCK_RESPOND) }
}
}
/**
* The agent captured credentials on a login page and asks whether to store them in the vault.
*/
fun handleVaultSaveLoginRequest(event: WsEvent.VaultSaveLoginRequest) {
uiState.update {
it.copy(
vaultSaveLoginPrompt =
VaultSaveLoginPromptUi(
requestId = event.requestId,
sessionId = event.sessionId,
origin = event.origin,
site = event.site,
),
isAgentTyping = false,
)
}
}
fun handleVaultSaveLoginExpire(event: WsEvent.VaultSaveLoginExpire) {
uiState.update { state ->
val current = state.vaultSaveLoginPrompt ?: return@update state
if (event.requestId != null && current.requestId != null &&
event.requestId != current.requestId
) {
return@update state
}
state.copy(vaultSaveLoginPrompt = null)
}
}
/**
* Decline saving → send empty login (backend treats empty login as declined).
*/
fun dismissVaultSaveLogin() {
val prompt = uiState.value.vaultSaveLoginPrompt ?: return
val sessionId = prompt.sessionId ?: uiState.value.currentSessionId
uiState.update { it.copy(vaultSaveLoginPrompt = null) }
if (sessionId == null) return
scope.launch(ioDispatcher) {
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"login" to "",
)
prompt.requestId?.let { id -> params["request_id"] = id }
wsSend(
WsMethods.VAULT_SAVE_LOGIN_RESPOND,
params,
) { id -> trackRequest(id, WsMethods.VAULT_SAVE_LOGIN_RESPOND) }
}
}
fun respondToVaultSaveLogin(
identifier: String,
password: String,
) {
val prompt = uiState.value.vaultSaveLoginPrompt ?: return
val sessionId = prompt.sessionId ?: uiState.value.currentSessionId ?: return
if (identifier.isBlank() || password.isBlank()) return
uiState.update { it.copy(vaultSaveLoginPrompt = null) }
scope.launch(ioDispatcher) {
val loginJson =
buildJsonObject {
put("identifier", identifier)
put("password", password)
}.toString()
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"login" to loginJson,
)
prompt.requestId?.let { id -> params["request_id"] = id }
wsSend(
WsMethods.VAULT_SAVE_LOGIN_RESPOND,
params,
) { id -> trackRequest(id, WsMethods.VAULT_SAVE_LOGIN_RESPOND) }
}
}
/**
* The site requested a 2FA/MFA verification code.
*/
fun handleVaultCodeRequest(event: WsEvent.VaultCodeRequest) {
uiState.update {
it.copy(
vaultCodePrompt =
VaultCodePromptUi(
requestId = event.requestId,
sessionId = event.sessionId,
site = event.site,
hint = event.hint,
),
isAgentTyping = false,
)
}
}
fun handleVaultCodeExpire(event: WsEvent.VaultCodeExpire) {
uiState.update { state ->
val current = state.vaultCodePrompt ?: return@update state
if (event.requestId != null && current.requestId != null &&
event.requestId != current.requestId
) {
return@update state
}
state.copy(vaultCodePrompt = null)
}
}
/**
* Skip entering code → send empty code.
*/
fun dismissVaultCode() {
val prompt = uiState.value.vaultCodePrompt ?: return
val sessionId = prompt.sessionId ?: uiState.value.currentSessionId
uiState.update { it.copy(vaultCodePrompt = null) }
if (sessionId == null) return
scope.launch(ioDispatcher) {
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"code" to "",
)
prompt.requestId?.let { id -> params["request_id"] = id }
wsSend(
WsMethods.VAULT_CODE_RESPOND,
params,
) { id -> trackRequest(id, WsMethods.VAULT_CODE_RESPOND) }
}
}
fun respondToVaultCode(code: String) {
val prompt = uiState.value.vaultCodePrompt ?: return
val sessionId = prompt.sessionId ?: uiState.value.currentSessionId ?: return
if (code.isBlank()) return
uiState.update { it.copy(vaultCodePrompt = null) }
scope.launch(ioDispatcher) {
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"code" to code,
)
prompt.requestId?.let { id -> params["request_id"] = id }
wsSend(
WsMethods.VAULT_CODE_RESPOND,
params,
) { id -> trackRequest(id, WsMethods.VAULT_CODE_RESPOND) }
}
}
}
@@ -3,7 +3,6 @@ package com.m57.hermescontrol.ui.chat
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
@@ -549,6 +548,15 @@ fun ChatScreen(
onRespondClarify = viewModel::respondToClarify,
onRespondClarifyBatch = viewModel::respondToClarifyBatch,
onDismissClarify = viewModel::dismissClarify,
vaultUnlockPrompt = state.vaultUnlockPrompt,
onRespondVaultUnlock = viewModel::respondToVaultUnlock,
onDismissVaultUnlock = viewModel::dismissVaultUnlock,
vaultSaveLoginPrompt = state.vaultSaveLoginPrompt,
onRespondVaultSaveLogin = viewModel::respondToVaultSaveLogin,
onDismissVaultSaveLogin = viewModel::dismissVaultSaveLogin,
vaultCodePrompt = state.vaultCodePrompt,
onRespondVaultCode = viewModel::respondToVaultCode,
onDismissVaultCode = viewModel::dismissVaultCode,
onSaveAttachment = onSaveAttachment,
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
openingAttachmentPath = state.openingAttachmentPath,
@@ -98,6 +98,10 @@ data class ChatUiState(
// Sudo / secret prompts — surfaced as dialogs (issue #524)
val sudoPrompt: SudoPromptUi? = null,
val secretPrompt: SecretPromptUi? = null,
// Credential vault prompts — interactive prompt cards (issue #1090)
val vaultUnlockPrompt: VaultUnlockPromptUi? = null,
val vaultSaveLoginPrompt: VaultSaveLoginPromptUi? = null,
val vaultCodePrompt: VaultCodePromptUi? = null,
val showSessionPicker: Boolean = false,
// /update confirm dialog (issue #862) — the command is handled client-side
val updateConfirmOpen: Boolean = false,
@@ -251,6 +255,30 @@ data class SecretPromptUi(
val prompt: String? = null,
)
/** Transient — not persisted. Holds a pending vault unlock request (issue #1090). */
data class VaultUnlockPromptUi(
val requestId: String?,
val sessionId: String?,
val backend: String? = null,
val displayName: String? = null,
)
/** Transient — not persisted. Holds a pending vault save login request (issue #1090). */
data class VaultSaveLoginPromptUi(
val requestId: String?,
val sessionId: String?,
val origin: String? = null,
val site: String? = null,
)
/** Transient — not persisted. Holds a pending vault 2FA/MFA code request (issue #1090). */
data class VaultCodePromptUi(
val requestId: String?,
val sessionId: String?,
val site: String? = null,
val hint: String? = null,
)
/**
* Token breakdown backing the context meter's detail sheet. All values are
* cumulative lifetime token counts sourced from `GET /api/sessions/{id}`
@@ -882,6 +910,30 @@ class ChatViewModel(
credentialPromptsDelegate.handleSecretExpire(event)
}
is WsEvent.VaultUnlockRequest -> {
credentialPromptsDelegate.handleVaultUnlockRequest(event)
}
is WsEvent.VaultUnlockExpire -> {
credentialPromptsDelegate.handleVaultUnlockExpire(event)
}
is WsEvent.VaultSaveLoginRequest -> {
credentialPromptsDelegate.handleVaultSaveLoginRequest(event)
}
is WsEvent.VaultSaveLoginExpire -> {
credentialPromptsDelegate.handleVaultSaveLoginExpire(event)
}
is WsEvent.VaultCodeRequest -> {
credentialPromptsDelegate.handleVaultCodeRequest(event)
}
is WsEvent.VaultCodeExpire -> {
credentialPromptsDelegate.handleVaultCodeExpire(event)
}
is WsEvent.GatewayError -> {
// Reducer already set errorMessage; no extra VM work needed.
}
@@ -3251,6 +3303,23 @@ class ChatViewModel(
fun respondToSecret(value: String) = credentialPromptsDelegate.respondToSecret(value)
// ── Vault prompt flow (issue #1090) ──────────────────────────────────
fun dismissVaultUnlock() = credentialPromptsDelegate.dismissVaultUnlock()
fun respondToVaultUnlock(password: String) = credentialPromptsDelegate.respondToVaultUnlock(password)
fun dismissVaultSaveLogin() = credentialPromptsDelegate.dismissVaultSaveLogin()
fun respondToVaultSaveLogin(
identifier: String,
password: String,
) = credentialPromptsDelegate.respondToVaultSaveLogin(identifier, password)
fun dismissVaultCode() = credentialPromptsDelegate.dismissVaultCode()
fun respondToVaultCode(code: String) = credentialPromptsDelegate.respondToVaultCode(code)
fun reconnect() {
_uiState.update {
it.copy(
@@ -75,6 +75,12 @@ object ChatWsEventReducer {
is WsEvent.BtwComplete -> event.sessionId
is WsEvent.SessionUsage -> event.sessionId
is WsEvent.TranscriptResyncRequired -> event.sessionId
is WsEvent.VaultUnlockRequest -> event.sessionId
is WsEvent.VaultUnlockExpire -> event.sessionId
is WsEvent.VaultSaveLoginRequest -> event.sessionId
is WsEvent.VaultSaveLoginExpire -> event.sessionId
is WsEvent.VaultCodeRequest -> event.sessionId
is WsEvent.VaultCodeExpire -> event.sessionId
else -> null
}
if (eventSessionId != null && currentSessionId != null && eventSessionId != currentSessionId) {
@@ -170,6 +176,15 @@ object ChatWsEventReducer {
is WsEvent.SecretExpire -> ReducerResult(state = state, streamingState = streamingState)
// Vault prompts are handled by the ViewModel (issue #1090)
is WsEvent.VaultUnlockRequest,
is WsEvent.VaultUnlockExpire,
is WsEvent.VaultSaveLoginRequest,
is WsEvent.VaultSaveLoginExpire,
is WsEvent.VaultCodeRequest,
is WsEvent.VaultCodeExpire,
-> ReducerResult(state = state, streamingState = streamingState)
// ReactionEvent is handled by the ViewModel — purely cosmetic animation
is WsEvent.ReactionEvent -> ReducerResult(state = state, streamingState = streamingState)
@@ -0,0 +1,394 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Pin
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi
/**
* Interactive prompt cards for mid-turn Credential Vault operations (issue #1090).
* Rendered inline in chat (matching ClarifyBubble pattern) to avoid blocking dialog scrims.
*/
@Composable
fun VaultUnlockCard(
prompt: VaultUnlockPromptUi,
onConfirm: (String) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
var password by remember(prompt.requestId) { mutableStateOf("") }
var passwordVisible by remember(prompt.requestId) { mutableStateOf(false) }
val displayName = prompt.displayName ?: prompt.backend ?: "Password Manager"
Surface(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp)
.testTag("vault_unlock_card"),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceContainerLow,
border =
BorderStroke(
width = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant,
),
) {
Column(
modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.vault_unlock_title, displayName),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
)
}
Text(
text = stringResource(R.string.vault_unlock_desc, displayName),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.vault_unlock_password_label)) },
modifier =
Modifier
.fillMaxWidth()
.testTag("vault_unlock_password_input"),
singleLine = true,
visualTransformation =
if (passwordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
keyboardOptions =
KeyboardOptions(
autoCorrectEnabled = false,
keyboardType = KeyboardType.Password,
),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector =
if (passwordVisible) {
Icons.Filled.VisibilityOff
} else {
Icons.Filled.Visibility
},
contentDescription = null,
)
}
},
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(
onClick = onDismiss,
modifier = Modifier.testTag("vault_unlock_dismiss"),
) {
Text(stringResource(R.string.vault_unlock_dismiss))
}
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = {
if (password.isNotBlank()) {
onConfirm(password)
}
},
enabled = password.isNotBlank(),
modifier = Modifier.testTag("vault_unlock_confirm"),
) {
Text(stringResource(R.string.vault_unlock_action))
}
}
}
}
}
@Composable
fun VaultSaveLoginCard(
prompt: VaultSaveLoginPromptUi,
onConfirm: (identifier: String, password: String) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
var identifier by remember(prompt.requestId) { mutableStateOf("") }
var password by remember(prompt.requestId) { mutableStateOf("") }
var passwordVisible by remember(prompt.requestId) { mutableStateOf(false) }
val siteLabel = prompt.site ?: prompt.origin ?: "this site"
Surface(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp)
.testTag("vault_save_login_card"),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceContainerLow,
border =
BorderStroke(
width = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant,
),
) {
Column(
modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.Key,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.vault_save_login_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
)
}
Text(
text = stringResource(R.string.vault_save_login_desc, siteLabel),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = identifier,
onValueChange = { identifier = it },
label = { Text(stringResource(R.string.vault_save_login_identifier)) },
modifier =
Modifier
.fillMaxWidth()
.testTag("vault_save_login_identifier_input"),
singleLine = true,
keyboardOptions =
KeyboardOptions(
autoCorrectEnabled = false,
keyboardType = KeyboardType.Email,
),
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.vault_save_login_password)) },
modifier =
Modifier
.fillMaxWidth()
.testTag("vault_save_login_password_input"),
singleLine = true,
visualTransformation =
if (passwordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
keyboardOptions =
KeyboardOptions(
autoCorrectEnabled = false,
keyboardType = KeyboardType.Password,
),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector =
if (passwordVisible) {
Icons.Filled.VisibilityOff
} else {
Icons.Filled.Visibility
},
contentDescription = null,
)
}
},
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(
onClick = onDismiss,
modifier = Modifier.testTag("vault_save_login_dismiss"),
) {
Text(stringResource(R.string.vault_save_login_dismiss))
}
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = {
if (identifier.isNotBlank() && password.isNotBlank()) {
onConfirm(identifier.trim(), password)
}
},
enabled = identifier.isNotBlank() && password.isNotBlank(),
modifier = Modifier.testTag("vault_save_login_confirm"),
) {
Text(stringResource(R.string.vault_save_login_action))
}
}
}
}
}
@Composable
fun VaultCodeCard(
prompt: VaultCodePromptUi,
onConfirm: (String) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
var code by remember(prompt.requestId) { mutableStateOf("") }
val siteLabel = prompt.site ?: "site"
val desc = prompt.hint?.takeIf { it.isNotBlank() } ?: stringResource(R.string.vault_code_desc, siteLabel)
Surface(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp)
.testTag("vault_code_card"),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceContainerLow,
border =
BorderStroke(
width = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant,
),
) {
Column(
modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.Pin,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.vault_code_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
)
}
Text(
text = desc,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = code,
onValueChange = { code = it },
label = { Text(stringResource(R.string.vault_code_label)) },
placeholder = { Text("123456") },
modifier =
Modifier
.fillMaxWidth()
.testTag("vault_code_input"),
singleLine = true,
keyboardOptions =
KeyboardOptions(
autoCorrectEnabled = false,
keyboardType = KeyboardType.Number,
),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(
onClick = onDismiss,
modifier = Modifier.testTag("vault_code_dismiss"),
) {
Text(stringResource(R.string.vault_code_dismiss))
}
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = {
if (code.isNotBlank()) {
onConfirm(code.trim())
}
},
enabled = code.isNotBlank(),
modifier = Modifier.testTag("vault_code_confirm"),
) {
Text(stringResource(R.string.vault_code_action))
}
}
}
}
}
@@ -28,9 +28,15 @@ import com.m57.hermescontrol.ui.chat.ChatViewModel
import com.m57.hermescontrol.ui.chat.ClarifyUi
import com.m57.hermescontrol.ui.chat.ImageViewerModel
import com.m57.hermescontrol.ui.chat.ToolCallDivider
import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi
import com.m57.hermescontrol.ui.chat.components.ChatScrollController
import com.m57.hermescontrol.ui.chat.components.ClarifyBubble
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
import com.m57.hermescontrol.ui.chat.components.VaultCodeCard
import com.m57.hermescontrol.ui.chat.components.VaultSaveLoginCard
import com.m57.hermescontrol.ui.chat.components.VaultUnlockCard
import com.m57.hermescontrol.ui.chat.toolCallMilestones
import com.m57.hermescontrol.ui.common.EmptyState
@@ -69,6 +75,15 @@ fun FullBleedChatList(
onRespondClarify: ((String) -> Unit)? = null,
onRespondClarifyBatch: ((Map<String, String>) -> Unit)? = null,
onDismissClarify: (() -> Unit)? = null,
vaultUnlockPrompt: VaultUnlockPromptUi? = null,
onRespondVaultUnlock: ((String) -> Unit)? = null,
onDismissVaultUnlock: (() -> Unit)? = null,
vaultSaveLoginPrompt: VaultSaveLoginPromptUi? = null,
onRespondVaultSaveLogin: ((String, String) -> Unit)? = null,
onDismissVaultSaveLogin: (() -> Unit)? = null,
vaultCodePrompt: VaultCodePromptUi? = null,
onRespondVaultCode: ((String) -> Unit)? = null,
onDismissVaultCode: (() -> Unit)? = null,
onSaveAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit = {},
savingAttachmentPath: String? = null,
openingAttachmentPath: String? = null,
@@ -294,6 +309,38 @@ fun FullBleedChatList(
)
}
}
if (vaultUnlockPrompt != null) {
item(key = "vault_unlock_card") {
VaultUnlockCard(
prompt = vaultUnlockPrompt,
onConfirm = { password -> onRespondVaultUnlock?.invoke(password) },
onDismiss = { onDismissVaultUnlock?.invoke() },
)
}
}
if (vaultSaveLoginPrompt != null) {
item(key = "vault_save_login_card") {
VaultSaveLoginCard(
prompt = vaultSaveLoginPrompt,
onConfirm = { identifier, password ->
onRespondVaultSaveLogin?.invoke(identifier, password)
},
onDismiss = { onDismissVaultSaveLogin?.invoke() },
)
}
}
if (vaultCodePrompt != null) {
item(key = "vault_code_card") {
VaultCodeCard(
prompt = vaultCodePrompt,
onConfirm = { code -> onRespondVaultCode?.invoke(code) },
onDismiss = { onDismissVaultCode?.invoke() },
)
}
}
}
}
}