commit 2f7d2f98e4bd2899ed3c6e47b9281e3d4d4464e9
parent 1b17496d1b4e802183bd2f639c0fa087137e8ade
Author: triesap <tyson@radroots.org>
Date: Tue, 11 Aug 2026 16:57:53 +0000
ui: implement the foundation Network screen
- Map signer, relay, runtime, and pending-operation state from snapshots.
- Render Overview, Identity, Public relays, and Runtime foundation views.
- State the absence of managed service without inventing inbox or authority data.
- Verify mapper, Compose, formatting, Rust, and desktop test suites.
Diffstat:
4 files changed, 342 insertions(+), 0 deletions(-)
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreen.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreen.kt
@@ -0,0 +1,168 @@
+package org.harvestcircle.ui.shell
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.unit.dp
+import org.harvestcircle.application.ApplicationLifecycle
+import org.harvestcircle.application.HarvestCircleShellState
+import org.harvestcircle.application.RelayConnectionState
+import org.harvestcircle.application.SignerAvailability
+
+data class NetworkRelayModel(
+ val url: String,
+ val destination: String,
+ val readState: String,
+ val writeState: String,
+)
+
+data class FoundationNetworkModel(
+ val identityState: String,
+ val identityLabel: String?,
+ val relayState: String,
+ val relays: List<NetworkRelayModel>,
+ val runtimeState: String,
+ val runtimeProblem: String?,
+ val pendingOperations: Int,
+)
+
+fun foundationNetworkModel(state: HarvestCircleShellState): FoundationNetworkModel {
+ val snapshot = state.identity.snapshot
+ val active = snapshot.activeIdentity
+ val identityState =
+ when {
+ state.session.readOnly -> "Read-only"
+ active?.identity?.signer?.availability == SignerAvailability.Available -> "Local identity active"
+ active != null -> "Credential unavailable"
+ snapshot.identities.isNotEmpty() -> "Local identity available"
+ else -> "Signed out"
+ }
+ return FoundationNetworkModel(
+ identityState = identityState,
+ identityLabel = active?.identity?.displayLabel ?: snapshot.identities.firstOrNull()?.displayLabel,
+ relayState = active?.relays?.state?.label() ?: "Not yet observed",
+ relays =
+ snapshot.configuredRelays.map { relay ->
+ NetworkRelayModel(
+ url = relay.url,
+ destination = relay.destination.name,
+ readState = if (relay.read) "Read available" else "Read unavailable",
+ writeState = if (relay.write) "Write available" else "Write unavailable",
+ )
+ },
+ runtimeState = snapshot.lifecycle.label(),
+ runtimeProblem =
+ snapshot.lifecycleProblem?.safeMessage
+ ?: snapshot.sessionProblem?.safeMessage
+ ?: snapshot.recoverableProblem?.safeMessage
+ ?: state.identity.problem,
+ pendingOperations = if (state.identity.busy) 1 else 0,
+ )
+}
+
+@Composable
+fun FoundationNetworkScreen(model: FoundationNetworkModel) {
+ val tabs =
+ listOf(
+ TemplateTab(TemplateSelectionKey("overview"), "Overview"),
+ TemplateTab(TemplateSelectionKey("identity"), "Identity"),
+ TemplateTab(TemplateSelectionKey("public_relays"), "Public relays"),
+ TemplateTab(TemplateSelectionKey("runtime"), "Runtime"),
+ )
+ var selected by remember { mutableStateOf(tabs.first().key) }
+ TabbedDetailTemplate(
+ tabs = tabs,
+ selected = selected,
+ tabRail = { available, current ->
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ available.forEach { tab ->
+ ShellAction(
+ label = tab.label,
+ description = "Show ${tab.label}",
+ tag = "network-tab-${tab.key.value}",
+ enabled = tab.key != current,
+ ) { selected = tab.key }
+ }
+ }
+ },
+ detail = { selection -> NetworkDetail(selection, model) },
+ )
+}
+
+@Composable
+private fun NetworkDetail(
+ selection: TemplateSelectionKey,
+ model: FoundationNetworkModel,
+) {
+ Column(
+ Modifier.testTag("network-${selection.value}"),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ when (selection.value) {
+ "overview" -> {
+ Fact("Signer", model.identityState)
+ Fact("Public relay reads", model.relays.count { it.readState == "Read available" }.toString())
+ Fact("Public relay writes", model.relays.count { it.writeState == "Write available" }.toString())
+ Fact("Local runtime", model.runtimeState)
+ Fact("Pending operations", model.pendingOperations.toString())
+ BasicText("No managed HarvestCircle service is configured.")
+ }
+ "identity" -> {
+ BasicText(model.identityState, Modifier.testTag("network-identity-state"))
+ model.identityLabel?.let { BasicText(it, Modifier.testTag("network-identity-label")) }
+ }
+ "public_relays" -> {
+ BasicText(model.relayState, Modifier.testTag("network-relay-state"))
+ model.relays.forEach { relay ->
+ Column(Modifier.testTag("network-relay:${relay.url}")) {
+ BasicText(relay.url)
+ BasicText(relay.destination)
+ BasicText(relay.readState)
+ BasicText(relay.writeState)
+ }
+ }
+ }
+ "runtime" -> {
+ Fact("Local runtime", model.runtimeState)
+ Fact("Pending operations", model.pendingOperations.toString())
+ model.runtimeProblem?.let { BasicText(it, Modifier.testTag("network-runtime-problem")) }
+ }
+ }
+ }
+}
+
+@Composable
+private fun Fact(
+ label: String,
+ value: String,
+) {
+ Column {
+ BasicText(label)
+ BasicText(value, Modifier.testTag("network-fact-${label.lowercase().replace(' ', '-') }"))
+ }
+}
+
+private fun RelayConnectionState.label(): String =
+ when (this) {
+ RelayConnectionState.Disconnected -> "Unavailable"
+ RelayConnectionState.Connecting -> "Not yet observed"
+ RelayConnectionState.Connected -> "Available"
+ RelayConnectionState.Degraded -> "Degraded"
+ RelayConnectionState.Error -> "Unavailable"
+ }
+
+private fun ApplicationLifecycle.label(): String =
+ when (this) {
+ ApplicationLifecycle.Ready -> "Available"
+ ApplicationLifecycle.Degraded -> "Degraded"
+ ApplicationLifecycle.Blocked, ApplicationLifecycle.Fatal, ApplicationLifecycle.Closed -> "Unavailable"
+ else -> "Not yet observed"
+ }
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt
@@ -161,6 +161,7 @@ private fun DashboardRoot(
)
},
)
+ AppRoute.Network -> FoundationNetworkScreen(foundationNetworkModel(state))
else -> BasicText(route.title(), Modifier.testTag("foundation-route-body"))
}
},
diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/ui/shell/FoundationNetworkModelTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/ui/shell/FoundationNetworkModelTest.kt
@@ -0,0 +1,103 @@
+package org.harvestcircle.ui.shell
+
+import org.harvestcircle.application.ActiveIdentity
+import org.harvestcircle.application.ApplicationErrorCategory
+import org.harvestcircle.application.ApplicationErrorCode
+import org.harvestcircle.application.ApplicationLifecycle
+import org.harvestcircle.application.ApplicationProblem
+import org.harvestcircle.application.ApplicationSnapshot
+import org.harvestcircle.application.BuildInfo
+import org.harvestcircle.application.HarvestCirclePresenterState
+import org.harvestcircle.application.HarvestCircleRoute
+import org.harvestcircle.application.HarvestCircleShellState
+import org.harvestcircle.application.IdentityId
+import org.harvestcircle.application.IdentitySummary
+import org.harvestcircle.application.ProfileLoadState
+import org.harvestcircle.application.RecoveryAction
+import org.harvestcircle.application.RelayConnectionState
+import org.harvestcircle.application.RelayDestination
+import org.harvestcircle.application.RelayEndpoint
+import org.harvestcircle.application.RelaySummary
+import org.harvestcircle.application.SessionLifecycle
+import org.harvestcircle.application.ShellSessionState
+import org.harvestcircle.application.SignerAvailability
+import org.harvestcircle.application.SignerBindingKind
+import org.harvestcircle.application.SignerBindingSummary
+import org.harvestcircle.application.SnapshotRevision
+import org.harvestcircle.application.UnixSeconds
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class FoundationNetworkModelTest {
+ @Test
+ fun mapsActiveRelayRuntimeAndOperationTruth() {
+ val model = foundationNetworkModel(shellState())
+ assertEquals("Local identity active", model.identityState)
+ assertEquals("Grower identity", model.identityLabel)
+ assertEquals("Degraded", model.relayState)
+ assertEquals("Public", model.relays.single().destination)
+ assertEquals("Read available", model.relays.single().readState)
+ assertEquals("Write unavailable", model.relays.single().writeState)
+ assertEquals("Degraded", model.runtimeState)
+ assertEquals("Runtime degraded.", model.runtimeProblem)
+ assertEquals(1, model.pendingOperations)
+ }
+
+ @Test
+ fun readOnlyOverridesSignerAuthorityWithoutInventingNetworkState() {
+ val model = foundationNetworkModel(shellState(readOnly = true, active = false))
+ assertEquals("Read-only", model.identityState)
+ assertEquals("Not yet observed", model.relayState)
+ }
+}
+
+private fun shellState(
+ readOnly: Boolean = false,
+ active: Boolean = true,
+): HarvestCircleShellState {
+ val identity =
+ IdentitySummary(
+ IdentityId.fromPublicKeyHex("11".repeat(32)),
+ "npub1grower",
+ "Grower identity",
+ SignerBindingSummary(SignerBindingKind.LocalKeyring, SignerAvailability.Available),
+ UnixSeconds(1),
+ null,
+ )
+ val relay = RelayEndpoint("wss://relay.example", RelayDestination.Public, read = true, write = false)
+ val problem =
+ ApplicationProblem(
+ ApplicationErrorCode.StorageUnavailable,
+ ApplicationErrorCategory.Storage,
+ retryable = true,
+ RecoveryAction.Retry,
+ operationId = null,
+ safeMessage = "Runtime degraded.",
+ )
+ val snapshot =
+ ApplicationSnapshot(
+ SnapshotRevision(2UL),
+ ApplicationLifecycle.Degraded,
+ lifecycleProblem = problem,
+ configuredRelays = listOf(relay),
+ identities = listOf(identity),
+ selectedIdentityId = identity.id,
+ session = if (active) SessionLifecycle.Active else SessionLifecycle.SignedOut,
+ sessionSubjectIdentityId = identity.id.takeIf { active },
+ sessionProblem = null,
+ activeIdentity =
+ ActiveIdentity(
+ identity,
+ RelaySummary(listOf(relay), RelayConnectionState.Degraded),
+ ProfileLoadState.Empty,
+ profile = null,
+ ).takeIf { active },
+ recoverableProblem = null,
+ )
+ val presenter = HarvestCirclePresenterState(snapshot, route = HarvestCircleRoute.ACTIVE_IDENTITY, busy = true)
+ return HarvestCircleShellState(
+ identity = presenter,
+ buildInfo = BuildInfo.unknown(),
+ session = ShellSessionState(readOnly),
+ )
+}
diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreenTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreenTest.kt
@@ -0,0 +1,70 @@
+package org.harvestcircle.ui.shell
+
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.assertCountEquals
+import androidx.compose.ui.test.onAllNodesWithText
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.v2.runComposeUiTest
+import kotlin.test.Test
+
+@OptIn(ExperimentalTestApi::class)
+class FoundationNetworkScreenTest {
+ @Test
+ fun overviewIsTruthfulAndClaimsNoManagedService() =
+ runComposeUiTest {
+ setContent { FoundationNetworkScreen(model()) }
+ onNodeWithText("Signer").assertExists()
+ onNodeWithText("Local identity active").assertExists()
+ onNodeWithText("No managed HarvestCircle service is configured.").assertExists()
+ onAllNodesWithText("Authority").assertCountEquals(0)
+ onAllNodesWithText("Inbox").assertCountEquals(0)
+ }
+
+ @Test
+ fun identityRelaysAndRuntimeUseOnlyTheSuppliedFoundationState() =
+ runComposeUiTest {
+ setContent { FoundationNetworkScreen(model()) }
+ onNodeWithTag("network-tab-identity").performClick()
+ onNodeWithText("Grower identity").assertExists()
+ onNodeWithTag("network-tab-public_relays").performClick()
+ onNodeWithText("wss://relay.example").assertExists()
+ onNodeWithText("Public").assertExists()
+ onNodeWithText("Read available").assertExists()
+ onNodeWithText("Write unavailable").assertExists()
+ onNodeWithText("Degraded").assertExists()
+ onNodeWithTag("network-tab-runtime").performClick()
+ onNodeWithText("Storage is temporarily unavailable.").assertExists()
+ }
+
+ @Test
+ fun signedOutAndReadOnlyStatesRemainExplicit() =
+ runComposeUiTest {
+ setContent { FoundationNetworkScreen(model(identityState = "Read-only", relays = emptyList())) }
+ onNodeWithText("Read-only").assertExists()
+ onNodeWithTag("network-tab-public_relays").performClick()
+ onNodeWithText("Not yet observed").assertExists()
+ }
+}
+
+private fun model(
+ identityState: String = "Local identity active",
+ relays: List<NetworkRelayModel> =
+ listOf(
+ NetworkRelayModel(
+ url = "wss://relay.example",
+ destination = "Public",
+ readState = "Read available",
+ writeState = "Write unavailable",
+ ),
+ ),
+) = FoundationNetworkModel(
+ identityState = identityState,
+ identityLabel = "Grower identity",
+ relayState = if (relays.isEmpty()) "Not yet observed" else "Degraded",
+ relays = relays,
+ runtimeState = "Degraded",
+ runtimeProblem = "Storage is temporarily unavailable.",
+ pendingOperations = 1,
+)