21 files changed
+1872
-7
No files matched your search
@@ -0,0 +1,75 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import com.m57.hermescontrol.util.ConnectorUrlValidator
|
||||
|
||||
/** Backend-owned connector setup operation snapshot. Secrets are intentionally absent. */
|
||||
data class ConnectionOperationSnapshot(
|
||||
val sessionId: String?,
|
||||
val opId: String,
|
||||
val seq: Long,
|
||||
val deadlineAt: Double,
|
||||
val timeoutSeconds: Double?,
|
||||
val toolCallId: String?,
|
||||
val settled: Boolean,
|
||||
val settledBy: String?,
|
||||
val targets: List<ConnectionOperationTarget>,
|
||||
)
|
||||
|
||||
data class ConnectionOperationTarget(
|
||||
val name: String,
|
||||
val kind: ConnectionTargetKind,
|
||||
val action: ConnectionTargetAction,
|
||||
val state: ConnectionTargetState,
|
||||
val detail: String?,
|
||||
val instructions: String?,
|
||||
val discoveryError: String?,
|
||||
val connectUrl: String?,
|
||||
val connectionId: String?,
|
||||
val attempt: String?,
|
||||
val requiredEnv: List<ConnectionEnvField>,
|
||||
val tools: List<String>,
|
||||
val hint: String?,
|
||||
) {
|
||||
/** Safe validated browser URL. OAuth query parameters stay redacted from [toString]. */
|
||||
val safeConnectUrl: String?
|
||||
get() = connectUrl?.takeIf(ConnectorUrlValidator::isValidHttpsUrl)
|
||||
|
||||
override fun toString(): String =
|
||||
"ConnectionOperationTarget(" +
|
||||
"name=$name, kind=$kind, action=$action, state=$state, detail=$detail, " +
|
||||
"instructions=$instructions, discoveryError=$discoveryError, " +
|
||||
"connectUrl=${if (connectUrl == null) "null" else "[REDACTED]"}, " +
|
||||
"connectionId=$connectionId, attempt=$attempt, requiredEnv=$requiredEnv, " +
|
||||
"tools=$tools, hint=$hint)"
|
||||
}
|
||||
|
||||
data class ConnectionEnvField(
|
||||
val name: String,
|
||||
val required: Boolean,
|
||||
val secret: Boolean,
|
||||
val defaultValue: String?,
|
||||
val prompt: String?,
|
||||
)
|
||||
|
||||
enum class ConnectionTargetKind { CONNECTOR, MCP, UNKNOWN }
|
||||
|
||||
enum class ConnectionTargetAction {
|
||||
AUTHORIZE,
|
||||
CONNECT,
|
||||
ENABLE,
|
||||
INSTALL,
|
||||
RECONNECT,
|
||||
UNKNOWN,
|
||||
}
|
||||
|
||||
enum class ConnectionTargetState {
|
||||
PENDING,
|
||||
INITIATED,
|
||||
CONNECTED,
|
||||
SKIPPED,
|
||||
FAILED,
|
||||
EXPIRED,
|
||||
UNAVAILABLE,
|
||||
NOT_CONNECTED,
|
||||
UNKNOWN,
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.model.ConnectionEnvField
|
||||
import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot
|
||||
import com.m57.hermescontrol.data.model.ConnectionOperationTarget
|
||||
import com.m57.hermescontrol.data.model.ConnectionTargetAction
|
||||
import com.m57.hermescontrol.data.model.ConnectionTargetKind
|
||||
import com.m57.hermescontrol.data.model.ConnectionTargetState
|
||||
|
||||
/** Tolerant parser for backend-owned connector operation snapshots. */
|
||||
object ConnectionOperationParser {
|
||||
fun parse(
|
||||
payload: Map<String, Any?>,
|
||||
sessionId: String? = null,
|
||||
): ConnectionOperationSnapshot? {
|
||||
val opId = (payload["op_id"] as? String)?.trim() ?: return null
|
||||
val seq = payload["seq"].toLongOrNull() ?: return null
|
||||
val deadlineAt = payload["deadline_at"].toDoubleOrNull() ?: return null
|
||||
val targets =
|
||||
(payload["targets"] as? List<*>)
|
||||
?.mapNotNull { parseTarget(it as? Map<*, *>) }
|
||||
?: return null
|
||||
if (opId.isBlank() || seq < 0L || !deadlineAt.isFinite() || deadlineAt <= 0.0 || targets.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
return ConnectionOperationSnapshot(
|
||||
sessionId = (sessionId ?: payload["session_id"] as? String)?.takeIf { it.isNotBlank() },
|
||||
opId = opId,
|
||||
seq = seq,
|
||||
deadlineAt = deadlineAt,
|
||||
timeoutSeconds =
|
||||
payload["timeout_seconds"]
|
||||
.toDoubleOrNull()
|
||||
?.takeIf { it.isFinite() && it > 0.0 },
|
||||
toolCallId = payload["tool_call_id"] as? String,
|
||||
settled = payload["settled"] as? Boolean ?: false,
|
||||
settledBy = payload["settled_by"] as? String,
|
||||
targets = targets,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseTarget(raw: Map<*, *>?): ConnectionOperationTarget? {
|
||||
val map = raw ?: return null
|
||||
val name = (map["name"] as? String)?.trim() ?: return null
|
||||
if (name.isBlank()) return null
|
||||
return ConnectionOperationTarget(
|
||||
name = name,
|
||||
kind =
|
||||
enumValue(
|
||||
map["kind"] as? String,
|
||||
ConnectionTargetKind.values(),
|
||||
ConnectionTargetKind.UNKNOWN,
|
||||
),
|
||||
action =
|
||||
enumValue(
|
||||
map["action"] as? String,
|
||||
ConnectionTargetAction.values(),
|
||||
ConnectionTargetAction.UNKNOWN,
|
||||
),
|
||||
state =
|
||||
enumValue(
|
||||
map["state"] as? String,
|
||||
ConnectionTargetState.values(),
|
||||
ConnectionTargetState.UNKNOWN,
|
||||
),
|
||||
detail = map["detail"] as? String,
|
||||
instructions = map["instructions"] as? String,
|
||||
discoveryError = map["discovery_error"] as? String,
|
||||
connectUrl = map["connect_url"] as? String,
|
||||
connectionId = map["connection_id"] as? String,
|
||||
attempt = map["attempt"] as? String,
|
||||
requiredEnv =
|
||||
(map["required_env"] as? List<*>)
|
||||
?.mapNotNull { parseEnv(it as? Map<*, *>) }
|
||||
?: emptyList(),
|
||||
tools = (map["tools"] as? List<*>)?.filterIsInstance<String>() ?: emptyList(),
|
||||
hint = map["hint"] as? String,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseEnv(raw: Map<*, *>?): ConnectionEnvField? {
|
||||
val map = raw ?: return null
|
||||
val name = (map["name"] as? String)?.trim() ?: return null
|
||||
if (name.isBlank()) return null
|
||||
val secret = map["secret"] as? Boolean ?: false
|
||||
return ConnectionEnvField(
|
||||
name = name,
|
||||
required = map["required"] as? Boolean ?: false,
|
||||
secret = secret,
|
||||
defaultValue = if (secret) null else map["default"] as? String,
|
||||
prompt = map["prompt"] as? String,
|
||||
)
|
||||
}
|
||||
|
||||
private inline fun <reified T : Enum<T>> enumValue(
|
||||
raw: String?,
|
||||
values: Array<T>,
|
||||
fallback: T,
|
||||
): T = values.firstOrNull { it.name.equals(raw, ignoreCase = true) } ?: fallback
|
||||
|
||||
private fun Any?.toLongOrNull(): Long? =
|
||||
when (this) {
|
||||
is Number -> this.toLong().takeIf { this.toDouble() == it.toDouble() }
|
||||
is String -> this.toLongOrNull()
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun Any?.toDoubleOrNull(): Double? =
|
||||
when (this) {
|
||||
is Number -> this.toDouble()
|
||||
is String -> this.toDoubleOrNull()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,19 @@ object EventParser {
|
||||
val sessionId = params["session_id"] as? String ?: payload?.get("session_id") as? String
|
||||
|
||||
return when (eventType) {
|
||||
"connection.request", "connection.update" -> {
|
||||
val snapshot = payload?.let { ConnectionOperationParser.parse(it, sessionId) }
|
||||
// Operation payloads may carry credential defaults and OAuth URLs.
|
||||
// Never retain the raw frame when a malformed operation degrades to Unknown.
|
||||
if (snapshot == null) {
|
||||
WsEvent.Unknown("")
|
||||
} else if (eventType == "connection.request") {
|
||||
WsEvent.ConnectionRequest(snapshot)
|
||||
} else {
|
||||
WsEvent.ConnectionUpdate(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
"gateway.ready" -> {
|
||||
WsEvent.GatewayReady(payload)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot
|
||||
import com.m57.hermescontrol.ui.chat.TodoItem
|
||||
|
||||
/** Parsed WebSocket events emitted by [HermesWsClient]. */
|
||||
@@ -191,6 +192,14 @@ sealed class WsEvent {
|
||||
val sessionId: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
data class ConnectionRequest(
|
||||
val snapshot: ConnectionOperationSnapshot,
|
||||
) : WsEvent()
|
||||
|
||||
data class ConnectionUpdate(
|
||||
val snapshot: ConnectionOperationSnapshot,
|
||||
) : WsEvent()
|
||||
|
||||
// ── Status ───────────────────────────────────────────────────────────
|
||||
|
||||
data class StatusUpdate(
|
||||
|
||||
@@ -101,6 +101,9 @@ object WsMethods {
|
||||
|
||||
// ── Connectors (issue #1091) ───────────────────────────────────────────
|
||||
|
||||
const val CONNECTION_RESPOND = "connection.respond"
|
||||
const val CONNECTORS_OPERATION_WAKE = "connectors.operation.wake"
|
||||
|
||||
/** List available third-party connectors and authorization state for a session. */
|
||||
const val CONNECTORS_LIST = "connectors.list"
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user