This commit is contained in:
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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user