From ed574bf02372e69f696d591e4842601951bcdcf4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 18:57:21 +0000 Subject: [PATCH] feat(buzz): shared per-channel Jobs board (P0) for the agent backlog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6 --- .../vitorpamplona/amethyst/model/Account.kt | 51 +++ .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 5 + .../ui/screen/loggedIn/buzz/JobBoardScreen.kt | 322 ++++++++++++++++++ .../screen/loggedIn/buzz/JobBoardViewModel.kt | 172 ++++++++++ .../relayGroup/RelayGroupTopBar.kt | 8 + .../2026-07-25-buzz-agent-support-channel.md | 40 ++- 7 files changed, 591 insertions(+), 9 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardViewModel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index c0a35d5f47..2520c41964 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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.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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index fafad6126a..37c7c9a104 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { BuzzCanvasScreen(it.channelId, it.relayUrl, accountViewModel, nav) } + composableFromEndArgs { JobBoardScreen(it.channelId, it.relayUrl, accountViewModel, nav) } composableFromBottomArgs { BuzzForumPostScreen(it.channelId, it.relayUrl, accountViewModel, nav) } composableFromEndArgs { BuzzForumThreadScreen(it.channelId, it.relayUrl, it.rootId, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index acb3f8f6f9..b216be1345 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardScreen.kt new file mode 100644 index 0000000000..9ddc899702 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardScreen.kt @@ -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 { 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, + 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) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardViewModel.kt new file mode 100644 index 0000000000..a8ae81960b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardViewModel.kt @@ -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>(emptyList()) + val jobs: StateFlow> = _jobs.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _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))), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt index 90ae2ba15f..549bc65e89 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt @@ -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 diff --git a/cli/plans/2026-07-25-buzz-agent-support-channel.md b/cli/plans/2026-07-25-buzz-agent-support-channel.md index ae7a760705..4ca3d0d28e 100644 --- a/cli/plans/2026-07-25-buzz-agent-support-channel.md +++ b/cli/plans/2026-07-25-buzz-agent-support-channel.md @@ -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