mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
feat(buzz): shared per-channel Jobs board (P0) for the agent backlog
Evaluate placement, then add the first interactive agent surface in the app. Placement: the existing agent screens (AgentConsole/Attestation/PersonaEdit) are owner-global telemetry entered per-relay and buried behind a channel-list footer. A Buzz job is h-scoped to a channel, so the backlog is per-channel — it belongs where Canvas/Forum already live (RelayGroupTopBar, gated by BuzzRelayDialect.isBuzz), not inside the owner Console. Keep the two surfaces separate. - JobBoardScreen + JobBoardViewModel (ui/screen/loggedIn/buzz/): the shared backlog of one channel. Reads the job kinds (43001-43006) + their kind-7 upvotes scoped to the channel h, folds via the shared BuzzJobAggregator, groups by lifecycle state (In progress / Queued-by-upvotes / Done / Closed), live via subscribeAsFlow. Every member sees the same board. - Three write actions via new Account helpers: fileBuzzJob (43001, FAB → New task dialog), upvoteBuzzJob (kind-7 "+" e-tagging the job, h-scoped so the scheduler + board count it), cancelBuzzJob (43005, own jobs only). Merge is deliberately NOT here — a done job's result is its PR; merge happens on GitHub. - Route.BuzzJobBoard(channelId, relayUrl) + AppNavigation wiring + a Checklist entry in RelayGroupTopBar next to the Canvas action. Reuses the AgentConsoleViewModel fetch/watch pattern and existing MaterialSymbols (no font regen). App compiles (fdroidDebug). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
This commit is contained in:
@@ -162,6 +162,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concor
|
||||
import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent
|
||||
import com.vitorpamplona.quartz.buzz.dm.DmHideEvent
|
||||
import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobCancelEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent
|
||||
import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent
|
||||
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminAddMemberEvent
|
||||
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminRemoveMemberEvent
|
||||
@@ -237,6 +239,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNu
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasMoreHashtagsThan
|
||||
@@ -289,6 +292,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.previous
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
|
||||
import com.vitorpamplona.quartz.nip32Labeling.LabelEvent
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
|
||||
import com.vitorpamplona.quartz.nip37Drafts.DraftEventCache
|
||||
@@ -3266,6 +3270,53 @@ class Account(
|
||||
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
}
|
||||
|
||||
/**
|
||||
* File a Buzz agent job (kind-43001) into channel [channelId] on [relay] — a shared
|
||||
* feature-request the workspace bot can pick up. Untargeted: any agent watching the
|
||||
* channel may accept it. Returns the new job id (the request event id), or null when the
|
||||
* account can't write. See [com.vitorpamplona.amethyst.commons.model.buzz.BuzzJobAggregator].
|
||||
*/
|
||||
suspend fun fileBuzzJob(
|
||||
relay: NormalizedRelayUrl,
|
||||
channelId: String,
|
||||
request: String,
|
||||
): HexKey? {
|
||||
if (!isWriteable()) return null
|
||||
val signed = signer.sign(JobRequestEvent.build(request, channelId, null))
|
||||
client.publish(signed, setOf(relay))
|
||||
return signed.id
|
||||
}
|
||||
|
||||
/** Cancel a Buzz job [jobId] with a kind-43005 scoped to [channelId] on [relay]. */
|
||||
suspend fun cancelBuzzJob(
|
||||
relay: NormalizedRelayUrl,
|
||||
channelId: String,
|
||||
jobId: HexKey,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
val signed = signer.sign(JobCancelEvent.build(jobId, "", channelId))
|
||||
client.publish(signed, setOf(relay))
|
||||
}
|
||||
|
||||
/**
|
||||
* Upvote a Buzz job [jobId] — a NIP-25 like (kind-7 `+`) `e`-tagging the request and
|
||||
* `h`-scoped to [channelId] so the scheduler (and the board) count it toward priority.
|
||||
*/
|
||||
suspend fun upvoteBuzzJob(
|
||||
relay: NormalizedRelayUrl,
|
||||
channelId: String,
|
||||
jobId: HexKey,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
val template =
|
||||
eventTemplate<ReactionEvent>(ReactionEvent.KIND, ReactionEvent.LIKE) {
|
||||
addUnique(ETag.assemble(jobId, null, null))
|
||||
addUnique(GroupIdTag.assemble(channelId))
|
||||
}
|
||||
val signed = signer.sign(template)
|
||||
client.publish(signed, setOf(relay))
|
||||
}
|
||||
|
||||
/** Send a kind 9022 leave request to the host relay and drop it from our list. */
|
||||
suspend fun leaveRelayGroup(channel: RelayGroupChannel) {
|
||||
val template = LeaveRequestEvent.build(channel.groupId.id)
|
||||
|
||||
@@ -110,6 +110,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzForumPostScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzForumThreadScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzInviteScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzNewDmScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.JobBoardScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarCollectionsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarReminderSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsScreen
|
||||
@@ -773,6 +774,7 @@ fun BuildNavigation(
|
||||
)
|
||||
}
|
||||
composableFromEndArgs<Route.BuzzCanvas> { BuzzCanvasScreen(it.channelId, it.relayUrl, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.BuzzJobBoard> { JobBoardScreen(it.channelId, it.relayUrl, accountViewModel, nav) }
|
||||
composableFromBottomArgs<Route.BuzzForumPost> { BuzzForumPostScreen(it.channelId, it.relayUrl, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.BuzzForumThread> { BuzzForumThreadScreen(it.channelId, it.relayUrl, it.rootId, accountViewModel, nav) }
|
||||
|
||||
|
||||
@@ -711,6 +711,11 @@ sealed class Route {
|
||||
val relayUrl: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class BuzzJobBoard(
|
||||
val channelId: String,
|
||||
val relayUrl: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class BuzzForumPost(
|
||||
val channelId: String,
|
||||
val relayUrl: String,
|
||||
|
||||
+322
@@ -0,0 +1,322 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.JobState
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.JobView
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
/**
|
||||
* The shared **backlog** of one Buzz channel — where a team drives an AI agent together.
|
||||
*
|
||||
* Every member sees the same board: file a task (kind-43001), upvote to reprioritize (kind-7),
|
||||
* and watch the workspace bot work items back through accept → progress → result/error
|
||||
* (43002-43006). Jobs are grouped by lifecycle state; the queue is ordered by the group's
|
||||
* upvotes. The heavy lifting is the shared
|
||||
* [com.vitorpamplona.amethyst.commons.model.buzz.BuzzJobAggregator]; this screen renders its
|
||||
* [JobView] output and routes the three write actions through [JobBoardViewModel].
|
||||
*
|
||||
* Merge is deliberately NOT here — a completed job's result is its PR; the merge happens on
|
||||
* GitHub, the only human gate left.
|
||||
*/
|
||||
@Composable
|
||||
fun JobBoardScreen(
|
||||
channelId: String,
|
||||
relayUrl: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val me = accountViewModel.account.userProfile().pubkeyHex
|
||||
val viewModel: JobBoardViewModel = viewModel(key = "JobBoard-$relayUrl-$channelId")
|
||||
viewModel.bind(accountViewModel.account, channelId, relayUrl)
|
||||
|
||||
DisposableEffect(channelId) {
|
||||
viewModel.startWatching()
|
||||
onDispose { viewModel.stopWatching() }
|
||||
}
|
||||
|
||||
val jobs by viewModel.jobs.collectAsStateWithLifecycle()
|
||||
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
|
||||
|
||||
var composing by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
topBar = { TopBarWithBackButton("Backlog", nav) },
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = { composing = true }, shape = CircleShape) {
|
||||
Icon(symbol = MaterialSymbols.Add, contentDescription = "New task")
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Box(modifier = Modifier.padding(padding).fillMaxSize()) {
|
||||
val running = jobs.filter { it.state == JobState.IN_PROGRESS || it.state == JobState.ACCEPTED }
|
||||
val queued =
|
||||
jobs
|
||||
.filter { it.state == JobState.REQUESTED }
|
||||
.sortedWith(compareByDescending<JobView> { it.upvotes }.thenBy { it.createdAt })
|
||||
val done = jobs.filter { it.state == JobState.COMPLETED }
|
||||
val closed = jobs.filter { it.state == JobState.FAILED || it.state == JobState.CANCELLED }
|
||||
|
||||
if (jobs.isEmpty() && !isLoading) {
|
||||
EmptyBoard()
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
contentPadding = PaddingValues(vertical = 12.dp),
|
||||
) {
|
||||
section("In progress", running, me, viewModel)
|
||||
section("Queued", queued, me, viewModel)
|
||||
section("Done", done, me, viewModel)
|
||||
section("Closed", closed, me, viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp).size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (composing) {
|
||||
NewTaskDialog(
|
||||
onDismiss = { composing = false },
|
||||
onFile = { text ->
|
||||
viewModel.file(text)
|
||||
composing = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.section(
|
||||
title: String,
|
||||
jobs: List<JobView>,
|
||||
me: String,
|
||||
viewModel: JobBoardViewModel,
|
||||
) {
|
||||
if (jobs.isEmpty()) return
|
||||
item(key = "header-$title") {
|
||||
Text(
|
||||
text = "$title · ${jobs.size}",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
items(jobs, key = { it.jobId }) { job ->
|
||||
JobCard(job, me, viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun JobCard(
|
||||
job: JobView,
|
||||
me: String,
|
||||
viewModel: JobBoardViewModel,
|
||||
) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
StatePill(job.state)
|
||||
UpvoteChip(job.upvotes) { viewModel.upvote(job.jobId) }
|
||||
}
|
||||
|
||||
Text(
|
||||
text = job.request?.takeIf { it.isNotBlank() } ?: "(no description)",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
|
||||
val meta =
|
||||
buildString {
|
||||
append("by ${shortKey(job.requester)}")
|
||||
job.agent?.let { append(" · agent ${shortKey(it)}") }
|
||||
}
|
||||
Text(
|
||||
text = meta,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
when (job.state) {
|
||||
JobState.COMPLETED ->
|
||||
job.result?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
JobState.FAILED ->
|
||||
job.error?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
JobState.CANCELLED ->
|
||||
job.cancelReason?.let {
|
||||
Text("Cancelled: $it", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
else ->
|
||||
job.lastProgress?.takeIf { it.isNotBlank() }?.let {
|
||||
Text("… $it", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
// The requester can cancel their own job while it's still open.
|
||||
if (!job.isTerminal && job.requester == me) {
|
||||
TextButton(onClick = { viewModel.cancel(job.jobId) }, modifier = Modifier.align(Alignment.End)) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatePill(state: JobState) {
|
||||
val (label, symbol, color) = stateStyle(state)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Icon(symbol = symbol, contentDescription = null, tint = color, modifier = Modifier.size(16.dp))
|
||||
Text(text = label, style = MaterialTheme.typography.labelMedium, color = color, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UpvoteChip(
|
||||
count: Int,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.clickable(onClick = onClick).padding(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Icon(symbol = MaterialSymbols.ThumbUp, contentDescription = "Upvote", modifier = Modifier.size(18.dp))
|
||||
Text(text = count.toString(), style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NewTaskDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onFile: (String) -> Unit,
|
||||
) {
|
||||
var text by remember { mutableStateOf("") }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("New task") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
label = { Text("Describe the feature or fix") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 3,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onFile(text.trim()) },
|
||||
enabled = text.isNotBlank(),
|
||||
) { Text("File") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyBoard() {
|
||||
Box(modifier = Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = "No tasks yet. Tap + to ask the workspace agent to build or fix something — the whole channel will see it.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class StateStyle(
|
||||
val label: String,
|
||||
val symbol: MaterialSymbol,
|
||||
val color: Color,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun stateStyle(state: JobState): StateStyle =
|
||||
when (state) {
|
||||
JobState.REQUESTED -> StateStyle("Queued", MaterialSymbols.Schedule, MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
JobState.ACCEPTED -> StateStyle("Picked up", MaterialSymbols.Bolt, MaterialTheme.colorScheme.primary)
|
||||
JobState.IN_PROGRESS -> StateStyle("Working", MaterialSymbols.Bolt, MaterialTheme.colorScheme.primary)
|
||||
JobState.COMPLETED -> StateStyle("Done", MaterialSymbols.CheckCircle, MaterialTheme.colorScheme.tertiary)
|
||||
JobState.FAILED -> StateStyle("Failed", MaterialSymbols.Error, MaterialTheme.colorScheme.error)
|
||||
JobState.CANCELLED -> StateStyle("Cancelled", MaterialSymbols.Cancel, MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
|
||||
private fun shortKey(hex: String?): String =
|
||||
when {
|
||||
hex == null -> "unknown"
|
||||
hex.length <= 16 -> hex
|
||||
else -> hex.take(8) + "…" + hex.takeLast(4)
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzJobAggregator
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.JobView
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* Backing ViewModel for the [JobBoardScreen] — the shared backlog of one Buzz channel.
|
||||
*
|
||||
* A Buzz "job" is the agent-job protocol (kinds 43001-43006): a member files a request, the
|
||||
* workspace bot works it, and every step lands as a signed event the whole room sees. This VM
|
||||
* fetches those events (plus their kind-7 upvotes) scoped to the channel `h`, folds them into
|
||||
* per-job [JobView] records via the shared [BuzzJobAggregator], and exposes the backlog as a
|
||||
* [StateFlow]. It also drives the three write actions the board offers: file, upvote, cancel.
|
||||
*
|
||||
* The heavy lifting (correlation, state machine, upvote priority) lives in `commons`; this VM is
|
||||
* the Android glue (fetch → LocalCache → re-derive → publish via [Account]).
|
||||
*/
|
||||
class JobBoardViewModel : ViewModel() {
|
||||
@Volatile private var account: Account? = null
|
||||
private var relay: NormalizedRelayUrl? = null
|
||||
private var channelId: String? = null
|
||||
|
||||
private val _jobs = MutableStateFlow<List<JobView>>(emptyList())
|
||||
val jobs: StateFlow<List<JobView>> = _jobs.asStateFlow()
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
|
||||
private var watchJob: Job? = null
|
||||
private val reloadMutex = Mutex()
|
||||
|
||||
fun bind(
|
||||
account: Account,
|
||||
channelId: String,
|
||||
relayUrl: String,
|
||||
) {
|
||||
if (this.account != null) return
|
||||
this.account = account
|
||||
this.channelId = channelId
|
||||
this.relay = RelayUrlNormalizer.normalizeOrNull(relayUrl)
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
val account = account ?: return
|
||||
val relay = relay ?: return
|
||||
val channelId = channelId ?: return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
_isLoading.value = true
|
||||
try {
|
||||
account.client.fetchAllWithHooks(
|
||||
filters = mapOf(relay to boardFilters(channelId)),
|
||||
timeoutMs = 8_000,
|
||||
pendingOnAuthRequired = true,
|
||||
) { _, _ -> false }
|
||||
reloadFromCache(channelId)
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep the board live while it's on screen: any batch of job/reaction events re-derives it. */
|
||||
fun startWatching() {
|
||||
val account = account ?: return
|
||||
val relay = relay ?: return
|
||||
val channelId = channelId ?: return
|
||||
if (watchJob != null) return
|
||||
watchJob =
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
account.client.subscribeAsFlow(relay, boardFilters(channelId)).collect {
|
||||
// The client's global listener already consumed the batch into LocalCache.
|
||||
reloadFromCache(channelId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopWatching() {
|
||||
watchJob?.cancel()
|
||||
watchJob = null
|
||||
}
|
||||
|
||||
private suspend fun reloadFromCache(channelId: String) =
|
||||
reloadMutex.withLock {
|
||||
val events =
|
||||
LocalCache
|
||||
.filter(Filter(kinds = ALL_KINDS, tags = mapOf("h" to listOf(channelId))))
|
||||
.mapNotNull { it.event }
|
||||
_jobs.value = BuzzJobAggregator.aggregate(events)
|
||||
}
|
||||
|
||||
fun file(request: String) =
|
||||
act { account, relay, channelId ->
|
||||
account.fileBuzzJob(relay, channelId, request)
|
||||
}
|
||||
|
||||
fun upvote(jobId: String) =
|
||||
act { account, relay, channelId ->
|
||||
account.upvoteBuzzJob(relay, channelId, jobId)
|
||||
}
|
||||
|
||||
fun cancel(jobId: String) =
|
||||
act { account, relay, channelId ->
|
||||
account.cancelBuzzJob(relay, channelId, jobId)
|
||||
}
|
||||
|
||||
private inline fun act(crossinline block: suspend (Account, NormalizedRelayUrl, String) -> Unit) {
|
||||
val account = account ?: return
|
||||
val relay = relay ?: return
|
||||
val channelId = channelId ?: return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
block(account, relay, channelId)
|
||||
reloadFromCache(channelId) // optimistic local re-derive; the live watch catches relay echoes
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
stopWatching()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
companion object {
|
||||
// The Buzz agent-job protocol (request/accepted/progress/result/cancel/error) plus the
|
||||
// kind-7 upvotes that prioritize it.
|
||||
private val JOB_KINDS = (43001..43006).toList()
|
||||
private val ALL_KINDS = JOB_KINDS + ReactionEvent.KIND
|
||||
|
||||
private fun boardFilters(channelId: String) =
|
||||
listOf(
|
||||
Filter(kinds = JOB_KINDS, tags = mapOf("h" to listOf(channelId))),
|
||||
Filter(kinds = listOf(ReactionEvent.KIND), tags = mapOf("h" to listOf(channelId))),
|
||||
)
|
||||
}
|
||||
}
|
||||
+8
@@ -192,6 +192,14 @@ fun RelayGroupTopBar(
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
// Buzz agent backlog (kinds 43001-43006): the channel's shared job board.
|
||||
IconButton(onClick = { nav.nav(Route.BuzzJobBoard(channel.groupId.id, channel.groupId.relayUrl.url)) }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Checklist,
|
||||
contentDescription = "Backlog",
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// remember the bech32 (naddr) encode — this top bar recomposes on every roster/metadata
|
||||
|
||||
@@ -131,20 +131,42 @@ Kinds 43001-43006 are *reserved* in Buzz with no upstream builder; the tag layou
|
||||
implements the job protocol. The prototype is deliberately isolated so that reconciliation
|
||||
touches only the quartz models + this aggregator.
|
||||
|
||||
## Mobile app — placement evaluation
|
||||
|
||||
The existing agent screens are **owner-global concepts entered per-relay, and buried**:
|
||||
`AgentConsole(relayUrl)` (Costs/Personas/Observer, read-only telemetry) is only reachable via
|
||||
a footer in the channel list or a bot-member tap; Costs/Personas/Observer are really the
|
||||
owner's whole fleet, not one relay's. That's a discoverability + scoping smell, but the Console
|
||||
is a coherent *owner telemetry* surface and should stay that — just get a better entry later.
|
||||
|
||||
The **shared work surface is a different thing and belongs at the channel level.** A Buzz job is
|
||||
`h`-scoped to a channel, so the backlog is *per-channel* — exactly like the Canvas (40100) and
|
||||
Forum, which launch from `RelayGroupTopBar` gated by `BuzzRelayDialect.isBuzz`. So the Jobs
|
||||
board sits there too (a `Checklist` action → `Route.BuzzJobBoard(channelId, relayUrl)`), NOT
|
||||
inside the owner Console. Keeping "owner fleet telemetry" and "this channel's shared backlog"
|
||||
as separate surfaces is the right call.
|
||||
|
||||
Note the model change also **deprioritizes the workflow-approval inbox (46010/46030/46031)**: with
|
||||
full-auto intake and merge-on-GitHub, the human gate moved to the PR — so the approvals inbox is
|
||||
now P1/optional, not P0. The true P0 is the shared board.
|
||||
|
||||
## Mobile app gaps (prioritized)
|
||||
|
||||
The quartz layer + LocalCache ingest are complete for every kind; the app has **zero
|
||||
create/interact surface** for the two kinds that define the workflow. Priorities:
|
||||
|
||||
**P0 — the human-in-the-loop loop**
|
||||
- **P0-1 Approvals inbox** — render 46010, publish 46030/46031 grant/deny (token-hash
|
||||
correlation; 46010 is NIP-PL push-urgent). Build on `AgentConsoleViewModel` fetch pattern
|
||||
+ `ApprovalGrantEvent.build`/`ApprovalDenyEvent.build`. Size **M**.
|
||||
- **P0-2 Jobs board** — create 43001, watch 43002-43006, see result, grouped by `e`. Reuse
|
||||
the new `BuzzJobAggregator`; jobs already render as inline system rows + are subscribed in
|
||||
`RelayGroupFilterBuilders`. Size **L**.
|
||||
- **P0-3 Agent picker** — choose a target agent when filing a job/approval, from stored
|
||||
30177/10100 + the fleet list. Size **S**.
|
||||
**P0 — the shared work surface**
|
||||
- **P0-2 Jobs board — ✅ LANDED.** `JobBoardScreen` + `JobBoardViewModel` (per-channel,
|
||||
`Route.BuzzJobBoard(channelId, relayUrl)`, entered from `RelayGroupTopBar` on Buzz relays).
|
||||
Reads job kinds + kind-7 upvotes scoped to the channel `h`, folds via `BuzzJobAggregator`,
|
||||
groups by state (In progress / Queued-by-upvotes / Done / Closed), live via `subscribeAsFlow`.
|
||||
Three write actions through new `Account` helpers: **file** a task (43001, FAB → dialog),
|
||||
**upvote** (kind-7 `+` with `h`), **cancel** own job (43005). Merge stays on GitHub.
|
||||
- **P0-1 Approvals inbox** — deprioritized to P1 by the full-auto/merge-on-GitHub model (the
|
||||
human gate is now the PR, not a 46010 gate). Still worth it if a workflow-gate flow returns:
|
||||
render 46010, publish 46030/46031, token-hash correlation, push-urgent.
|
||||
- **P0-3 Agent picker** — the board files **untargeted** jobs (any channel agent claims them),
|
||||
so a picker isn't needed for the shared-channel model; revisit only for directed jobs.
|
||||
|
||||
**P1 — a credible agent-driving client**
|
||||
- **P1-1 Diff/PR review surface** — upgrade read-only 40008 (`RenderBuzzDiff`) into a
|
||||
|
||||
Reference in New Issue
Block a user