refactor(buzz): nest DMs + Agent Console per-community, drop drawer entries

Buzz DMs and the Agent Console are community-scoped (a DM is a t=dm relay group on
one community's relay; agents/telemetry live on community relays), so they don't
belong as global drawer entries. Remove the AGENT_CONSOLE + BUZZ_DMS nav items and
reach both from within a community instead: the Buzz relay's group screen now shows
"Direct Messages" and "Agent Console" cards scoped to that one relay.

- Route.BuzzDmList / BuzzNewDm / AgentConsole now carry a relayUrl.
- BuzzDmListViewModel / BuzzNewDmViewModel / AgentConsoleViewModel take a single-relay
  scope (and mark it joined + pre-approve NIP-42 so the #p=me discovery authenticates).
- Removed the settings-catalog AgentConsole search entry (no longer global).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
This commit is contained in:
Claude
2026-07-22 22:58:41 +00:00
parent abdcd9c39d
commit e1f732ee38
12 changed files with 126 additions and 54 deletions
@@ -461,12 +461,12 @@ fun BuildNavigation(
composableFromEnd<Route.Shorts> { ShortsScreen(accountViewModel, nav) }
composableFromEnd<Route.PublicChats> { PublicChatsScreen(accountViewModel, nav) }
composableFromEnd<Route.RelayGroups> { RelayGroupDiscoveryScreen(accountViewModel, nav) }
composableFromEnd<Route.BuzzDmList> { BuzzDmListScreen(accountViewModel, nav) }
composableFromEnd<Route.BuzzNewDm> { BuzzNewDmScreen(accountViewModel, nav) }
composableFromEndArgs<Route.BuzzDmList> { BuzzDmListScreen(it.relayUrl, accountViewModel, nav) }
composableFromEndArgs<Route.BuzzNewDm> { BuzzNewDmScreen(it.relayUrl, accountViewModel, nav) }
composableFromEnd<Route.FollowPacks> { FollowPacksScreen(accountViewModel, nav) }
composableFromEnd<Route.LiveStreams> { LiveStreamsScreen(accountViewModel, nav) }
composableFromEnd<Route.Nests> { NestsScreen(accountViewModel, nav) }
composableFromEnd<Route.AgentConsole> { AgentConsoleScreen(accountViewModel, nav) }
composableFromEndArgs<Route.AgentConsole> { AgentConsoleScreen(it.relayUrl, accountViewModel, nav) }
composableFromEnd<Route.AgentAttestation> { AgentAttestationScreen(accountViewModel, nav) }
composableFromEndArgs<Route.AgentPersonaEdit> { AgentPersonaEditScreen(it.slug, accountViewModel, nav) }
composableFromEndArgs<Route.NestLobby> { NestLobbyScreen(it.addressValue, accountViewModel, nav) }
@@ -69,8 +69,6 @@ enum class NavBarItem {
PODCASTS,
PUBLIC_CHATS,
RELAY_GROUPS,
AGENT_CONSOLE,
BUZZ_DMS,
CONCORD,
GEOHASH_CHATS,
FOLLOW_PACKS,
@@ -346,20 +344,6 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
icon = MaterialSymbols.Forum,
resolveRoute = { Route.RelayGroups },
),
NavBarItem.AGENT_CONSOLE to
NavBarItemDef(
id = NavBarItem.AGENT_CONSOLE,
labelRes = R.string.buzz_console_card_title,
icon = MaterialSymbols.AutoAwesome,
resolveRoute = { Route.AgentConsole },
),
NavBarItem.BUZZ_DMS to
NavBarItemDef(
id = NavBarItem.BUZZ_DMS,
labelRes = R.string.buzz_dm_nav_label,
icon = MaterialSymbols.AutoMirrored.Send,
resolveRoute = { Route.BuzzDmList },
),
NavBarItem.CONCORD to
NavBarItemDef(
id = NavBarItem.CONCORD,
@@ -512,8 +496,6 @@ val BottomBarCategories: List<NavBarCategory> =
listOf(
NavBarItem.PUBLIC_CHATS,
NavBarItem.RELAY_GROUPS,
NavBarItem.AGENT_CONSOLE,
NavBarItem.BUZZ_DMS,
NavBarItem.CONCORD,
NavBarItem.GEOHASH_CHATS,
),
@@ -597,8 +579,6 @@ val DrawerFeedsItems: List<NavBarItem> =
NavBarItem.COMMUNITIES,
NavBarItem.PUBLIC_CHATS,
NavBarItem.RELAY_GROUPS,
NavBarItem.AGENT_CONSOLE,
NavBarItem.BUZZ_DMS,
NavBarItem.CONCORD,
NavBarItem.GEOHASH_CHATS,
NavBarItem.CALENDARS,
@@ -469,7 +469,10 @@ sealed class Route {
@Serializable object EditNestsServers : Route()
@Serializable object AgentConsole : Route()
@Serializable
data class AgentConsole(
val relayUrl: String,
) : Route()
@Serializable object AgentAttestation : Route()
@@ -719,9 +722,15 @@ sealed class Route {
@Serializable object RelayGroups : Route()
@Serializable object BuzzDmList : Route()
@Serializable
data class BuzzDmList(
val relayUrl: String,
) : Route()
@Serializable object BuzzNewDm : Route()
@Serializable
data class BuzzNewDm(
val relayUrl: String,
) : Route()
@Serializable object RelayGroupBrowse : Route()
@@ -137,10 +137,6 @@ private fun PreloadFor(
// this tab needs no extra preload.
NavBarItem.RELAY_GROUPS -> Unit
// The Agent Console + Buzz DM inbox fetch on-screen; nothing extra to preload here.
NavBarItem.AGENT_CONSOLE -> Unit
NavBarItem.BUZZ_DMS -> Unit
NavBarItem.CONCORD -> ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel)
NavBarItem.FOLLOW_PACKS -> FollowPacksFilterAssemblerSubscription(accountViewModel)
@@ -81,13 +81,14 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
*/
@Composable
fun AgentConsoleScreen(
relayUrl: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val pubkey = accountViewModel.account.userProfile().pubkeyHex
val viewModel: AgentConsoleViewModel = viewModel(key = "AgentConsole-$pubkey")
val viewModel: AgentConsoleViewModel = viewModel(key = "AgentConsole-$relayUrl-$pubkey")
viewModel.bindAccountIfMissing(accountViewModel.account)
viewModel.bind(accountViewModel.account, relayUrl)
val metrics by viewModel.metrics.collectAsStateWithLifecycle()
val personas by viewModel.personas.collectAsStateWithLifecycle()
@@ -26,6 +26,8 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.model.buzz.AgentFleetAggregator
import com.vitorpamplona.amethyst.commons.model.buzz.AgentFleetMetrics
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.filter
@@ -38,6 +40,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@@ -68,6 +72,9 @@ import java.util.Collections
class AgentConsoleViewModel : ViewModel() {
@Volatile private var account: Account? = null
/** The community (relay) this console is scoped to — agents/telemetry live on community relays. */
private var scopeRelay: NormalizedRelayUrl? = null
/** event id -> decrypted payload (null = decryption failed; don't retry). */
private val decryptCache = HashMap<HexKey, AgentTurnMetricPayload?>()
private val refreshMutex = Mutex()
@@ -89,9 +96,19 @@ class AgentConsoleViewModel : ViewModel() {
/** Dedups ephemeral frames across relays and re-emissions (accessed off multiple readers). */
private val observerSeen = Collections.synchronizedSet(HashSet<HexKey>())
fun bindAccountIfMissing(account: Account) {
/** Binds to [account] scoped to the community [relayUrl] — the console shows that community's fleet. */
fun bind(
account: Account,
relayUrl: String,
) {
if (this.account != null) return
val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl)
this.scopeRelay = relay
this.account = account
relay?.let {
BuzzWorkspaces.join(it)
viewModelScope.launch { account.relayAuthLedger.setDecision(it.url, RelayAuthDecision.ALLOW) }
}
refresh()
}
@@ -118,7 +135,7 @@ class AgentConsoleViewModel : ViewModel() {
*/
private suspend fun fetchFromRelays(account: Account) {
val myPubkey = account.userProfile().pubkeyHex
val relays = BuzzRelayDialect.flow.value + account.outboxRelays.flow.value
val relays = scopeRelay?.let { setOf(it) } ?: (BuzzRelayDialect.flow.value + account.outboxRelays.flow.value)
if (relays.isEmpty()) return
val filters =
@@ -183,7 +200,7 @@ class AgentConsoleViewModel : ViewModel() {
val account = account ?: return
if (observerJob != null) return
val relays = BuzzRelayDialect.flow.value
val relays = scopeRelay?.let { setOf(it) } ?: BuzzRelayDialect.flow.value
if (relays.isEmpty()) return
val signer = account.signer
@@ -90,12 +90,13 @@ import kotlinx.coroutines.launch
*/
@Composable
fun BuzzDmListScreen(
relayUrl: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val pubkey = accountViewModel.account.userProfile().pubkeyHex
val viewModel: BuzzDmListViewModel = viewModel(key = "BuzzDmList-$pubkey")
viewModel.bindAccountIfMissing(accountViewModel.account)
val viewModel: BuzzDmListViewModel = viewModel(key = "BuzzDmList-$relayUrl-$pubkey")
viewModel.bind(accountViewModel.account, relayUrl)
val rows by viewModel.rows.collectAsStateWithLifecycle()
@@ -105,7 +106,7 @@ fun BuzzDmListScreen(
ExtendedFloatingActionButton(
text = { Text(stringRes(R.string.buzz_dm_new)) },
icon = { Icon(symbol = MaterialSymbols.Add, contentDescription = null) },
onClick = { nav.nav(Route.BuzzNewDm) },
onClick = { nav.nav(Route.BuzzNewDm(relayUrl)) },
)
},
) { padding ->
@@ -26,6 +26,7 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.filter
@@ -40,6 +41,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPages
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.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import kotlinx.coroutines.Dispatchers
@@ -73,6 +75,9 @@ import java.util.concurrent.ConcurrentHashMap
*/
class BuzzDmListViewModel : ViewModel() {
@Volatile private var account: Account? = null
/** The community (relay) this inbox is scoped to — DMs are per-community. */
private var scopeRelay: NormalizedRelayUrl? = null
private val refreshMutex = Mutex()
private var liveJob: Job? = null
@@ -98,11 +103,24 @@ class BuzzDmListViewModel : ViewModel() {
val lastActivity: Long,
)
private fun relays(): Set<NormalizedRelayUrl> = BuzzWorkspaces.flow.value + BuzzRelayDialect.flow.value
private fun relays(): Set<NormalizedRelayUrl> = scopeRelay?.let { setOf(it) } ?: (BuzzWorkspaces.flow.value + BuzzRelayDialect.flow.value)
fun bindAccountIfMissing(account: Account) {
/**
* Binds to [account] scoped to the community [relayUrl]. Marks that relay a joined workspace and
* pre-approves NIP-42 so the read-only `#p=me` DM discovery authenticates against it.
*/
fun bind(
account: Account,
relayUrl: String,
) {
if (this.account != null) return
val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl)
this.scopeRelay = relay
this.account = account
relay?.let {
BuzzWorkspaces.join(it)
viewModelScope.launch { account.relayAuthLedger.setDecision(it.url, RelayAuthDecision.ALLOW) }
}
refresh()
startLive()
}
@@ -75,11 +75,12 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun BuzzNewDmScreen(
relayUrl: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val viewModel: BuzzNewDmViewModel = viewModel()
viewModel.bindAccountIfMissing(accountViewModel.account)
val viewModel: BuzzNewDmViewModel = viewModel(key = "BuzzNewDm-$relayUrl")
viewModel.bind(accountViewModel.account, relayUrl)
val relays by viewModel.relays.collectAsStateWithLifecycle()
val selectedRelay by viewModel.relay.collectAsStateWithLifecycle()
@@ -162,7 +163,7 @@ fun BuzzNewDmScreen(
if (groupId != null) {
nav.newStack(Route.RelayGroup(groupId.id, groupId.relayUrl.url))
} else {
nav.newStack(Route.BuzzDmList)
nav.newStack(Route.BuzzDmList(relayUrl))
}
}
},
@@ -22,13 +22,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.isValid
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import kotlinx.coroutines.Dispatchers
@@ -70,15 +69,16 @@ class BuzzNewDmViewModel : ViewModel() {
) : Status
}
fun bindAccountIfMissing(account: Account) {
/** Binds scoped to a single community [relayUrl] — a new DM is created on that community. */
fun bind(
account: Account,
relayUrl: String,
) {
if (this.account != null) return
this.account = account
val buzz =
(BuzzWorkspaces.flow.value + BuzzRelayDialect.flow.value)
.toList()
.sortedBy { it.url }
_relays.value = buzz
_relay.value = buzz.firstOrNull()
val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl)
_relays.value = listOfNotNull(relay)
_relay.value = relay
}
fun selectRelay(relay: NormalizedRelayUrl) {
@@ -62,6 +62,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
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.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
@@ -218,7 +219,9 @@ fun RelayGroupChannelListScreen(
) { padding ->
val myPubkey = accountViewModel.userProfile().pubkeyHex
val showBuzz = isBuzz && buzzChannels.isNotEmpty()
if (channels.isEmpty() && !showBuzz) {
// A Buzz relay is a community, so always render its list (with the per-community DM + Console
// entries) even before channels load, rather than the generic "empty" text.
if (channels.isEmpty() && !showBuzz && !isBuzz) {
// An empty directory on a relay whose NIP-11 says it doesn't run NIP-29 is almost
// certainly the wrong relay, not a young one — say so instead of the generic empty text.
// For a Buzz relay still discovering, say so; if it settled empty, guide the user.
@@ -249,6 +252,13 @@ fun RelayGroupChannelListScreen(
}
} else {
LazyColumn(modifier = Modifier.padding(padding)) {
// A Buzz relay is a community: its Direct Messages and (owner) Agent Console are
// scoped to it, reached from here rather than as global drawer entries.
if (isBuzz) {
item(key = "buzz-community-actions") {
BuzzCommunityActions(relayUrl = relay.url, nav = nav)
}
}
// Buzz membership section: the channels you already belong to on this workspace,
// each addable to your kind-10009 list.
if (showBuzz) {
@@ -325,6 +335,46 @@ private fun TorClearnetBanner(
}
}
/**
* Per-community entry points on a Buzz relay's screen: its Direct Messages inbox and (owner) Agent
* Console, both scoped to this one relay — replacing the old global drawer entries.
*/
@Composable
private fun BuzzCommunityActions(
relayUrl: String,
nav: INav,
) {
Column(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp)) {
BuzzActionCard(
icon = MaterialSymbols.AutoMirrored.Send,
label = stringRes(R.string.buzz_dm_title),
onClick = { nav.nav(Route.BuzzDmList(relayUrl)) },
)
Spacer(Modifier.size(8.dp))
BuzzActionCard(
icon = MaterialSymbols.AutoAwesome,
label = stringRes(R.string.buzz_console_card_title),
onClick = { nav.nav(Route.AgentConsole(relayUrl)) },
)
}
}
@Composable
private fun BuzzActionCard(
icon: MaterialSymbol,
label: String,
onClick: () -> Unit,
) {
Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
Row(Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(symbol = icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(22.dp))
Spacer(Modifier.size(12.dp))
Text(label, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
Icon(symbol = MaterialSymbols.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(20.dp))
}
}
}
@Composable
private fun RelayGroupChannelRow(
channel: RelayGroupChannel,
@@ -104,7 +104,6 @@ fun buildSettingsCatalog(
symEntry(R.string.ots_explorer_settings, MaterialSymbols.Search, R.string.ots_explorer_search_keywords, Route.OtsSettings),
symEntry(R.string.namecoin_settings, MaterialSymbols.Security, R.string.namecoin_search_keywords, Route.NamecoinSettings),
symEntry(R.string.resource_usage_title, MaterialSymbols.Bolt, R.string.resource_usage_search_keywords, Route.ResourceUsage),
symEntry(R.string.agent_console_title, MaterialSymbols.AutoAwesome, R.string.agent_console_search_keywords, Route.AgentConsole),
),
)