feat(#1218): support backend-owned connector setup operations (#1229)

This commit is contained in:
m57 authored and GitHub committed 2026-09-22 20:20:44 +03:00
1 parent bf53905a93
commit f34292aa83
21 files changed
+1872 -7

No files matched your search

@@ -0,0 +1,269 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsMethods
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
sealed class ConnectionPendingAction {
abstract val opId: String
abstract val observedSeq: Long
data class Respond(
override val opId: String,
val target: String,
val approved: Boolean,
override val observedSeq: Long,
) : ConnectionPendingAction()
data class Continue(
override val opId: String,
override val observedSeq: Long,
) : ConnectionPendingAction()
data class Wake(
override val opId: String,
override val observedSeq: Long,
) : ConnectionPendingAction()
}
data class ConnectionOperationError(
val message: String,
val retryable: Boolean = true,
)
data class ConnectionOperationUiState(
val operation: ConnectionOperationSnapshot? = null,
val pendingAction: ConnectionPendingAction? = null,
val error: ConnectionOperationError? = null,
)
fun interface ConnectionOperationRequester {
suspend fun request(
method: String,
params: Map<String, Any>,
): Any?
}
internal class ConnectionResumeCheckpoint(
internal val generation: Long,
internal val operationId: String?,
internal val seq: Long?,
)
/** Session-scoped authoritative reducer for backend-owned connector operations. */
class ChatConnectionOperationDelegate(
private val requester: ConnectionOperationRequester,
) {
private val _state = MutableStateFlow(ConnectionOperationUiState())
val state: StateFlow<ConnectionOperationUiState> = _state.asStateFlow()
private var sessionId: String? = null
private var generation = 0L
private val settled = LinkedHashSet<String>()
@Synchronized
fun reset(newSessionId: String? = null) {
generation++
sessionId = newSessionId
settled.clear()
_state.value = ConnectionOperationUiState()
}
/** Rebinds the same stored chat to its current runtime session after create/resume/reconnect. */
@Synchronized
fun bindSession(newSessionId: String) {
if (newSessionId.isNotBlank()) sessionId = newSessionId
}
@Synchronized
internal fun resumeCheckpoint(): ConnectionResumeCheckpoint =
ConnectionResumeCheckpoint(
generation = generation,
operationId = _state.value.operation?.opId,
seq = _state.value.operation?.seq,
)
fun acceptRequest(snapshot: ConnectionOperationSnapshot): Boolean = accept(snapshot, allowReplacement = true)
fun acceptUpdate(snapshot: ConnectionOperationSnapshot): Boolean = accept(snapshot, allowReplacement = false)
@Synchronized
private fun accept(
snapshot: ConnectionOperationSnapshot,
allowReplacement: Boolean,
): Boolean {
if (!matchesSession(snapshot)) return false
if (settled.contains(snapshot.opId)) return false
val current = _state.value.operation
if (current != null && current.opId == snapshot.opId && snapshot.seq <= current.seq) return false
if (current != null && current.opId != snapshot.opId && !allowReplacement) return false
val pending = _state.value.pendingAction
val keepPending =
pending != null &&
pending.opId == snapshot.opId &&
snapshot.seq <= pending.observedSeq
_state.value =
ConnectionOperationUiState(
operation = snapshot,
pendingAction = pending.takeIf { keepPending },
)
if (snapshot.settled) markSettled(snapshot.opId)
return true
}
/** Applies a resume snapshot only if it cannot regress a live event received after resume began. */
@Synchronized
internal fun reconcileResume(
snapshot: ConnectionOperationSnapshot?,
checkpoint: ConnectionResumeCheckpoint,
): Boolean {
if (checkpoint.generation != generation) return false
val current = _state.value.operation
val unchanged = current?.opId == checkpoint.operationId && current?.seq == checkpoint.seq
if (snapshot == null) {
if (unchanged) {
_state.value = ConnectionOperationUiState()
return true
}
return false
}
if (!unchanged && current?.opId != snapshot.opId) return false
return accept(snapshot, allowReplacement = unchanged)
}
suspend fun respond(
target: String,
env: Map<String, String>,
approved: Boolean,
) {
val current = _state.value.operation ?: return
val targetSnapshot = current.targets.firstOrNull { it.name == target } ?: return
val safeEnv =
if (approved) {
val allowed = targetSnapshot.requiredEnv.mapTo(HashSet()) { it.name }
env.filterKeys(allowed::contains)
} else {
emptyMap()
}
val snapshot =
begin(
ConnectionPendingAction.Respond(
opId = current.opId,
target = target,
approved = approved,
observedSeq = current.seq,
),
) ?: return
val answer =
buildMap<String, Any> {
put("name", target)
put("status", if (approved) "approved" else "skipped")
if (safeEnv.isNotEmpty()) put("env", safeEnv)
}
dispatch(
method = WsMethods.CONNECTION_RESPOND,
params =
mapOf(
"session_id" to requireSessionId(),
"op_id" to snapshot.opId,
"result" to mapOf("targets" to listOf(answer)),
),
)
}
suspend fun continueOperation() {
val snapshot = begin(ConnectionPendingAction.Continue(currentOp(), currentSeq())) ?: return
dispatch(
method = WsMethods.CONNECTION_RESPOND,
params =
mapOf(
"session_id" to requireSessionId(),
"op_id" to snapshot.opId,
"result" to mapOf("settled_by" to "continue"),
),
)
}
suspend fun wake(expectedOpId: String) {
val current = _state.value.operation ?: return
if (current.opId != expectedOpId) return
val snapshot = begin(ConnectionPendingAction.Wake(current.opId, current.seq)) ?: return
dispatch(
method = WsMethods.CONNECTORS_OPERATION_WAKE,
params = mapOf("session_id" to requireSessionId(), "op_id" to snapshot.opId),
clearOnSuccess = true,
unknownOperationSettles = true,
)
}
private suspend fun dispatch(
method: String,
params: Map<String, Any>,
clearOnSuccess: Boolean = false,
unknownOperationSettles: Boolean = false,
) {
val action = _state.value.pendingAction
val actionGeneration = generation
try {
requester.request(method, params)
// Success only acknowledges receipt. Keep the exactly-once lock until
// a newer authoritative snapshot advances the operation sequence.
if (clearOnSuccess && generation == actionGeneration && _state.value.pendingAction == action) {
_state.value = _state.value.copy(pendingAction = null)
}
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
if (unknownOperationSettles && error is HermesWsClient.HermesRpcException && error.code == 4004) {
if (generation == actionGeneration && _state.value.pendingAction == action) {
action?.opId?.let(::markSettled)
}
} else if (generation == actionGeneration && _state.value.pendingAction == action) {
_state.value =
_state.value.copy(
pendingAction = null,
error = ConnectionOperationError("request_failed"),
)
}
}
}
@Synchronized
private fun begin(action: ConnectionPendingAction): ConnectionOperationSnapshot? {
val current = _state.value.operation ?: return null
if (sessionId.isNullOrBlank() ||
_state.value.pendingAction != null ||
current.opId != action.opId ||
current.seq != action.observedSeq
) {
return null
}
_state.value = _state.value.copy(pendingAction = action, error = null)
return current
}
private fun matchesSession(snapshot: ConnectionOperationSnapshot): Boolean =
!sessionId.isNullOrBlank() && snapshot.sessionId == sessionId
private fun requireSessionId(): String = checkNotNull(sessionId?.takeIf { it.isNotBlank() })
private fun currentOp(): String =
_state.value.operation
?.opId
.orEmpty()
private fun currentSeq(): Long = _state.value.operation?.seq ?: -1L
private fun markSettled(opId: String) {
settled += opId
while (settled.size > MAX_SETTLED_IDS) settled.remove(settled.first())
_state.value = ConnectionOperationUiState()
}
private companion object {
const val MAX_SETTLED_IDS = 32
}
}
@@ -96,6 +96,7 @@ import com.m57.hermescontrol.ui.chat.components.ChatResumeErrorOverlay
import com.m57.hermescontrol.ui.chat.components.ChatScrollToBottomFab
import com.m57.hermescontrol.ui.chat.components.ChatTimelineNoPrefetchStrategy
import com.m57.hermescontrol.ui.chat.components.ChatTimelineSheet
import com.m57.hermescontrol.ui.chat.components.ConnectionSetupSheet
import com.m57.hermescontrol.ui.chat.components.ContextDetailSheet
import com.m57.hermescontrol.ui.chat.components.ContextUsageChip
import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay
@@ -158,6 +159,7 @@ fun ChatScreen(
val connectorsViewModel: ChatConnectorsViewModel = viewModel()
val connectorsState by connectorsViewModel.uiState.collectAsStateWithLifecycle()
val actionProgressState by viewModel.actionProgress.state.collectAsStateWithLifecycle()
val connectionOperationState by viewModel.connectionOperationState.collectAsStateWithLifecycle()
// Snapshot-backed search state — read directly so only the scopes that
// read its fields recompose on search changes (bar, matched bubbles).
val searchState = viewModel.searchState
@@ -166,6 +168,8 @@ fun ChatScreen(
val context = LocalContext.current
var browserAuthInFlight by rememberSaveable { mutableStateOf(false) }
var browserAuthDeparted by rememberSaveable { mutableStateOf(false) }
var connectionBrowserOperationId by remember { mutableStateOf<String?>(null) }
var connectionBrowserDeparted by remember { mutableStateOf(false) }
DisposableEffect(lifecycleOwner) {
val observer =
@@ -175,15 +179,27 @@ fun ChatScreen(
if (browserAuthInFlight) {
browserAuthDeparted = true
}
if (connectionBrowserOperationId != null) {
connectionBrowserDeparted = true
}
connectorsViewModel.onPause()
}
Lifecycle.Event.ON_RESUME -> {
connectorsViewModel.onResume()
if (browserAuthInFlight && browserAuthDeparted) {
val legacyBrowserReturned = browserAuthInFlight && browserAuthDeparted
val operationId = connectionBrowserOperationId.takeIf { connectionBrowserDeparted }
if (legacyBrowserReturned || operationId != null) {
ExternalActivityLifecycleGuard.externalActivityReturned()
}
if (legacyBrowserReturned) {
browserAuthInFlight = false
browserAuthDeparted = false
ExternalActivityLifecycleGuard.externalActivityReturned()
}
if (operationId != null) {
connectionBrowserOperationId = null
connectionBrowserDeparted = false
viewModel.wakeConnectionOperation(operationId)
}
}
@@ -199,11 +215,18 @@ fun ChatScreen(
.firstOrNull()
val isChangingConfigs = activity?.isChangingConfigurations == true
if (!isChangingConfigs) {
val externalActivityOutstanding =
(browserAuthInFlight && browserAuthDeparted) ||
(connectionBrowserOperationId != null && connectionBrowserDeparted)
if (externalActivityOutstanding) {
ExternalActivityLifecycleGuard.externalActivityReturned()
}
if (browserAuthInFlight && browserAuthDeparted) {
browserAuthInFlight = false
browserAuthDeparted = false
ExternalActivityLifecycleGuard.externalActivityReturned()
}
connectionBrowserOperationId = null
connectionBrowserDeparted = false
connectorsViewModel.onPause()
connectorsViewModel.hide()
}
@@ -424,7 +447,8 @@ fun ChatScreen(
state.btwState == null &&
viewingImage == null &&
!connectorsState.isVisible &&
!timelineState.isOpen
!timelineState.isOpen &&
connectionOperationState.operation == null
// Lifecycle effects, permissions, session switching, auto-scroll, errors
ChatLifecycleEffects(
@@ -967,6 +991,34 @@ fun ChatScreen(
)
}
if (connectionOperationState.operation != null) {
ConnectionSetupSheet(
state = connectionOperationState,
onRespond = viewModel::respondToConnection,
onContinue = viewModel::continueConnectionOperation,
onOpenBrowser = { operationId, url ->
if (ConnectorUrlValidator.isValidHttpsUrl(url)) {
try {
launchExternalActivity {
connectionBrowserOperationId = operationId
connectionBrowserDeparted = false
val intent =
Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
} catch (_: Exception) {
connectionBrowserOperationId = null
connectionBrowserDeparted = false
scrollScope.launch { snackbarHostState.showSnackbar(browserLaunchError) }
}
}
},
onDismiss = viewModel::continueConnectionOperation,
)
}
SessionIntegrationsSheet(
uiState = connectorsState,
sessionId = connectorsState.sessionId,
@@ -25,6 +25,7 @@ import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
import com.m57.hermescontrol.data.ws.CommandBlocklist
import com.m57.hermescontrol.data.ws.CommandCatalog
import com.m57.hermescontrol.data.ws.ConnectionOperationParser
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent
@@ -401,8 +402,14 @@ class ChatViewModel(
) : AndroidViewModel(application) {
constructor(application: Application) : this(application, startCleanup = true)
private val connectionOperationDelegate =
ChatConnectionOperationDelegate { method, params ->
HermesWsClient.request(method, params).await()
}
// ── Internal state ───────────────────────────────────────────────────
private val _uiState = MutableStateFlow(ChatUiState())
val connectionOperationState: StateFlow<ConnectionOperationUiState> = connectionOperationDelegate.state
private val _streamingState = MutableStateFlow(StreamingState())
@@ -413,6 +420,7 @@ class ChatViewModel(
val generation: Long,
val resumeSequence: Long = 0L,
val sessionId: String? = null,
val connectionCheckpoint: ConnectionResumeCheckpoint? = null,
)
private val sessionRequestById = ConcurrentHashMap<String, SessionRequest>()
@@ -842,6 +850,14 @@ class ChatViewModel(
return
}
if (event is WsEvent.ConnectionRequest) {
if (!isCurrentSession(event.snapshot.sessionId)) return
connectionOperationDelegate.acceptRequest(event.snapshot)
} else if (event is WsEvent.ConnectionUpdate) {
if (!isCurrentSession(event.snapshot.sessionId)) return
connectionOperationDelegate.acceptUpdate(event.snapshot)
}
// Flush any throttled reasoning before a state transition so the
// finalized/orphan message carries the latest reasoning text.
when (event) {
@@ -1272,7 +1288,18 @@ class ChatViewModel(
}
}
@Suppress("UNCHECKED_CAST")
private fun handleSessionInfo(info: Map<String, Any?>?) {
// session.resume/session.info carries the open operation so a mobile client
// that missed connection.request can reconstruct the backend-authoritative card.
val pendingConnection = info?.get("pending_connection") as? Map<String, Any?>
if (pendingConnection != null) {
ConnectionOperationParser
.parse(
pendingConnection,
runtimeSessionId ?: _uiState.value.currentSessionId,
)?.let(connectionOperationDelegate::acceptRequest)
}
// Session info pushed by backend when config changes
// (model switch, reasoning level, etc.)
if (info != null) {
@@ -1385,6 +1412,7 @@ class ChatViewModel(
}
val storageId = resultMap["stored_session_id"] as? String ?: runtimeId
runtimeSessionId = runtimeId
connectionOperationDelegate.bindSession(runtimeId)
// The gateway persists the row lazily on the first prompt —
// do not resume this key until presence is confirmed.
sessionHasServerPresence = false
@@ -1449,6 +1477,7 @@ class ChatViewModel(
isLoading = false,
)
runtimeSessionId = runtimeId
connectionOperationDelegate.bindSession(runtimeId)
resumedGeneration = generation
ActiveSessionHolder.set(runtimeId, storageId)
sessionHasServerPresence = false
@@ -1488,6 +1517,7 @@ class ChatViewModel(
return
}
runtimeSessionId = runtimeId
connectionOperationDelegate.bindSession(runtimeId)
// Resume succeeded — the gateway confirmed the DB row.
sessionHasServerPresence = true
val sessionId =
@@ -1577,6 +1607,16 @@ class ChatViewModel(
)
}
val activeSessionId = runtimeSessionId ?: sessionId
// Reconnect replay: the backend-owned connector operation is
// authoritative and uses the same full snapshot as the live
// connection.request event. Feed it through the seq guard so
// a late resume response cannot regress a newer live update.
val pendingConnection = resultMap["pending_connection"] as? Map<String, Any?>
val pendingSnapshot =
pendingConnection?.let { ConnectionOperationParser.parse(it, activeSessionId) }
request?.connectionCheckpoint?.let { checkpoint ->
connectionOperationDelegate.reconcileResume(pendingSnapshot, checkpoint)
}
if (activeSessionId != null) approvalsDelegate.replayPendingApproval(activeSessionId)
}
@@ -3436,6 +3476,7 @@ class ChatViewModel(
isLoading: Boolean,
): Long {
val generation = ++sessionGeneration
connectionOperationDelegate.reset()
cancelResumeRetry()
contextUsageJob?.cancel()
contextUsageJob = null
@@ -3519,6 +3560,7 @@ class ChatViewModel(
_uiState.update { it.copy(isSessionReady = false) }
val requestSequence = ++resumeRequestSequence
activeResumeRequestSequence = requestSequence
val connectionCheckpoint = connectionOperationDelegate.resumeCheckpoint()
val profile = AuthManager.activeProfileId.value
val params =
mutableMapOf<String, Any>(
@@ -3539,6 +3581,7 @@ class ChatViewModel(
generation = generation,
resumeSequence = requestSequence,
sessionId = sessionId,
connectionCheckpoint = connectionCheckpoint,
)
},
)
@@ -4432,8 +4475,15 @@ class ChatViewModel(
generation: Long,
resumeSequence: Long = 0L,
sessionId: String? = null,
connectionCheckpoint: ConnectionResumeCheckpoint? = null,
) {
sessionRequestById[id] = SessionRequest(generation, resumeSequence, sessionId)
sessionRequestById[id] =
SessionRequest(
generation = generation,
resumeSequence = resumeSequence,
sessionId = sessionId,
connectionCheckpoint = connectionCheckpoint,
)
trackRequest(id, method)
}
@@ -4492,6 +4542,22 @@ class ChatViewModel(
// ── Lifecycle ─────────────────────────────────────────────────────────
fun respondToConnection(
target: String,
env: Map<String, String>,
approved: Boolean,
) {
viewModelScope.launch { connectionOperationDelegate.respond(target, env, approved) }
}
fun continueConnectionOperation() {
viewModelScope.launch { connectionOperationDelegate.continueOperation() }
}
fun wakeConnectionOperation(operationId: String) {
viewModelScope.launch { connectionOperationDelegate.wake(operationId) }
}
override fun onCleared() {
super.onCleared()
subagentsDelegate.closeSubagentTranscript()
@@ -176,6 +176,10 @@ object ChatWsEventReducer {
is WsEvent.StatusUpdate -> onStatusUpdate(state, streamingState)
is WsEvent.ConnectionRequest,
is WsEvent.ConnectionUpdate,
-> ReducerResult(state, streamingState)
is WsEvent.Unknown -> onUnknown(state, streamingState)
// SessionInfo is a no-op in the original code
@@ -0,0 +1,293 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
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.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.ConnectionOperationTarget
import com.m57.hermescontrol.data.model.ConnectionTargetState
import com.m57.hermescontrol.ui.chat.ConnectionOperationUiState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConnectionSetupSheet(
state: ConnectionOperationUiState,
onRespond: (String, Map<String, String>, Boolean) -> Unit,
onContinue: () -> Unit,
onOpenBrowser: (operationId: String, url: String) -> Unit,
onDismiss: () -> Unit,
) {
if (state.operation == null) return
ModalBottomSheet(
onDismissRequest = {
if (state.pendingAction == null) onDismiss()
},
modifier = Modifier.testTag("connection_setup_sheet"),
) {
ConnectionSetupContent(
state = state,
onRespond = onRespond,
onContinue = onContinue,
onOpenBrowser = onOpenBrowser,
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
internal fun ConnectionSetupContent(
state: ConnectionOperationUiState,
onRespond: (String, Map<String, String>, Boolean) -> Unit,
onContinue: () -> Unit,
onOpenBrowser: (operationId: String, url: String) -> Unit,
modifier: Modifier = Modifier,
) {
val operation = state.operation ?: return
val target =
operation.targets.firstOrNull { !isTerminalTarget(it) }
?: operation.targets.firstOrNull { !it.discoveryError.isNullOrBlank() }
?: operation.targets.lastOrNull()
?: return
val targetIndex = operation.targets.indexOf(target)
val values =
remember(operation.opId, target.name) {
mutableStateMapOf<String, String>().apply {
target.requiredEnv.forEach { field ->
this[field.name] = if (field.secret) "" else field.defaultValue.orEmpty()
}
}
}
LaunchedEffect(target.requiredEnv) {
val currentNames = target.requiredEnv.mapTo(HashSet()) { it.name }
values.keys
.toList()
.filterNot(currentNames::contains)
.forEach(values::remove)
target.requiredEnv.forEach { field ->
values.putIfAbsent(field.name, if (field.secret) "" else field.defaultValue.orEmpty())
}
}
val missingRequired = target.requiredEnv.firstOrNull { it.required && values[it.name].orEmpty().isBlank() }
val pending = state.pendingAction != null
val safeUrl = target.safeConnectUrl
Column(
modifier =
modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = stringResource(R.string.connection_setup_target_title, target.name),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.testTag("connection_setup_title"),
)
Text(
text =
stringResource(
R.string.connection_setup_progress,
targetIndex + 1,
operation.targets.size,
),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
target.instructions?.takeIf { it.isNotBlank() }?.let { instructions ->
Text(instructions, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
when {
target.state == ConnectionTargetState.CONNECTED && !target.discoveryError.isNullOrBlank() -> {
Text(
text = stringResource(R.string.connection_setup_authorized_no_tools),
style = MaterialTheme.typography.titleMedium,
)
Text(target.discoveryError, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
isTerminalTarget(target) -> {
Text(
text = stringResource(R.string.connection_setup_target_resolved),
style = MaterialTheme.typography.titleMedium,
)
if (target.tools.isNotEmpty()) {
Text(
text = stringResource(R.string.connection_setup_tools_available, target.tools.size),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
target.detail?.takeIf { it.isNotBlank() }?.let { detail ->
Text(detail, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
target.state == ConnectionTargetState.INITIATED && safeUrl != null -> {
target.detail?.takeIf { it.isNotBlank() }?.let { detail ->
Text(detail, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Text(
text = safeUrl,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.testTag("connection_setup_browser_url"),
)
OutlinedButton(
onClick = { onOpenBrowser(operation.opId, safeUrl) },
enabled = !pending,
modifier =
Modifier
.fillMaxWidth()
.testTag("connection_setup_open_browser"),
) {
Text(stringResource(R.string.connection_setup_open_browser))
}
}
target.state == ConnectionTargetState.UNKNOWN ||
(target.state == ConnectionTargetState.INITIATED && target.connectUrl != null) -> {
Text(
text = stringResource(R.string.connection_setup_unsupported),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
else -> {
target.requiredEnv.forEach { field ->
OutlinedTextField(
value = values[field.name].orEmpty(),
onValueChange = { values[field.name] = it },
enabled = !pending,
label = { Text(field.prompt ?: field.name) },
visualTransformation =
if (field.secret) PasswordVisualTransformation() else VisualTransformation.None,
singleLine = true,
modifier =
Modifier
.fillMaxWidth()
.testTag("connection_env_${field.name}"),
)
}
target.detail?.takeIf { it.isNotBlank() }?.let { detail ->
Text(
text = detail,
color =
if (target.state == ConnectionTargetState.FAILED) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
missingRequired?.let { field ->
Text(
text = stringResource(R.string.connection_setup_required, field.prompt ?: field.name),
color = MaterialTheme.colorScheme.error,
modifier = Modifier.testTag("connection_setup_validation"),
)
}
Button(
onClick = {
val env = target.requiredEnv.associate { field -> field.name to values[field.name].orEmpty() }
onRespond(target.name, env, true)
},
enabled = !pending && missingRequired == null && target.state != ConnectionTargetState.UNKNOWN,
modifier =
Modifier
.fillMaxWidth()
.testTag("connection_setup_connect"),
) {
Text(stringResource(R.string.connection_setup_connect))
}
}
}
state.error?.let {
Text(
text = stringResource(R.string.connection_setup_request_failed),
color = MaterialTheme.colorScheme.error,
modifier = Modifier.testTag("connection_setup_error"),
)
}
if (pending) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.testTag("connection_setup_pending"),
) {
CircularProgressIndicator()
Text(stringResource(R.string.connection_setup_pending))
}
}
ConnectionSetupActions(
target = target,
pending = pending,
onSkip = { onRespond(target.name, emptyMap(), false) },
onContinue = onContinue,
)
}
}
@Composable
private fun ConnectionSetupActions(
target: ConnectionOperationTarget,
pending: Boolean,
onSkip: () -> Unit,
onContinue: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (!isTerminalTarget(target)) {
OutlinedButton(
onClick = onSkip,
enabled = !pending,
modifier =
Modifier
.weight(1f)
.testTag("connection_setup_skip"),
) {
Text(stringResource(R.string.connection_setup_skip))
}
}
OutlinedButton(
onClick = onContinue,
enabled = !pending,
modifier =
Modifier
.weight(1f)
.testTag("connection_setup_continue"),
) {
Text(stringResource(R.string.connection_setup_continue))
}
}
}
private fun isTerminalTarget(target: ConnectionOperationTarget): Boolean =
target.state == ConnectionTargetState.CONNECTED ||
target.state == ConnectionTargetState.SKIPPED ||
target.state == ConnectionTargetState.EXPIRED ||
target.state == ConnectionTargetState.UNAVAILABLE