mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 19:53:08 +00:00
feat(app): confirm step + honest feedback on the workflow approval gate
Approving publishes a signed grant that makes the runner push code and open
a PR — too consequential for a single stray tap. Tapping Approve (or Deny)
now raises a confirm dialog that states the boundary plainly ("Approving
never merges or deploys — that still happens on GitHub"); denying warns the
work is discarded. On confirm, a snackbar gives immediate, truthful feedback
("Approved — the runner is opening a pull request"), and the card moves to
Working/Approved, then genuinely lands in Shipped with the PR link when the
runner's 46005 arrives — rather than faking "PR opened" at tap time.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
This commit is contained in:
+95
-9
@@ -48,6 +48,7 @@ import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -60,6 +61,8 @@ import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -69,6 +72,7 @@ import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -96,6 +100,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20dp
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* The shared **workflow run board** of one Buzz channel — where a team drives an AI agent under a
|
||||
@@ -131,9 +136,13 @@ fun WorkflowRunBoardScreen(
|
||||
val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) }
|
||||
|
||||
var composing by remember { mutableStateOf(false) }
|
||||
var pending by remember { mutableStateOf<PendingDecision?>(null) }
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Scaffold(
|
||||
topBar = { TopBarWithBackButton("Workflow runs", nav) },
|
||||
snackbarHost = { SnackbarHost(snackbar) },
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { composing = true },
|
||||
@@ -156,10 +165,12 @@ fun WorkflowRunBoardScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
contentPadding = PaddingValues(top = 10.dp, bottom = 96.dp),
|
||||
) {
|
||||
section("Needs your approval", groups.awaiting, RunStyle.GATE, me, accountViewModel, nav, viewModel)
|
||||
section("Working now", groups.active, RunStyle.ACTIVE, me, accountViewModel, nav, viewModel)
|
||||
section("Shipped", groups.done, RunStyle.SHIPPED, me, accountViewModel, nav, viewModel)
|
||||
section("Closed", groups.closed, RunStyle.CLOSED, me, accountViewModel, nav, viewModel)
|
||||
section("Needs your approval", groups.awaiting, RunStyle.GATE, me, accountViewModel, nav) { run, grant ->
|
||||
pending = PendingDecision(run, grant)
|
||||
}
|
||||
section("Working now", groups.active, RunStyle.ACTIVE, me, accountViewModel, nav)
|
||||
section("Shipped", groups.done, RunStyle.SHIPPED, me, accountViewModel, nav)
|
||||
section("Closed", groups.closed, RunStyle.CLOSED, me, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,10 +194,37 @@ fun WorkflowRunBoardScreen(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Approving pushes code and opens a PR; denying discards it — both consequential enough to confirm.
|
||||
pending?.let { decision ->
|
||||
ConfirmDecisionDialog(
|
||||
decision = decision,
|
||||
onDismiss = { pending = null },
|
||||
onConfirm = {
|
||||
if (decision.grant) viewModel.approve(decision.run.runId) else viewModel.deny(decision.run.runId)
|
||||
pending = null
|
||||
scope.launch {
|
||||
snackbar.showSnackbar(
|
||||
if (decision.grant) {
|
||||
"Approved — the runner is opening a pull request"
|
||||
} else {
|
||||
"Denied — the work was discarded"
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private enum class RunStyle { GATE, ACTIVE, SHIPPED, CLOSED }
|
||||
|
||||
/** A grant/deny the approver has tapped but not yet confirmed. */
|
||||
private data class PendingDecision(
|
||||
val run: WorkflowRun,
|
||||
val grant: Boolean,
|
||||
)
|
||||
|
||||
/** The runs bucketed by lifecycle; awaiting-a-human first (those block the room). */
|
||||
private class RunGroups(
|
||||
val awaiting: List<WorkflowRun>,
|
||||
@@ -222,7 +260,7 @@ private fun LazyListScope.section(
|
||||
me: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
viewModel: WorkflowRunBoardViewModel,
|
||||
onDecide: (WorkflowRun, Boolean) -> Unit = { _, _ -> },
|
||||
) {
|
||||
if (runs.isEmpty()) return
|
||||
item(key = "header-$title") {
|
||||
@@ -230,7 +268,7 @@ private fun LazyListScope.section(
|
||||
}
|
||||
items(runs, key = { it.runId }) { run ->
|
||||
if (style == RunStyle.GATE) {
|
||||
GateCard(run, me, accountViewModel, nav, viewModel)
|
||||
GateCard(run, me, accountViewModel, nav, onDecide)
|
||||
} else {
|
||||
RunCard(run, style, accountViewModel, nav)
|
||||
}
|
||||
@@ -278,7 +316,7 @@ private fun LazyItemScope.GateCard(
|
||||
me: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
viewModel: WorkflowRunBoardViewModel,
|
||||
onDecide: (WorkflowRun, Boolean) -> Unit,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val glowT = rememberInfiniteTransition(label = "gate")
|
||||
@@ -337,8 +375,8 @@ private fun LazyItemScope.GateCard(
|
||||
|
||||
if (mine) {
|
||||
ApprovalActions(
|
||||
onApprove = { viewModel.approve(run.runId) },
|
||||
onDeny = { viewModel.deny(run.runId) },
|
||||
onApprove = { onDecide(run, true) },
|
||||
onDeny = { onDecide(run, false) },
|
||||
)
|
||||
} else {
|
||||
WaitingPill(run.pendingApprover, accountViewModel, nav)
|
||||
@@ -376,6 +414,54 @@ private fun ApprovalActions(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The confirm step before a decision is published. Approving is a real commitment (it authorizes a
|
||||
* push + PR), so it gets a filled confirm; denying discards work, so it gets an error-toned confirm.
|
||||
* The copy states the boundary plainly: approving never merges or deploys.
|
||||
*/
|
||||
@Composable
|
||||
private fun ConfirmDecisionDialog(
|
||||
decision: PendingDecision,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
) {
|
||||
val task = decision.run.task?.takeIf { it.isNotBlank() } ?: "this run"
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = {
|
||||
Icon(
|
||||
symbol = if (decision.grant) MaterialSymbols.Gavel else MaterialSymbols.Close,
|
||||
contentDescription = null,
|
||||
tint = if (decision.grant) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
title = { Text(if (decision.grant) "Approve this run?" else "Deny this run?") },
|
||||
text = {
|
||||
Text(
|
||||
if (decision.grant) {
|
||||
"The runner will push its branch and open a pull request for “$task”. Approving never merges or deploys — that still happens on GitHub."
|
||||
} else {
|
||||
"The agent's work for “$task” will be discarded. This can't be undone."
|
||||
},
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
if (decision.grant) {
|
||||
Button(onClick = onConfirm) {
|
||||
Icon(symbol = MaterialSymbols.CheckCircle, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Approve & open PR")
|
||||
}
|
||||
} else {
|
||||
TextButton(onClick = onConfirm, colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)) {
|
||||
Text("Deny", fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
/** Who the room is waiting on, for members who aren't the approver. */
|
||||
@Composable
|
||||
private fun WaitingPill(
|
||||
|
||||
Reference in New Issue
Block a user