commit 9f11bd08d04d0fac4a87277753d800615f4eaf92
parent 3359c65307c7536f43ef673bd0ec5958bc78f01a
Author: triesap <tyson@radroots.org>
Date: Wed, 12 Aug 2026 19:52:07 +0000
ui: localize overlay busy state
- derive confirmation blocking only from the active confirmation phase
- keep reference and status overlays interactive during unrelated identity work
- restore rejected confirmations to a ready state immediately
- cover reducer, presenter, and Compose interaction behavior
Diffstat:
7 files changed, 107 insertions(+), 12 deletions(-)
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellOverlays.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellOverlays.kt
@@ -167,7 +167,6 @@ object OverlayReducer {
val admitted = state.identity.removalConfirmation ?: return OverlayTransition(state)
if (current.action != expected ||
current.phase != ConfirmationPhase.Ready ||
- state.identity.busy ||
state.identity.removalStatus != RemovalStatus.AWAITING_CONFIRMATION ||
admitted.identityId != removal.identityId ||
admitted.requestId != removal.requestId
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellReducer.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellReducer.kt
@@ -86,7 +86,6 @@ object ShellReducer {
if (current?.action == action) {
val admissionRejected =
current.busy &&
- !identity.busy &&
identity.removalStatus == RemovalStatus.AWAITING_CONFIRMATION &&
identity.commandStatus in REMOVAL_ADMISSION_FAILURES
return if (admissionRejected) {
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHost.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHost.kt
@@ -36,7 +36,6 @@ import org.harvestcircle.application.StatusOverlayKey
fun FoundationOverlayHost(
state: OverlayState,
status: ShellStatusModel,
- busy: Boolean = false,
onIntent: (OverlayIntent) -> Unit,
) {
status.banner?.let { banner ->
@@ -50,7 +49,7 @@ fun FoundationOverlayHost(
}
}
state.current?.let { overlay ->
- val overlayBusy = busy || (overlay as? FoundationOverlay.ConfirmAction)?.busy == true
+ val overlayBusy = (overlay as? FoundationOverlay.ConfirmAction)?.busy == true
val rootRequester = remember { FocusRequester() }
Dialog(
onDismissRequest = {
@@ -67,7 +66,7 @@ fun FoundationOverlayHost(
Modifier
.focusGroup()
.focusRequester(rootRequester)
- .focusable(overlayBusy && overlay is FoundationOverlay.ConfirmAction)
+ .focusable(overlayBusy)
.semantics {
contentDescription = "Dialog: ${overlay.title()}"
paneTitle = overlay.title()
@@ -81,7 +80,7 @@ fun FoundationOverlayHost(
StatusOverlayKey.Signer -> StatusOverlay("Signer status", status.signer.text, onIntent)
StatusOverlayKey.Sync -> StatusOverlay("Sync status", status.sync.text, onIntent)
}
- is FoundationOverlay.OpenNostrReference -> ReferenceOverlay(overlay, busy, onIntent)
+ is FoundationOverlay.OpenNostrReference -> ReferenceOverlay(overlay, onIntent)
}
}
}
@@ -164,7 +163,6 @@ private fun StatusOverlay(
@Composable
private fun ReferenceOverlay(
overlay: FoundationOverlay.OpenNostrReference,
- busy: Boolean,
onIntent: (OverlayIntent) -> Unit,
) {
val inputRequester = remember { FocusRequester() }
@@ -185,7 +183,6 @@ private fun ReferenceOverlay(
previous = cancelRequester
}.modalFocusCycle(submitRequester, cancelRequester)
.testTag("nostr-reference-input"),
- enabled = !busy,
)
overlay.result?.let { ShellText(it.message, Modifier.testTag("nostr-reference-result")) }
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
@@ -200,7 +197,6 @@ private fun ReferenceOverlay(
previous = inputRequester
}.modalFocusCycle(cancelRequester, inputRequester)
.testTag("nostr-reference-submit"),
- enabled = !busy,
kind = ShellButtonKind.Primary,
)
ShellButton(
@@ -214,7 +210,6 @@ private fun ReferenceOverlay(
previous = submitRequester
}.modalFocusCycle(inputRequester, submitRequester)
.testTag("overlay-cancel"),
- !busy,
)
}
}
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
@@ -105,7 +105,7 @@ private fun HarvestCircleShellContent(
}
}
ShellFocusRestorer(state.overlays.restoreFocus, fallback)
- FoundationOverlayHost(state.overlays, deriveShellStatus(state), state.identity.busy) {
+ FoundationOverlayHost(state.overlays, deriveShellStatus(state)) {
dispatch(HarvestCircleShellIntent.Overlay(it))
}
}
diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt
@@ -120,6 +120,41 @@ class HarvestCircleShellPresenterTest {
}
@Test
+ fun hcSl006BusyRejectedConfirmationReturnsToReady() =
+ runTest {
+ val identityId = IdentityId.fromPublicKeyHex("04".repeat(32))
+ val requestId = RemovalRequestId.from("removal-shell-busy")
+ val identity =
+ FakeIdentityPresentation(
+ presenterState(HarvestCircleRoute.IDENTITIES)
+ .withRemovalConfirmation(identityId, requestId)
+ .copy(busy = true, commandStatus = CommandStatus.RUNNING),
+ )
+ val presenter = HarvestCircleShellPresenter(identity, BuildInfo.unknown(), this)
+ runCurrent()
+ val action = ConfirmationAction.RemoveLocalIdentity(identityId, requestId)
+
+ presenter.dispatch(HarvestCircleShellIntent.Overlay(OverlayIntent.Confirm(action)))
+ assertEquals(
+ ConfirmationPhase.Submitting,
+ (presenter.state.value.overlays.current as FoundationOverlay.ConfirmAction).phase,
+ )
+
+ identity.state.value =
+ identity.state.value.copy(
+ busy = true,
+ commandStatus = CommandStatus.REJECTED_BUSY,
+ )
+ runCurrent()
+
+ assertEquals(
+ ConfirmationPhase.Ready,
+ (presenter.state.value.overlays.current as FoundationOverlay.ConfirmAction).phase,
+ )
+ presenter.close()
+ }
+
+ @Test
fun replacedConfirmationRejectsThePriorTokenAndAdmitsOnlyTheCurrentToken() =
runTest {
val identityId = IdentityId.fromPublicKeyHex("04".repeat(32))
diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellOverlaysTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellOverlaysTest.kt
@@ -84,6 +84,50 @@ class ShellOverlaysTest {
}
@Test
+ fun hcSl006UnrelatedIdentityBusyDoesNotBlockReadyConfirmationAdmission() {
+ val identityId = IdentityId.fromPublicKeyHex("02".repeat(32))
+ val requestId = RemovalRequestId.from("overlay-local-busy")
+ val action = ConfirmationAction.RemoveLocalIdentity(identityId, requestId)
+ val confirmation =
+ IdentityRemovalConfirmation(
+ identityId = identityId,
+ requestId = requestId,
+ deletesLocalCredential = true,
+ signsOut = false,
+ expiresAt = UnixSeconds(60),
+ )
+ val initial =
+ shellState(
+ FoundationOverlay.ConfirmAction(
+ title = "Remove this saved identity?",
+ explanation = "Its local credential will be deleted.",
+ actionLabel = "Remove local identity",
+ action = action,
+ ),
+ )
+ val busy =
+ initial.copy(
+ identity =
+ initial.identity.copy(
+ busy = true,
+ removalConfirmation = confirmation,
+ removalStatus = RemovalStatus.AWAITING_CONFIRMATION,
+ ),
+ )
+
+ val transition = OverlayReducer.transition(busy, OverlayIntent.Confirm(action))
+
+ assertEquals(
+ ConfirmationPhase.Submitting,
+ (transition.state.overlays.current as FoundationOverlay.ConfirmAction).phase,
+ )
+ assertEquals(
+ listOf(ShellEffect.DispatchIdentity(HarvestCircleIntent.ConfirmIdentityRemoval(identityId, requestId))),
+ transition.effects,
+ )
+ }
+
+ @Test
fun deferredOverlaysHaveNoFoundationStateConstructor() {
val deferred = HarvestCircleSurfaceRegistry.overlays.filter { it.availability.name.startsWith("Deferred") }
assertEquals(5, deferred.size)
diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHostTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHostTest.kt
@@ -6,6 +6,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertCountEquals
+import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.isDialog
@@ -19,6 +20,7 @@ import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.v2.runComposeUiTest
import org.harvestcircle.application.BannerSeverity
import org.harvestcircle.application.ConfirmationAction
+import org.harvestcircle.application.ConfirmationPhase
import org.harvestcircle.application.FoundationOverlay
import org.harvestcircle.application.GlobalStatusBanner
import org.harvestcircle.application.OverlayIntent
@@ -123,9 +125,10 @@ class FoundationOverlayHostTest {
"The local credential will be deleted.",
"Remove local identity",
removalAction(),
+ phase = ConfirmationPhase.Submitting,
),
)
- setContent { FoundationOverlayHost(state, status(), busy = true, onIntent = intents::add) }
+ setContent { FoundationOverlayHost(state, status(), onIntent = intents::add) }
onNodeWithTag("foundation-overlay").assertIsFocused()
onNodeWithTag("foundation-overlay").performKeyInput {
@@ -139,6 +142,26 @@ class FoundationOverlayHostTest {
}
@Test
+ fun hcSl006ReferenceAndStatusControlsRemainLocallyInteractive() =
+ runComposeUiTest {
+ var state by mutableStateOf(OverlayState(current = FoundationOverlay.OpenNostrReference()))
+ setContent {
+ FoundationOverlayHost(state, status()) { intent ->
+ state = reduceOverlay(state, intent)
+ }
+ }
+
+ onNodeWithTag("nostr-reference-input").assertIsEnabled().performTextInput("note1candidate")
+ onNodeWithTag("nostr-reference-submit").assertIsEnabled()
+ onNodeWithTag("overlay-cancel").assertIsEnabled().performClick()
+ onAllNodesWithTag("foundation-overlay").assertCountEquals(0)
+
+ state = OverlayState(FoundationOverlay.Status(org.harvestcircle.application.StatusOverlayKey.Sync))
+ onNodeWithTag("overlay-close").assertIsEnabled().performClick()
+ onAllNodesWithTag("foundation-overlay").assertCountEquals(0)
+ }
+
+ @Test
fun referenceFocusWrapsAcrossInputAndActions() =
runComposeUiTest {
val state = OverlayState(current = FoundationOverlay.OpenNostrReference())