feat(android): add Skill Workshop settings panel (#101911)

* feat(android): add Skill Workshop settings

* fix(android): scope Skill Workshop state by agent

* fix(android): confirm Skill Workshop proposal actions

* fix(android): strengthen Skill Workshop action proof gates

* fix(android): satisfy Skill Workshop action ktlint

* fix(android): serialize Skill Workshop lifecycle actions

* chore(android): refresh native i18n inventory

---------

Co-authored-by: snowzlmbot <293528334+snowzlmbot@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
snowzlmbot 2026-07-09 19:07:04 +08:00 committed by GitHub
parent c9ec548e6f
commit 6fef79a28d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 2152 additions and 216 deletions

File diff suppressed because it is too large Load diff

View file

@ -18,6 +18,7 @@ OpenClaw Android is the officially released Google Play app. It connects to an O
- [x] Authenticated background presence beacons
- [x] Voice tab full functionality
- [x] Screen tab full functionality
- [x] Skill Workshop settings can filter proposals, inspect proposal content, and apply/reject/quarantine drafts through Gateway RPCs
## Open in Android Studio

View file

@ -158,6 +158,7 @@ class MainViewModel(
val gatewayConnectionProblem: StateFlow<GatewayConnectionProblem?> = runtimeState(initial = null) { it.gatewayConnectionProblem }
val gatewayConnectionDisplay: StateFlow<GatewayConnectionDisplay> =
runtimeState(initial = GatewayConnectionDisplay(false, "Offline", null)) { it.gatewayConnectionDisplay }
val operatorAdminScopeAvailable: StateFlow<Boolean> = runtimeState(initial = false) { it.operatorAdminScopeAvailable }
val serverName: StateFlow<String?> = runtimeState(initial = null) { it.serverName }
val remoteAddress: StateFlow<String?> = runtimeState(initial = null) { it.remoteAddress }
val gatewayVersion: StateFlow<String?> = runtimeState(initial = null) { it.gatewayVersion }
@ -184,6 +185,13 @@ class MainViewModel(
val skillsSummary: StateFlow<GatewaySkillsSummary> = runtimeState(initial = GatewaySkillsSummary(skills = emptyList())) { it.skillsSummary }
val skillsRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.skillsRefreshing }
val skillsErrorText: StateFlow<String?> = runtimeState(initial = null) { it.skillsErrorText }
val skillWorkshopSummary: StateFlow<GatewaySkillWorkshopSummary> =
runtimeState(initial = GatewaySkillWorkshopSummary(proposals = emptyList())) { it.skillWorkshopSummary }
val skillWorkshopRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.skillWorkshopRefreshing }
val skillWorkshopErrorText: StateFlow<String?> = runtimeState(initial = null) { it.skillWorkshopErrorText }
val skillWorkshopNoticeText: StateFlow<String?> = runtimeState(initial = null) { it.skillWorkshopNoticeText }
val skillWorkshopInspectingProposalId: StateFlow<String?> = runtimeState(initial = null) { it.skillWorkshopInspectingProposalId }
val skillWorkshopMutatingProposalId: StateFlow<String?> = runtimeState(initial = null) { it.skillWorkshopMutatingProposalId }
val nodesDevicesSummary: StateFlow<GatewayNodesDevicesSummary> =
runtimeState(initial = GatewayNodesDevicesSummary(nodes = emptyList(), pendingDevices = emptyList(), pairedDevices = emptyList())) { it.nodesDevicesSummary }
val nodesDevicesRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.nodesDevicesRefreshing }
@ -711,6 +719,46 @@ class MainViewModel(
ensureRuntime().refreshSkills()
}
fun refreshSkillWorkshopProposals(agentId: String? = null) {
ensureRuntime().refreshSkillWorkshopProposals(agentId = agentId)
}
fun resetSkillWorkshopAgentScope(agentId: String? = null) {
ensureRuntime().resetSkillWorkshopAgentScope(agentId = agentId)
}
fun inspectSkillWorkshopProposal(
proposalId: String,
agentId: String? = null,
) {
ensureRuntime().inspectSkillWorkshopProposal(proposalId = proposalId, agentId = agentId)
}
fun applySkillWorkshopProposal(
proposalId: String,
agentId: String? = null,
) {
ensureRuntime().applySkillWorkshopProposal(proposalId = proposalId, agentId = agentId)
}
fun rejectSkillWorkshopProposal(
proposalId: String,
agentId: String? = null,
) {
ensureRuntime().rejectSkillWorkshopProposal(proposalId = proposalId, agentId = agentId)
}
fun quarantineSkillWorkshopProposal(
proposalId: String,
agentId: String? = null,
) {
ensureRuntime().quarantineSkillWorkshopProposal(proposalId = proposalId, agentId = agentId)
}
fun clearSkillWorkshopMessage() {
ensureRuntime().clearSkillWorkshopMessage()
}
fun refreshNodesDevices() {
ensureRuntime().refreshNodesDevices()
}

View file

