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:
Claude
2026-07-25 18:57:21 +00:00
parent e0d6febd5b
commit ed574bf023
7 changed files with 591 additions and 9 deletions
@@ -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,
@@ -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)
}
@@ -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))),
)
}
}
@@ -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