@ -89,10 +89,13 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@ -113,6 +116,18 @@ import java.util.concurrent.atomic.AtomicReference
private const val MAX_PENDING_NOTIFICATION_EVENTS = 128
private const val NODE_APPROVAL_COMMAND_FRESH_MS = 30_000L
private const val OperatorAdminScope = "operator.admin"
private enum class SkillWorkshopGatewayAction(
val methodSuffix: String,
val expectedStatus: String,
val notice: String,
val verb: String,
) {
Apply("apply", "applied", "Proposal applied.", "apply"),
Reject("reject", "rejected", "Proposal rejected.", "reject"),
Quarantine("quarantine", "quarantined", "Proposal quarantined.", "quarantine"),
}
internal data class PendingNotificationNodeEvent(
val event: String,
@ -626,6 +641,12 @@ class NodeRuntime private constructor(
val statusText: StateFlow<String> = _statusText.asStateFlow()
private val _gatewayConnectionProblem = MutableStateFlow<GatewayConnectionProblem?>(null)
val gatewayConnectionProblem: StateFlow<GatewayConnectionProblem?> = _gatewayConnectionProblem.asStateFlow()
private val _operatorScopes = MutableStateFlow<List<String>>(emptyList())
val operatorScopes: StateFlow<List<String>> = _operatorScopes.asStateFlow()
val operatorAdminScopeAvailable: StateFlow<Boolean> =
operatorScopes
.map { scopes -> scopes.any { it == OperatorAdminScope } }
.stateIn(scope, SharingStarted.Eagerly, false)
private val _pendingGatewayTrust = MutableStateFlow<GatewayTrustPrompt?>(null)
val pendingGatewayTrust: StateFlow<GatewayTrustPrompt?> = _pendingGatewayTrust.asStateFlow()
@ -708,6 +729,21 @@ class NodeRuntime private constructor(
val skillsRefreshing: StateFlow<Boolean> = _skillsRefreshing.asStateFlow()
private val _skillsErrorText = MutableStateFlow<String?>(null)
val skillsErrorText: StateFlow<String?> = _skillsErrorText.asStateFlow()
private val _skillWorkshopSummary = MutableStateFlow(GatewaySkillWorkshopSummary(proposals = emptyList()))
val skillWorkshopSummary: StateFlow<GatewaySkillWorkshopSummary> = _skillWorkshopSummary.asStateFlow()
private val _skillWorkshopRefreshing = MutableStateFlow(false)
val skillWorkshopRefreshing: StateFlow<Boolean> = _skillWorkshopRefreshing.asStateFlow()
private val _skillWorkshopErrorText = MutableStateFlow<String?>(null)
val skillWorkshopErrorText: StateFlow<String?> = _skillWorkshopErrorText.asStateFlow()
private val _skillWorkshopNoticeText = MutableStateFlow<String?>(null)
val skillWorkshopNoticeText: StateFlow<String?> = _skillWorkshopNoticeText.asStateFlow()
private val _skillWorkshopInspectingProposalId = MutableStateFlow<String?>(null)
val skillWorkshopInspectingProposalId: StateFlow<String?> = _skillWorkshopInspectingProposalId.asStateFlow()
private val _skillWorkshopMutatingProposalId = MutableStateFlow<String?>(null)
val skillWorkshopMutatingProposalId: StateFlow<String?> = _skillWorkshopMutatingProposalId.asStateFlow()
private val skillWorkshopListSeq = AtomicLong(0)
private val skillWorkshopInspectSeq = AtomicLong(0)
private val skillWorkshopMutationSeq = AtomicLong(0)
private val _nodesDevicesSummary =
MutableStateFlow(
GatewayNodesDevicesSummary(
@ -791,6 +827,7 @@ class NodeRuntime private constructor(
_remoteAddress.value = hello.remoteAddress
_gatewayVersion.value = hello.serverVersion
_gatewayUpdateAvailable.value = hello.updateAvailable
_operatorScopes.value = normalizeOperatorScopes(hello.authScopes)
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
syncMainSessionKey(resolveAgentIdFromMainSessionKey(hello.mainSessionKey))
// Every successful connection refreshes history; reconnects preserve local run ownership.
@ -839,6 +876,7 @@ class NodeRuntime private constructor(
_remoteAddress.value = null
_gatewayVersion.value = null
_gatewayUpdateAvailable.value = null
_operatorScopes.value = emptyList()
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
_gatewayDefaultAgentId.value = null
_gatewayAgents.value = emptyList()
@ -859,6 +897,15 @@ class NodeRuntime private constructor(
_skillsSummary.value = GatewaySkillsSummary(skills = emptyList())
_skillsRefreshing.value = false
_skillsErrorText.value = null
_skillWorkshopSummary.value = GatewaySkillWorkshopSummary(proposals = emptyList())
_skillWorkshopRefreshing.value = false
_skillWorkshopErrorText.value = null
_skillWorkshopNoticeText.value = null
_skillWorkshopInspectingProposalId.value = null
_skillWorkshopMutatingProposalId.value = null
skillWorkshopListSeq.incrementAndGet()
skillWorkshopInspectSeq.incrementAndGet()
skillWorkshopMutationSeq.incrementAndGet()
_nodesDevicesSummary.value =
GatewayNodesDevicesSummary(
nodes = emptyList(),
@ -1377,6 +1424,74 @@ class NodeRuntime private constructor(
}
}
fun refreshSkillWorkshopProposals(agentId: String? = null) {
scope.launch {
refreshSkillWorkshopProposalsFromGateway(agentId = agentId)
}
}
fun resetSkillWorkshopAgentScope(agentId: String? = null) {
val normalizedAgentId = normalizeSkillWorkshopAgentId(agentId)
skillWorkshopListSeq.incrementAndGet()
skillWorkshopInspectSeq.incrementAndGet()
skillWorkshopMutationSeq.incrementAndGet()
_skillWorkshopSummary.value = GatewaySkillWorkshopSummary(agentId = normalizedAgentId, proposals = emptyList())
_skillWorkshopRefreshing.value = false
_skillWorkshopErrorText.value = null
_skillWorkshopNoticeText.value = null
_skillWorkshopInspectingProposalId.value = null
_skillWorkshopMutatingProposalId.value = null
}
fun inspectSkillWorkshopProposal(
proposalId: String,
agentId: String? = null,
) {
val normalized = proposalId.trim()
if (normalized.isEmpty()) return
scope.launch {
inspectSkillWorkshopProposalFromGateway(proposalId = normalized, agentId = agentId)
}
}
fun applySkillWorkshopProposal(
proposalId: String,
agentId: String? = null,
) {
mutateSkillWorkshopProposal(proposalId = proposalId, agentId = agentId, action = SkillWorkshopGatewayAction.Apply)
}
fun rejectSkillWorkshopProposal(
proposalId: String,
agentId: String? = null,
) {
mutateSkillWorkshopProposal(proposalId = proposalId, agentId = agentId, action = SkillWorkshopGatewayAction.Reject)
}
fun quarantineSkillWorkshopProposal(
proposalId: String,
agentId: String? = null,
) {
mutateSkillWorkshopProposal(proposalId = proposalId, agentId = agentId, action = SkillWorkshopGatewayAction.Quarantine)
}
private fun mutateSkillWorkshopProposal(
proposalId: String,
agentId: String?,
action: SkillWorkshopGatewayAction,
) {
val normalized = proposalId.trim()
if (normalized.isEmpty()) return
scope.launch {
mutateSkillWorkshopProposalOnGateway(proposalId = normalized, agentId = agentId, action = action)
}
}
fun clearSkillWorkshopMessage() {
_skillWorkshopErrorText.value = null
_skillWorkshopNoticeText.value = null
}
fun refreshNodesDevices() {
if (mode == NodeRuntimeMode.ScreenshotFixture) return
scope.launch {
@ -3602,6 +3717,246 @@ class NodeRuntime private constructor(
}
}
private suspend fun refreshSkillWorkshopProposalsFromGateway(agentId: String?) {
val listSeq = skillWorkshopListSeq.incrementAndGet()
val requestAgentId = normalizeSkillWorkshopAgentId(agentId)
val gatewayScope = captureGatewayDataScope()
if (gatewayScope == null || !operatorConnected) {
_skillWorkshopSummary.value = GatewaySkillWorkshopSummary(agentId = requestAgentId, proposals = emptyList())
_skillWorkshopRefreshing.value = false
_skillWorkshopErrorText.value = "Connect the gateway to load Skill Workshop proposals."
return
}
publishGatewayData(gatewayScope) {
_skillWorkshopRefreshing.value = true
_skillWorkshopErrorText.value = null
if (_skillWorkshopSummary.value.agentId != requestAgentId) {
_skillWorkshopSummary.value = GatewaySkillWorkshopSummary(agentId = requestAgentId, proposals = emptyList())
_skillWorkshopNoticeText.value = null
_skillWorkshopInspectingProposalId.value = null
_skillWorkshopMutatingProposalId.value = null
skillWorkshopInspectSeq.incrementAndGet()
skillWorkshopMutationSeq.incrementAndGet()
}
}
try {
val res =
requestGatewayData(
gatewayScope,
"skills.proposals.list",
skillWorkshopParams(agentId = agentId).toString(),
)
val root = json.parseToJsonElement(res).asObjectOrNull()
val previousById =
_skillWorkshopSummary.value
.takeIf { it.agentId == requestAgentId }
?.proposals
?.associateBy { it.id }
.orEmpty()
val proposals = parseSkillWorkshopProposals(root?.get("proposals") as? JsonArray, previousById)
publishGatewayData(gatewayScope) {
if (skillWorkshopListSeq.get() == listSeq && _skillWorkshopSummary.value.agentId == requestAgentId) {
_skillWorkshopSummary.value = GatewaySkillWorkshopSummary(agentId = requestAgentId, proposals = proposals)
}
}
} catch (err: CancellationException) {
throw err
} catch (_: Throwable) {
publishGatewayData(gatewayScope) {
if (skillWorkshopListSeq.get() == listSeq && _skillWorkshopSummary.value.agentId == requestAgentId) {
_skillWorkshopErrorText.value = "Could not load Skill Workshop proposals."
}
}
} finally {
publishGatewayData(gatewayScope) {
if (skillWorkshopListSeq.get() == listSeq && _skillWorkshopSummary.value.agentId == requestAgentId) {
_skillWorkshopRefreshing.value = false
}
}
}
}
private suspend fun inspectSkillWorkshopProposalFromGateway(
proposalId: String,
agentId: String?,
) {
var inspectSeq = 0L
val requestAgentId = normalizeSkillWorkshopAgentId(agentId)
val gatewayScope = captureGatewayDataScope()
if (gatewayScope == null || !operatorConnected) {
_skillWorkshopErrorText.value = "Connect the gateway to inspect Skill Workshop proposals."
return
}
var inspectStarted = false
val scopeCurrent =
publishGatewayData(gatewayScope) {
val currentSummary = _skillWorkshopSummary.value
if (
currentSummary.agentId == requestAgentId &&
currentSummary.proposals.any { it.id == proposalId } &&
_skillWorkshopMutatingProposalId.value == null
) {
inspectStarted = true
inspectSeq = skillWorkshopInspectSeq.incrementAndGet()
_skillWorkshopInspectingProposalId.value = proposalId
_skillWorkshopErrorText.value = null
}
}
if (!scopeCurrent || !inspectStarted) {
return
}
try {
val res =
requestGatewayData(
gatewayScope,
"skills.proposals.inspect",
skillWorkshopParams(agentId = agentId, proposalId = proposalId).toString(),
)
val root = json.parseToJsonElement(res).asObjectOrNull()
val previous =
_skillWorkshopSummary.value
.takeIf { it.agentId == requestAgentId }
?.proposals
?.firstOrNull { it.id == proposalId }
val inspected =
parseSkillWorkshopProposalInspect(root, previous)
?: throw IllegalStateException("skills.proposals.inspect returned no proposal")
publishGatewayData(gatewayScope) {
val currentSummary = _skillWorkshopSummary.value
if (
skillWorkshopInspectSeq.get() == inspectSeq &&
currentSummary.agentId == requestAgentId &&
currentSummary.proposals.any { it.id == proposalId }
) {
_skillWorkshopSummary.value = _skillWorkshopSummary.value.withProposal(inspected)
}
}
} catch (err: CancellationException) {
throw err
} catch (_: Throwable) {
publishGatewayData(gatewayScope) {
if (skillWorkshopInspectSeq.get() == inspectSeq && _skillWorkshopSummary.value.agentId == requestAgentId) {
_skillWorkshopErrorText.value = "Could not inspect Skill Workshop proposal."
}
}
} finally {
publishGatewayData(gatewayScope) {
if (skillWorkshopInspectSeq.get() == inspectSeq && _skillWorkshopSummary.value.agentId == requestAgentId) {
_skillWorkshopInspectingProposalId.value = null
}
}
}
}
private suspend fun mutateSkillWorkshopProposalOnGateway(
proposalId: String,
agentId: String?,
action: SkillWorkshopGatewayAction,
) {
var mutationSeq = 0L
val requestAgentId = normalizeSkillWorkshopAgentId(agentId)
if (!operatorAdminScopeAvailable.value) {
_skillWorkshopErrorText.value = "Skill Workshop proposal actions require operator.admin scope."
return
}
val gatewayScope = captureGatewayDataScope()
if (gatewayScope == null || !operatorConnected) {
_skillWorkshopErrorText.value = "Connect the gateway to update Skill Workshop proposals."
return
}
var mutationStarted = false
val scopeCurrent =
publishGatewayData(gatewayScope) {
val currentSummary = _skillWorkshopSummary.value
if (
currentSummary.agentId == requestAgentId &&
currentSummary.proposals.any { it.id == proposalId } &&
_skillWorkshopMutatingProposalId.value == null
) {
mutationStarted = true
mutationSeq = skillWorkshopMutationSeq.incrementAndGet()
// A lifecycle action supersedes any older detail read. Without this
// guard, a late inspect response can restore the pre-action status.
skillWorkshopInspectSeq.incrementAndGet()
_skillWorkshopInspectingProposalId.value = null
_skillWorkshopMutatingProposalId.value = proposalId
_skillWorkshopErrorText.value = null
_skillWorkshopNoticeText.value = null
}
}
if (!scopeCurrent || !mutationStarted) {
return
}
try {
val res =
requestGatewayData(
gatewayScope,
"skills.proposals.${action.methodSuffix}",
skillWorkshopParams(agentId = agentId, proposalId = proposalId).toString(),
)
val updatedProposal =
parseSkillWorkshopProposalActionResult(
root = json.parseToJsonElement(res).asObjectOrNull(),
previous =
_skillWorkshopSummary.value
.takeIf { it.agentId == requestAgentId }
?.proposals
?.firstOrNull { it.id == proposalId },
)
var mutationConfirmed = false
publishGatewayData(gatewayScope) {
if (skillWorkshopMutationSeq.get() == mutationSeq && _skillWorkshopSummary.value.agentId == requestAgentId) {
if (updatedProposal?.status == action.expectedStatus) {
_skillWorkshopSummary.value = _skillWorkshopSummary.value.withProposal(updatedProposal)
_skillWorkshopNoticeText.value = action.notice
mutationConfirmed = true
} else {
val statusLabel = updatedProposal?.status?.takeIf { it.isNotBlank() } ?: "unknown"
_skillWorkshopErrorText.value =
"Gateway returned status '$statusLabel' after ${action.verb}."
}
}
}
if (!mutationConfirmed) return
var refreshStillCurrent = false
publishGatewayData(gatewayScope) {
refreshStillCurrent =
skillWorkshopMutationSeq.get() == mutationSeq &&
_skillWorkshopSummary.value.agentId == requestAgentId
}
if (refreshStillCurrent) {
refreshSkillWorkshopProposalsFromGateway(agentId = agentId)
}
} catch (err: CancellationException) {
throw err
} catch (_: Throwable) {
publishGatewayData(gatewayScope) {
if (skillWorkshopMutationSeq.get() == mutationSeq && _skillWorkshopSummary.value.agentId == requestAgentId) {
_skillWorkshopErrorText.value = "Could not ${action.verb} Skill Workshop proposal."
}
}
} finally {
publishGatewayData(gatewayScope) {
if (skillWorkshopMutationSeq.get() == mutationSeq && _skillWorkshopSummary.value.agentId == requestAgentId) {
_skillWorkshopMutatingProposalId.value = null
}
}
}
}
private fun normalizeSkillWorkshopAgentId(agentId: String?): String = agentId?.trim().orEmpty()
private fun skillWorkshopParams(
agentId: String?,
proposalId: String? = null,
): JsonObject =
buildJsonObject {
val normalizedAgentId = agentId?.trim()?.takeIf { it.isNotEmpty() }
if (normalizedAgentId != null) put("agentId", JsonPrimitive(normalizedAgentId))
val normalizedProposalId = proposalId?.trim()?.takeIf { it.isNotEmpty() }
if (normalizedProposalId != null) put("proposalId", JsonPrimitive(normalizedProposalId))
}
private suspend fun refreshNodesDevicesFromGateway() {
val gatewayScope = captureGatewayDataScope() ?: return
val refreshGeneration = nodeApprovalRefreshGuard.begin()
@ -4199,6 +4554,113 @@ class NodeRuntime private constructor(
)
}.orEmpty()
private fun parseSkillWorkshopProposals(
proposals: JsonArray?,
previousById: Map<String, GatewaySkillWorkshopProposal>,
): List<GatewaySkillWorkshopProposal> {
val parsed =
proposals?.mapNotNull { item ->
val obj = item.asObjectOrNull() ?: return@mapNotNull null
val id = obj.skillWorkshopString("id") ?: return@mapNotNull null
val previous = previousById[id]
val updatedAt = obj.skillWorkshopString("updatedAt").orEmpty()
GatewaySkillWorkshopProposal(
id = id,
kind = obj.skillWorkshopString("kind") ?: "proposal",
status = obj.skillWorkshopString("status") ?: "pending",
title = obj.skillWorkshopString("title") ?: obj.skillWorkshopString("skillName") ?: id,
description = obj.skillWorkshopString("description"),
skillName = obj.skillWorkshopString("skillName") ?: id,
skillKey = obj.skillWorkshopString("skillKey") ?: id,
createdAt = obj.skillWorkshopString("createdAt").orEmpty(),
updatedAt = updatedAt,
scanState = obj.skillWorkshopString("scanState"),
content = previous?.content?.takeIf { previous.updatedAt == updatedAt },
supportFiles = previous?.supportFiles?.takeIf { previous.updatedAt == updatedAt }.orEmpty(),
)
}
return parsed.orEmpty().sortedByDescending { it.updatedAt }
}
private fun parseSkillWorkshopProposalInspect(
root: JsonObject?,
previous: GatewaySkillWorkshopProposal?,
): GatewaySkillWorkshopProposal? {
val source = root ?: return null
val record = source["record"].asObjectOrNull() ?: return null
val id = record.skillWorkshopString("id") ?: previous?.id ?: return null
val target = record["target"].asObjectOrNull()
val updatedAt = record.skillWorkshopString("updatedAt").orEmpty()
return GatewaySkillWorkshopProposal(
id = id,
kind = record.skillWorkshopString("kind") ?: previous?.kind ?: "proposal",
status = record.skillWorkshopString("status") ?: previous?.status ?: "pending",
title = record.skillWorkshopString("title") ?: target?.skillWorkshopString("skillName") ?: previous?.title ?: id,
description = record.skillWorkshopString("description") ?: previous?.description,
skillName = target?.skillWorkshopString("skillName") ?: previous?.skillName ?: id,
skillKey = target?.skillWorkshopString("skillKey") ?: previous?.skillKey ?: id,
createdAt = record.skillWorkshopString("createdAt") ?: previous?.createdAt.orEmpty(),
updatedAt = updatedAt.ifEmpty { previous?.updatedAt.orEmpty() },
scanState = record.skillWorkshopString("scanState") ?: previous?.scanState,
content = stripSkillWorkshopFrontmatter(source["content"].asStringOrNull().orEmpty()),
supportFiles = parseSkillWorkshopSupportFiles(source["supportFiles"] as? JsonArray),
)
}
private fun parseSkillWorkshopProposalActionResult(
root: JsonObject?,
previous: GatewaySkillWorkshopProposal?,
): GatewaySkillWorkshopProposal? {
val record =
root?.get("record").asObjectOrNull()
?: root?.takeIf { it.skillWorkshopString("status") != null }
?: return null
val id = record.skillWorkshopString("id") ?: previous?.id ?: return null
val target = record["target"].asObjectOrNull()
val updatedAt = record.skillWorkshopString("updatedAt").orEmpty()
return GatewaySkillWorkshopProposal(
id = id,
kind = record.skillWorkshopString("kind") ?: previous?.kind ?: "proposal",
status = record.skillWorkshopString("status") ?: previous?.status ?: "pending",
title = record.skillWorkshopString("title") ?: target?.skillWorkshopString("skillName") ?: previous?.title ?: id,
description = record.skillWorkshopString("description") ?: previous?.description,
skillName = target?.skillWorkshopString("skillName") ?: previous?.skillName ?: id,
skillKey = target?.skillWorkshopString("skillKey") ?: previous?.skillKey ?: id,
createdAt = record.skillWorkshopString("createdAt") ?: previous?.createdAt.orEmpty(),
updatedAt = updatedAt.ifEmpty { previous?.updatedAt.orEmpty() },
scanState =
record["scan"].asObjectOrNull()?.skillWorkshopString("state")
?: record.skillWorkshopString("scanState")
?: previous?.scanState,
content = previous?.content,
supportFiles = previous?.supportFiles.orEmpty(),
)
}
private fun parseSkillWorkshopSupportFiles(files: JsonArray?): List<GatewaySkillWorkshopSupportFile> {
val parsed =
files?.mapNotNull { item ->
val obj = item.asObjectOrNull() ?: return@mapNotNull null
val path = obj.skillWorkshopString("path") ?: return@mapNotNull null
GatewaySkillWorkshopSupportFile(
path = path,
content = obj["content"].asStringOrNull()?.takeIf { it.isNotEmpty() },
)
}
return parsed.orEmpty()
}
private fun stripSkillWorkshopFrontmatter(content: String): String {
val withoutFrontmatter = content.replace(Regex("(?s)^---\\r?\\n.*?\\r?\\n---\\r?\\n?"), "")
return withoutFrontmatter.trim()
}
private fun JsonObject.skillWorkshopString(key: String): String? =
get(key)
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
private fun skillMissingCount(missing: JsonObject?): Int = listOf("bins", "env", "config", "os").sumOf { key -> (missing?.get(key) as? JsonArray)?.size ?: 0 }
private fun parsePendingDevices(devices: JsonArray?): List<GatewayPendingDeviceSummary> =
@ -4674,6 +5136,13 @@ internal fun operatorConnectScopesForAuth(
return ConnectionManager.nativeClientOperatorScopes
}
internal fun normalizeOperatorScopes(scopes: List<String>): List<String> =
scopes
.map { it.trim() }
.filter { it.isNotEmpty() }
.distinct()
.sorted()
private enum class HomeCanvasGatewayState {
Connected,
Connecting,
@ -4767,6 +5236,38 @@ data class GatewaySkillsSummary(
val skills: List<GatewaySkillSummary>,
)
data class GatewaySkillWorkshopSummary(
val agentId: String = "",
val proposals: List<GatewaySkillWorkshopProposal>,
) {
fun withProposal(proposal: GatewaySkillWorkshopProposal): GatewaySkillWorkshopSummary =
copy(
proposals =
(proposals.filterNot { it.id == proposal.id } + proposal)
.sortedByDescending { it.updatedAt },
)
}
data class GatewaySkillWorkshopProposal(
val id: String,
val kind: String,
val status: String,
val title: String,
val description: String?,
val skillName: String,
val skillKey: String,
val createdAt: String,
val updatedAt: String,
val scanState: String?,
val content: String? = null,
val supportFiles: List<GatewaySkillWorkshopSupportFile> = emptyList(),
)
data class GatewaySkillWorkshopSupportFile(
val path: String,
val content: String?,
)
data class GatewaySkillSummary(
val skillKey: String,
val name: String,

View file

@ -107,6 +107,8 @@ data class GatewayHelloSummary(
val serverVersion: String?,
val mainSessionKey: String?,
val updateAvailable: GatewayUpdateAvailableSummary?,
val authRole: String? = null,
val authScopes: List<String> = emptyList(),
)
data class GatewayUpdateAvailableSummary(
@ -887,6 +889,10 @@ class GatewaySession(
): List<String>? =
when (role.trim()) {
"node" -> emptyList()
// Setup-code bootstrap handoff is deliberately least-privilege. It never
// persists operator.admin, so Skill Workshop lifecycle actions remain
// disabled until shared token/password auth or an owner-approved scope
// upgrade issues an admin-scoped operator device token.
"operator" -> {
val allowedOperatorScopes =
setOf(
@ -993,6 +999,8 @@ class GatewaySession(
serverVersion = serverVersion,
mainSessionKey = nextMainSessionKey,
updateAvailable = parseUpdateAvailable(snapshot?.get("updateAvailable").asObjectOrNull()),
authRole = authRole,
authScopes = authScopes,
),
)
}

View file

@ -151,6 +151,7 @@ internal enum class SettingsRoute {
CronJobs,
Usage,
Skills,
SkillWorkshop,
NodesDevices,
Channels,
Dreaming,
@ -184,6 +185,7 @@ internal fun SettingsDetailScreen(
SettingsRoute.CronJobs -> CronJobsSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.Usage -> UsageSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.Skills -> SkillsSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.SkillWorkshop -> SkillWorkshopSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.NodesDevices -> NodesDevicesSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.Channels -> ChannelsSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.Dreaming -> DreamingSettingsScreen(viewModel = viewModel, onBack = onBack)

View file

@ -9,6 +9,7 @@ import ai.openclaw.app.GatewayDreamingSummary
import ai.openclaw.app.GatewayNodeApprovalState
import ai.openclaw.app.GatewayNodesDevicesSummary
import ai.openclaw.app.GatewaySkillSummary
import ai.openclaw.app.GatewaySkillWorkshopSummary
import ai.openclaw.app.HomeDestination
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.NodeRuntime
@ -1438,6 +1439,7 @@ private fun SettingsShellScreen(
val cronStatus by viewModel.cronStatus.collectAsState()
val usageSummary by viewModel.usageSummary.collectAsState()
val skillsSummary by viewModel.skillsSummary.collectAsState()
val skillWorkshopSummary by viewModel.skillWorkshopSummary.collectAsState()
val nodesDevicesSummary by viewModel.nodesDevicesSummary.collectAsState()
val channelsSummary by viewModel.channelsSummary.collectAsState()
val dreamingSummary by viewModel.dreamingSummary.collectAsState()
@ -1452,6 +1454,7 @@ private fun SettingsShellScreen(
viewModel.refreshCronJobs()
viewModel.refreshUsage()
viewModel.refreshSkills()
viewModel.refreshSkillWorkshopProposals()
viewModel.refreshNodesDevices()
viewModel.refreshChannels()
viewModel.refreshDreaming()
@ -1519,6 +1522,13 @@ private fun SettingsShellScreen(
SettingsRow("Cron Jobs", cronJobsSummary(cronStatus.jobs), Icons.Outlined.AccessTime, status = if (cronStatus.jobs > 0) cronStatus.enabled else null, route = SettingsRoute.CronJobs),
SettingsRow("Usage", usageSummaryText(usageSummary.providers.size), Icons.Default.Storage, status = if (usageSummary.providers.isNotEmpty()) true else null, route = SettingsRoute.Usage),
SettingsRow("Skills", skillsSummaryText(skillsSummary.skills), Icons.Default.Settings, status = skillsStatus(skillsSummary.skills), route = SettingsRoute.Skills),
SettingsRow(
"Skill Workshop",
skillWorkshopSummaryText(skillWorkshopSummary),
Icons.Default.Settings,
status = skillWorkshopStatus(skillWorkshopSummary),
route = SettingsRoute.SkillWorkshop,
),
SettingsRow("Dreaming", dreamingSummaryText(dreamingSummary), Icons.Default.Storage, status = dreamingStatus(dreamingSummary), route = SettingsRoute.Dreaming),
SettingsRow("Terminal", "Shell in the agent workspace", Icons.Outlined.Terminal, status = isConnected, route = SettingsRoute.Terminal),
SettingsRow("Voice", if (speakerEnabled) "Speaker on" else "Speaker muted", Icons.Default.Mic, route = SettingsRoute.Voice),
@ -1620,6 +1630,28 @@ private fun skillsStatus(skills: List<GatewaySkillSummary>): Boolean? =
else -> true
}
/** Mirrors the Skill Workshop review queue in one compact Settings row. */
internal fun skillWorkshopSummaryText(summary: GatewaySkillWorkshopSummary): String {
val pending = summary.proposals.count { it.status == "pending" }
if (pending > 0) return if (pending == 1) "1 pending" else "$pending pending"
val held = summary.proposals.count { it.status == "quarantined" || it.status == "stale" }
val applied = summary.proposals.count { it.status == "applied" }
return when {
summary.proposals.isEmpty() -> "No proposals"
held > 0 -> if (held == 1) "1 held" else "$held held"
applied > 0 -> if (applied == 1) "1 applied" else "$applied applied"
else -> "${summary.proposals.size} proposals"
}
}
internal fun skillWorkshopStatus(summary: GatewaySkillWorkshopSummary): Boolean? =
when {
summary.proposals.any { it.status == "pending" } -> false
summary.proposals.any { it.status == "quarantined" || it.status == "stale" } -> false
summary.proposals.any { it.status == "applied" } -> true
else -> null
}
/** Prioritizes pending pairings over online counts for compact node/device summaries. */
private fun nodesDevicesSummaryText(summary: GatewayNodesDevicesSummary): String {
val online = summary.nodes.count { it.connected }
@ -1726,6 +1758,7 @@ internal fun settingsSectionTitleForRoute(route: SettingsRoute): String =
SettingsRoute.CronJobs,
SettingsRoute.Usage,
SettingsRoute.Skills,
SettingsRoute.SkillWorkshop,
SettingsRoute.Dreaming,
SettingsRoute.Terminal,
-> "Agents & automation"

View file

@ -0,0 +1,689 @@
package ai.openclaw.app.ui
import ai.openclaw.app.GatewayAgentSummary
import ai.openclaw.app.GatewaySkillWorkshopProposal
import ai.openclaw.app.GatewaySkillWorkshopSummary
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawPrimaryButton
import ai.openclaw.app.ui.design.ClawSecondaryButton
import ai.openclaw.app.ui.design.ClawSegmentedControl
import ai.openclaw.app.ui.design.ClawStatus
import ai.openclaw.app.ui.design.ClawStatusPill
import ai.openclaw.app.ui.design.ClawTextField
import ai.openclaw.app.ui.design.ClawTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
private val skillWorkshopFilterLabels = listOf("Pending", "Held", "Applied", "Rejected", "All")
@Composable
internal fun SkillWorkshopSettingsScreen(
viewModel: MainViewModel,
onBack: () -> Unit,
) {
val summary by viewModel.skillWorkshopSummary.collectAsState()
val refreshing by viewModel.skillWorkshopRefreshing.collectAsState()
val errorText by viewModel.skillWorkshopErrorText.collectAsState()
val noticeText by viewModel.skillWorkshopNoticeText.collectAsState()
val inspectingProposalId by viewModel.skillWorkshopInspectingProposalId.collectAsState()
val mutatingProposalId by viewModel.skillWorkshopMutatingProposalId.collectAsState()
val isConnected by viewModel.isConnected.collectAsState()
val operatorAdminScopeAvailable by viewModel.operatorAdminScopeAvailable.collectAsState()
val agents by viewModel.gatewayAgents.collectAsState()
val defaultAgentId by viewModel.gatewayDefaultAgentId.collectAsState()
var statusFilter by rememberSaveable { mutableStateOf("pending") }
var query by rememberSaveable { mutableStateOf("") }
var selectedAgentId by rememberSaveable { mutableStateOf("") }
var selectedProposalId by rememberSaveable { mutableStateOf<String?>(null) }
var pendingAction by remember { mutableStateOf<SkillWorkshopPendingAction?>(null) }
val selectedAgentParam = selectedAgentId.trim().takeIf { it.isNotEmpty() }
val visibleProposals = skillWorkshopVisibleProposals(summary, selectedAgentParam)
val filteredProposals = skillWorkshopFilteredProposals(visibleProposals, statusFilter, query)
val selectedProposal =
filteredProposals.firstOrNull { it.id == selectedProposalId }
?: filteredProposals.firstOrNull()
LaunchedEffect(isConnected, selectedAgentParam) {
if (isConnected) {
viewModel.refreshSkillWorkshopProposals(agentId = selectedAgentParam)
}
}
LaunchedEffect(filteredProposals.map { it.id }) {
if (selectedProposalId == null || filteredProposals.none { it.id == selectedProposalId }) {
selectedProposalId = filteredProposals.firstOrNull()?.id
}
}
LaunchedEffect(selectedProposal?.id, selectedProposal?.content, isConnected, selectedAgentParam) {
if (isConnected && selectedProposal != null && selectedProposal.content == null) {
viewModel.inspectSkillWorkshopProposal(proposalId = selectedProposal.id, agentId = selectedAgentParam)
}
}
LaunchedEffect(selectedProposal?.id, pendingAction?.proposalId) {
if (pendingAction != null && selectedProposal?.id != pendingAction?.proposalId) {
pendingAction = null
}
}
pendingAction?.let { action ->
SkillWorkshopActionConfirmDialog(
action = action,
onDismiss = { pendingAction = null },
onConfirm = {
pendingAction = null
when (action.action) {
SkillWorkshopProposalAction.Apply ->
viewModel.applySkillWorkshopProposal(
proposalId = action.proposalId,
agentId = selectedAgentParam,
)
SkillWorkshopProposalAction.Reject ->
viewModel.rejectSkillWorkshopProposal(
proposalId = action.proposalId,
agentId = selectedAgentParam,
)
SkillWorkshopProposalAction.Quarantine ->
viewModel.quarantineSkillWorkshopProposal(
proposalId = action.proposalId,
agentId = selectedAgentParam,
)
}
},
)
}
SettingsDetailFrame(
title = "Skill Workshop",
subtitle = "Review generated skill proposals before they become live skills.",
icon = Icons.Default.Settings,
onBack = onBack,
) {
SettingsMetricPanel(
rows =
listOf(
SettingsMetric("Pending", visibleProposals.count { it.status == "pending" }.toString()),
SettingsMetric(
"Held",
visibleProposals.count { skillWorkshopStatusMatchesFilter(it.status, "held") }.toString(),
),
SettingsMetric("Applied", visibleProposals.count { it.status == "applied" }.toString()),
SettingsMetric("Rejected", visibleProposals.count { it.status == "rejected" }.toString()),
),
)
SkillWorkshopControls(
agents = agents,
defaultAgentId = defaultAgentId,
selectedAgentId = selectedAgentId,
onAgentChange = { agentId ->
selectedAgentId = agentId
selectedProposalId = null
viewModel.resetSkillWorkshopAgentScope(agentId = agentId.takeIf { it.isNotBlank() })
},
statusFilter = statusFilter,
onStatusFilterChange = { filter ->
statusFilter = filter
selectedProposalId = null
},
query = query,
onQueryChange = { value ->
query = value
selectedProposalId = null
},
refreshing = refreshing,
isConnected = isConnected,
onRefresh = { viewModel.refreshSkillWorkshopProposals(agentId = selectedAgentParam) },
)
noticeText?.let { message ->
ClawPanel {
Text(text = message, style = ClawTheme.type.body, color = ClawTheme.colors.success)
}
}
errorText?.let { message ->
ClawPanel {
Text(text = message, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
}
}
when {
!isConnected ->
SkillWorkshopEmptyPanel(
title = "Gateway offline",
detail = "Connect to a Gateway to load Skill Workshop proposals.",
)
filteredProposals.isEmpty() ->
SkillWorkshopEmptyPanel(
title = "No proposals",
detail = "Matching proposals will appear here after agents create reusable skill drafts.",
)
else -> {
SkillWorkshopProposalList(
proposals = filteredProposals,
selectedProposalId = selectedProposal?.id,
inspectingProposalId = inspectingProposalId,
mutatingProposalId = mutatingProposalId,
onSelect = { proposal ->
selectedProposalId = proposal.id
viewModel.inspectSkillWorkshopProposal(proposalId = proposal.id, agentId = selectedAgentParam)
},
)
selectedProposal?.let { proposal ->
SkillWorkshopProposalDetail(
proposal = proposal,
inspecting = inspectingProposalId == proposal.id,
mutating = mutatingProposalId == proposal.id,
isConnected = isConnected,
operatorAdminScopeAvailable = operatorAdminScopeAvailable,
onInspect = {
viewModel.inspectSkillWorkshopProposal(proposalId = proposal.id, agentId = selectedAgentParam)
},
onApply = {
pendingAction =
SkillWorkshopPendingAction(
action = SkillWorkshopProposalAction.Apply,
proposalId = proposal.id,
title = proposal.title,
)
},
onReject = {
pendingAction =
SkillWorkshopPendingAction(
action = SkillWorkshopProposalAction.Reject,
proposalId = proposal.id,
title = proposal.title,
)
},
onQuarantine = {
pendingAction =
SkillWorkshopPendingAction(
action = SkillWorkshopProposalAction.Quarantine,
proposalId = proposal.id,
title = proposal.title,
)
},
)
}
}
}
}
}
private enum class SkillWorkshopProposalAction(
val label: String,
) {
Apply("Apply"),
Reject("Reject"),
Quarantine("Quarantine"),
}
private data class SkillWorkshopPendingAction(
val action: SkillWorkshopProposalAction,
val proposalId: String,
val title: String,
)
@Composable
private fun SkillWorkshopActionConfirmDialog(
action: SkillWorkshopPendingAction,
onDismiss: () -> Unit,
onConfirm: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("${action.action.label} proposal?") },
text = {
Text(
text =
"This will ${action.action.label.lowercase()} \"${action.title}\" and refresh Skill Workshop state from the gateway.",
)
},
confirmButton = {
TextButton(onClick = onConfirm) {
Text(action.action.label)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
},
)
}
@Composable
private fun SkillWorkshopControls(
agents: List<GatewayAgentSummary>,
defaultAgentId: String?,
selectedAgentId: String,
onAgentChange: (String) -> Unit,
statusFilter: String,
onStatusFilterChange: (String) -> Unit,
query: String,
onQueryChange: (String) -> Unit,
refreshing: Boolean,
isConnected: Boolean,
onRefresh: () -> Unit,
) {
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
SkillWorkshopAgentMenu(
agents = agents,
defaultAgentId = defaultAgentId,
selectedAgentId = selectedAgentId,
onAgentChange = onAgentChange,
modifier = Modifier.weight(1f),
)
ClawSecondaryButton(
text = if (refreshing) "Refreshing" else "Refresh",
onClick = onRefresh,
enabled = isConnected && !refreshing,
icon = Icons.Default.Refresh,
)
}
ClawSegmentedControl(
options = skillWorkshopFilterLabels,
selected = skillWorkshopFilterLabel(statusFilter),
onSelect = { label -> onStatusFilterChange(skillWorkshopFilterFromLabel(label)) },
)
ClawTextField(
value = query,
onValueChange = onQueryChange,
placeholder = "Search proposals",
)
}
}
}
@Composable
private fun SkillWorkshopAgentMenu(
agents: List<GatewayAgentSummary>,
defaultAgentId: String?,
selectedAgentId: String,
onAgentChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(false) }
val label =
skillWorkshopAgentLabel(
agents = agents,
defaultAgentId = defaultAgentId,
selectedAgentId = selectedAgentId,
)
Box(modifier = modifier) {
ClawSecondaryButton(
text = label,
onClick = { expanded = true },
icon = Icons.Default.ArrowDropDown,
modifier = Modifier.fillMaxWidth(),
enabled = agents.isNotEmpty(),
)
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(
text = { Text("Default agent") },
onClick = {
expanded = false
onAgentChange("")
},
)
agents
.filter { agent -> agent.id.trim().isNotEmpty() && agent.id != defaultAgentId }
.sortedBy { it.name ?: it.id }
.forEach { agent ->
DropdownMenuItem(
text = { Text(agent.name?.takeIf { it.isNotBlank() } ?: agent.id) },
onClick = {
expanded = false
onAgentChange(agent.id)
},
)
}
}
}
}
@Composable
private fun SkillWorkshopProposalList(
proposals: List<GatewaySkillWorkshopProposal>,
selectedProposalId: String?,
inspectingProposalId: String?,
mutatingProposalId: String?,
onSelect: (GatewaySkillWorkshopProposal) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
proposals.forEach { proposal ->
SkillWorkshopProposalRow(
proposal = proposal,
selected = proposal.id == selectedProposalId,
busy = proposal.id == inspectingProposalId || proposal.id == mutatingProposalId,
onClick = { onSelect(proposal) },
)
}
}
}
@Composable
private fun SkillWorkshopProposalRow(
proposal: GatewaySkillWorkshopProposal,
selected: Boolean,
busy: Boolean,
onClick: () -> Unit,
) {
ClawPanel {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(
text = proposal.title,
style = ClawTheme.type.body,
color = if (selected) ClawTheme.colors.primary else ClawTheme.colors.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = proposal.description ?: proposal.skillKey,
style = ClawTheme.type.caption,
color = ClawTheme.colors.textSubtle,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(4.dp)) {
ClawStatusPill(
text = if (busy) "loading" else proposal.status,
status = skillWorkshopStatusPill(proposal.status),
)
Text(
text = skillWorkshopDateLabel(proposal.updatedAt),
style = ClawTheme.type.caption,
color = ClawTheme.colors.textSubtle,
maxLines = 1,
)
}
}
ClawSecondaryButton(
text = if (selected) "Selected" else "Open",
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
enabled = !selected,
)
}
}
@Composable
private fun SkillWorkshopProposalDetail(
proposal: GatewaySkillWorkshopProposal,
inspecting: Boolean,
mutating: Boolean,
isConnected: Boolean,
operatorAdminScopeAvailable: Boolean,
onInspect: () -> Unit,
onApply: () -> Unit,
onReject: () -> Unit,
onQuarantine: () -> Unit,
) {
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = proposal.title,
style = ClawTheme.type.title,
color = ClawTheme.colors.text,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(
text = proposal.skillKey,
style = ClawTheme.type.caption,
color = ClawTheme.colors.textSubtle,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
ClawStatusPill(text = proposal.status, status = skillWorkshopStatusPill(proposal.status))
}
proposal.description?.let { description ->
Text(text = description, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
SettingsMetricPanel(
rows =
listOf(
SettingsMetric("Kind", proposal.kind),
SettingsMetric("Updated", skillWorkshopDateLabel(proposal.updatedAt)),
SettingsMetric("Support Files", proposal.supportFiles.size.toString()),
),
)
Text(
text = proposal.content ?: "Inspect this proposal to load its markdown.",
style = ClawTheme.type.body,
color = if (proposal.content == null) ClawTheme.colors.textSubtle else ClawTheme.colors.text,
)
if (!operatorAdminScopeAvailable) {
Text(
text = "Apply, reject, and quarantine require operator.admin scope. Reconnect with shared gateway auth or approve an operator.admin device scope upgrade to enable lifecycle actions.",
style = ClawTheme.type.caption,
color = ClawTheme.colors.warning,
)
}
if (proposal.supportFiles.isNotEmpty()) {
HorizontalDivider(color = ClawTheme.colors.border)
proposal.supportFiles.forEach { file ->
Text(
text = file.path,
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Row(
modifier =
Modifier
.fillMaxWidth()
.semantics { contentDescription = "Skill Workshop inspect and apply actions" },
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
ClawSecondaryButton(
text = if (inspecting) "Inspecting" else "Inspect",
onClick = onInspect,
enabled = isConnected && !inspecting && !mutating,
modifier = Modifier.weight(1f),
)
ClawPrimaryButton(
text = if (mutating) "Working" else "Apply",
onClick = onApply,
enabled =
skillWorkshopProposalActionEnabled(
isConnected = isConnected,
operatorAdminScopeAvailable = operatorAdminScopeAvailable,
busy = inspecting || mutating,
status = proposal.status,
),
modifier = Modifier.weight(1f),
)
}
Row(
modifier =
Modifier
.fillMaxWidth()
.semantics { contentDescription = "Skill Workshop reject and quarantine actions" },
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
ClawSecondaryButton(
text = "Reject",
onClick = onReject,
enabled =
skillWorkshopProposalActionEnabled(
isConnected = isConnected,
operatorAdminScopeAvailable = operatorAdminScopeAvailable,
busy = inspecting || mutating,
status = proposal.status,
),
modifier = Modifier.weight(1f),
)
ClawSecondaryButton(
text = "Quarantine",
onClick = onQuarantine,
enabled =
skillWorkshopProposalActionEnabled(
isConnected = isConnected,
operatorAdminScopeAvailable = operatorAdminScopeAvailable,
busy = inspecting || mutating,
status = proposal.status,
),
modifier = Modifier.weight(1f),
)
}
}
}
}
@Composable
private fun SkillWorkshopEmptyPanel(
title: String,
detail: String,
) {
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(text = title, style = ClawTheme.type.title, color = ClawTheme.colors.text)
Text(text = detail, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
}
internal fun skillWorkshopVisibleProposals(
summary: GatewaySkillWorkshopSummary,
selectedAgentId: String?,
): List<GatewaySkillWorkshopProposal> =
when {
summary.agentId == skillWorkshopAgentScope(selectedAgentId) -> summary.proposals
else -> emptyList()
}
internal fun skillWorkshopAgentScope(agentId: String?): String = agentId?.trim().orEmpty()
internal fun skillWorkshopFilteredProposals(
proposals: List<GatewaySkillWorkshopProposal>,
statusFilter: String,
query: String,
): List<GatewaySkillWorkshopProposal> {
val normalizedQuery = query.trim().lowercase()
val matchingStatus =
proposals.filter { proposal -> skillWorkshopStatusMatchesFilter(proposal.status, statusFilter) }
val matchingQuery =
matchingStatus.filter { proposal ->
if (normalizedQuery.isEmpty()) return@filter true
val pieces =
listOf(proposal.title, proposal.description.orEmpty(), proposal.skillName, proposal.skillKey)
val haystack = pieces.joinToString(" ").lowercase()
haystack.contains(normalizedQuery)
}
return matchingQuery.sortedByDescending { it.updatedAt }
}
internal fun skillWorkshopStatusMatchesFilter(
status: String,
filter: String,
): Boolean =
when (filter) {
"all" -> true
"held" -> status == "quarantined" || status == "stale"
else -> status == filter
}
internal fun skillWorkshopProposalActionEnabled(
isConnected: Boolean,
operatorAdminScopeAvailable: Boolean,
busy: Boolean,
status: String,
): Boolean = isConnected && operatorAdminScopeAvailable && !busy && status == "pending"
private fun skillWorkshopFilterLabel(filter: String): String =
when (filter) {
"pending" -> "Pending"
"held" -> "Held"
"applied" -> "Applied"
"rejected" -> "Rejected"
else -> "All"
}
private fun skillWorkshopFilterFromLabel(label: String): String =
when (label) {
"Pending" -> "pending"
"Held" -> "held"
"Applied" -> "applied"
"Rejected" -> "rejected"
else -> "all"
}
private fun skillWorkshopAgentLabel(
agents: List<GatewayAgentSummary>,
defaultAgentId: String?,
selectedAgentId: String,
): String {
val selected = selectedAgentId.trim()
if (selected.isNotEmpty()) {
return agents.firstOrNull { it.id == selected }?.name?.takeIf { it.isNotBlank() } ?: selected
}
val defaultId = defaultAgentId?.trim().orEmpty()
if (defaultId.isNotEmpty()) {
return agents.firstOrNull { it.id == defaultId }?.name?.takeIf { it.isNotBlank() }
?: "Default agent"
}
return "Default agent"
}
private fun skillWorkshopStatusPill(status: String): ClawStatus =
when (status) {
"pending", "quarantined", "stale" -> ClawStatus.Warning
"applied" -> ClawStatus.Success
"rejected" -> ClawStatus.Neutral
else -> ClawStatus.Neutral
}
private fun skillWorkshopDateLabel(value: String): String = value.trim().takeIf { it.isNotEmpty() }?.take(10) ?: "unknown"

View file

@ -0,0 +1,237 @@
package ai.openclaw.app
import ai.openclaw.app.gateway.GatewayEndpoint
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.lang.reflect.Field
import java.util.UUID
import java.util.concurrent.atomic.AtomicLong
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class SkillWorkshopAgentScopeRuntimeTest {
private val json = Json { ignoreUnknownKeys = true }
@Before
fun clearPlainPrefs() {
RuntimeEnvironment
.getApplication()
.getSharedPreferences("openclaw.node", android.content.Context.MODE_PRIVATE)
.edit()
.clear()
.commit()
}
@Test
fun resetSkillWorkshopAgentScopeClearsRowsAndInFlightActionState() {
val runtime = createTestRuntime()
seedConnectedRuntime(runtime)
runtime.resetSkillWorkshopAgentScope("main")
readField<MutableStateFlow<GatewaySkillWorkshopSummary>>(runtime, "_skillWorkshopSummary").value =
GatewaySkillWorkshopSummary(
agentId = "main",
proposals = listOf(skillWorkshopProposal("main-proposal")),
)
runtime.resetSkillWorkshopAgentScope("ops")
assertEquals("ops", runtime.skillWorkshopSummary.value.agentId)
assertEquals(emptyList<GatewaySkillWorkshopProposal>(), runtime.skillWorkshopSummary.value.proposals)
assertFalse(runtime.skillWorkshopRefreshing.value)
assertNull(runtime.skillWorkshopErrorText.value)
assertNull(runtime.skillWorkshopNoticeText.value)
assertNull(runtime.skillWorkshopInspectingProposalId.value)
assertNull(runtime.skillWorkshopMutatingProposalId.value)
}
@Test
fun inspectAndMutateDoNotStartForStaleSelectedAgentScope() {
val runtime = createTestRuntime()
seedConnectedRuntime(runtime)
runtime.resetSkillWorkshopAgentScope("main")
runtime.inspectSkillWorkshopProposal(proposalId = "ops-proposal", agentId = "ops")
readField<MutableStateFlow<List<String>>>(runtime, "_operatorScopes").value = listOf("operator.admin")
waitUntil { runtime.operatorAdminScopeAvailable.value }
runtime.applySkillWorkshopProposal(proposalId = "ops-proposal", agentId = "ops")
Thread.sleep(100)
assertEquals("main", runtime.skillWorkshopSummary.value.agentId)
assertNull(runtime.skillWorkshopInspectingProposalId.value)
assertNull(runtime.skillWorkshopMutatingProposalId.value)
assertNull(runtime.skillWorkshopErrorText.value)
assertNull(runtime.skillWorkshopNoticeText.value)
}
@Test
fun busyProposalActionDoesNotInvalidateActiveRequestGenerations() {
val runtime = createTestRuntime()
seedConnectedRuntime(runtime)
readField<MutableStateFlow<GatewaySkillWorkshopSummary>>(runtime, "_skillWorkshopSummary").value =
GatewaySkillWorkshopSummary(
agentId = "main",
proposals = listOf(skillWorkshopProposal("proposal-1")),
)
readField<MutableStateFlow<List<String>>>(runtime, "_operatorScopes").value = listOf("operator.admin")
waitUntil { runtime.operatorAdminScopeAvailable.value }
readField<MutableStateFlow<String?>>(runtime, "_skillWorkshopMutatingProposalId").value = "proposal-1"
val mutationSeq = readField<AtomicLong>(runtime, "skillWorkshopMutationSeq").apply { set(41) }
val inspectSeq = readField<AtomicLong>(runtime, "skillWorkshopInspectSeq").apply { set(17) }
runtime.applySkillWorkshopProposal(proposalId = "proposal-1", agentId = "main")
runtime.inspectSkillWorkshopProposal(proposalId = "proposal-1", agentId = "main")
Thread.sleep(100)
assertEquals(41, mutationSeq.get())
assertEquals(17, inspectSeq.get())
assertEquals("proposal-1", runtime.skillWorkshopMutatingProposalId.value)
assertNull(runtime.skillWorkshopInspectingProposalId.value)
}
@Test
fun proposalActionResultUsesGatewayReturnedStatusAndPreservesInspectedDetails() {
val runtime = createTestRuntime()
val supportFiles =
listOf(GatewaySkillWorkshopSupportFile(path = "references/proof.md", content = "proof"))
val previous =
skillWorkshopProposal("proposal-1")
.copy(
status = "pending",
content = "inspected markdown",
supportFiles = supportFiles,
)
val rejected =
parseSkillWorkshopActionResult(
runtime,
"""
{
"record": {
"id": "proposal-1",
"kind": "create",
"status": "rejected",
"title": "Rejected proposal",
"description": "Gateway action response",
"createdAt": "2026-07-08T00:00:00Z",
"updatedAt": "2026-07-09T00:00:00Z",
"scan": { "state": "clean" },
"target": { "skillName": "Rejected Skill", "skillKey": "rejected-skill" }
}
}
""".trimIndent(),
previous,
)
assertEquals("rejected", rejected?.status)
assertEquals("2026-07-09T00:00:00Z", rejected?.updatedAt)
assertEquals("Rejected Skill", rejected?.skillName)
assertEquals("clean", rejected?.scanState)
assertEquals("inspected markdown", rejected?.content)
assertEquals(supportFiles, rejected?.supportFiles)
}
private fun createTestRuntime(): NodeRuntime {
val app = RuntimeEnvironment.getApplication()
val securePrefs =
app.getSharedPreferences(
"openclaw.node.secure.test.${UUID.randomUUID()}",
android.content.Context.MODE_PRIVATE,
)
return NodeRuntime(app, SecurePrefs(app, securePrefsOverride = securePrefs))
}
private fun seedConnectedRuntime(runtime: NodeRuntime) {
writeField(runtime, "connectedEndpoint", GatewayEndpoint.manual("127.0.0.1", 18789))
writeField(runtime, "operatorConnected", true)
}
private fun skillWorkshopProposal(id: String): GatewaySkillWorkshopProposal =
GatewaySkillWorkshopProposal(
id = id,
status = "pending",
kind = "create",
title = "Proposal $id",
skillKey = id,
skillName = "Proposal $id",
description = "desc",
createdAt = "2026-07-08T00:00:00Z",
updatedAt = "2026-07-08T00:00:00Z",
scanState = null,
)
private fun parseSkillWorkshopActionResult(
runtime: NodeRuntime,
payloadJson: String,
previous: GatewaySkillWorkshopProposal,
): GatewaySkillWorkshopProposal? {
val method =
runtime.javaClass.getDeclaredMethod(
"parseSkillWorkshopProposalActionResult",
JsonObject::class.java,
GatewaySkillWorkshopProposal::class.java,
)
method.isAccessible = true
@Suppress("UNCHECKED_CAST")
return method.invoke(
runtime,
json.parseToJsonElement(payloadJson).jsonObject,
previous,
) as GatewaySkillWorkshopProposal?
}
private fun waitUntil(condition: () -> Boolean) {
repeat(50) {
if (condition()) return
Thread.sleep(10)
}
error("Expected condition to become true")
}
private fun writeField(
target: Any,
name: String,
value: Any?,
) {
var type: Class<*>? = target.javaClass
while (type != null) {
try {
val field: Field = type.getDeclaredField(name)
field.isAccessible = true
field.set(target, value)
return
} catch (_: NoSuchFieldException) {
type = type.superclass
}
}
error("Field $name not found on ${target.javaClass.name}")
}
private fun <T> readField(
target: Any,
name: String,
): T {
var type: Class<*>? = target.javaClass
while (type != null) {
try {
val field: Field = type.getDeclaredField(name)
field.isAccessible = true
@Suppress("UNCHECKED_CAST")
return field.get(target) as T
} catch (_: NoSuchFieldException) {
type = type.superclass
}
}
error("Field $name not found on ${target.javaClass.name}")
}
}

View file

@ -10,7 +10,10 @@ import ai.openclaw.app.GatewayNodeApprovalState
import ai.openclaw.app.GatewayNodeSummary
import ai.openclaw.app.GatewayNodesDevicesSummary
import ai.openclaw.app.GatewayPendingDeviceSummary
import ai.openclaw.app.GatewaySkillWorkshopProposal
import ai.openclaw.app.GatewaySkillWorkshopSummary
import ai.openclaw.app.chat.ChatSessionEntry
import ai.openclaw.app.normalizeOperatorScopes
import ai.openclaw.app.ui.design.ClawStatus
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
@ -220,6 +223,121 @@ class ShellScreenLogicTest {
assertEquals(emptyList<String>(), rows.map { it.title })
}
@Test
fun skillWorkshopSummaryPrioritizesPendingAndHeldProposals() {
assertEquals(
"2 pending",
skillWorkshopSummaryText(
GatewaySkillWorkshopSummary(
proposals =
listOf(
skillWorkshopProposal("one", "pending"),
skillWorkshopProposal("two", "pending"),
skillWorkshopProposal("three", "applied"),
),
),
),
)
assertEquals(
"1 held",
skillWorkshopSummaryText(
GatewaySkillWorkshopSummary(proposals = listOf(skillWorkshopProposal("held", "quarantined"))),
),
)
assertEquals(null, skillWorkshopStatus(GatewaySkillWorkshopSummary(proposals = emptyList())))
assertEquals(false, skillWorkshopStatus(GatewaySkillWorkshopSummary(proposals = listOf(skillWorkshopProposal("pending", "pending")))))
assertEquals(true, skillWorkshopStatus(GatewaySkillWorkshopSummary(proposals = listOf(skillWorkshopProposal("applied", "applied")))))
}
@Test
fun skillWorkshopFilteringMatchesHeldAndSearchText() {
val proposals =
listOf(
skillWorkshopProposal("pending", "pending", title = "Browser Playbook", skillKey = "browser-playbook"),
skillWorkshopProposal("stale", "stale", title = "Old Draft", skillKey = "old-draft"),
skillWorkshopProposal("quarantine", "quarantined", title = "Risky Skill", skillKey = "risky-skill"),
)
assertEquals(listOf("stale", "quarantine"), skillWorkshopFilteredProposals(proposals, "held", "").map { it.id })
assertEquals(listOf("pending"), skillWorkshopFilteredProposals(proposals, "all", "browser").map { it.id })
assertTrue(skillWorkshopStatusMatchesFilter("stale", "held"))
assertFalse(skillWorkshopStatusMatchesFilter("applied", "held"))
}
@Test
fun skillWorkshopVisibleProposalsAreKeyedBySelectedAgentScope() {
val mainProposal = skillWorkshopProposal("main-proposal", "pending")
val opsProposal = skillWorkshopProposal("ops-proposal", "pending")
assertEquals(
listOf("main-proposal"),
skillWorkshopVisibleProposals(
GatewaySkillWorkshopSummary(agentId = "", proposals = listOf(mainProposal)),
selectedAgentId = null,
).map { it.id },
)
assertEquals(
emptyList<String>(),
skillWorkshopVisibleProposals(
GatewaySkillWorkshopSummary(agentId = "main", proposals = listOf(mainProposal)),
selectedAgentId = "ops",
).map { it.id },
)
assertEquals(
listOf("ops-proposal"),
skillWorkshopVisibleProposals(
GatewaySkillWorkshopSummary(agentId = "ops", proposals = listOf(opsProposal)),
selectedAgentId = " ops ",
).map { it.id },
)
}
@Test
fun skillWorkshopProposalActionsRequireAdminScope() {
assertTrue(
skillWorkshopProposalActionEnabled(
isConnected = true,
operatorAdminScopeAvailable = true,
busy = false,
status = "pending",
),
)
assertFalse(
skillWorkshopProposalActionEnabled(
isConnected = true,
operatorAdminScopeAvailable = false,
busy = false,
status = "pending",
),
)
assertFalse(
skillWorkshopProposalActionEnabled(
isConnected = true,
operatorAdminScopeAvailable = true,
busy = true,
status = "pending",
),
)
assertFalse(
skillWorkshopProposalActionEnabled(
isConnected = true,
operatorAdminScopeAvailable = true,
busy = false,
status = "applied",
),
)
}
@Test
fun operatorScopesNormalizeForStableAdminChecks() {
assertEquals(
listOf("operator.admin", "operator.read", "operator.write"),
normalizeOperatorScopes(
listOf(" operator.write ", "operator.admin", "", "operator.write", "operator.read"),
),
)
}
@Test
fun homeAttentionRowsSurfacePendingNodeCapabilityApproval() {
val rows =
@ -664,4 +782,23 @@ class ShellScreenLogicTest {
pauseReconnect = false,
retryable = false,
)
private fun skillWorkshopProposal(
id: String,
status: String,
title: String = id,
skillKey: String = id,
): GatewaySkillWorkshopProposal =
GatewaySkillWorkshopProposal(
id = id,
kind = "create",
status = status,
title = title,
description = null,
skillName = title,
skillKey = skillKey,
createdAt = "2026-07-08T00:00:00.000Z",
updatedAt = "2026-07-08T00:00:00.000Z",
scanState = null,
)
}