feat(buzz): elevated Jobs board UI + observable relay status bar

Make the shared backlog modern, alive, and transparent.

- RelayStatusBar: a live, tappable header that makes ALL of the workspace relay's
  state observable in context — connection (client.connectedRelaysFlow), NIP-42 auth
  phase (authCoordinator.authStateFlow, with a pulsing dot + lock icon), Buzz-dialect
  marker, and an expandable NIP-11 panel (software/version, supported NIPs, limitations,
  description, latency). Every signal is a real StateFlow/produceState, so it tracks
  reality live. Reusable across agent surfaces.
- JobBoardScreen redesign: "Working now" is the visual hero (primaryContainer, a
  breathing pulse on the streaming progress line, elapsed time); queued cards carry a
  colored status rail and reorder by upvotes with animateItem; shipped cards are a
  success tone with a prominent "View PR" (extracts the PR URL from the result);
  closed cards mute. Real avatars + display names (UserPicture/UsernameDisplay/LoadUser)
  instead of hex. Upvote chip animates its count; the composer is a friendly
  ModalBottomSheet with example chips; a warmer empty state.

App compiles (fdroidDebug). No font regen (reused existing MaterialSymbols).

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 20:18:37 +00:00
parent ed574bf023
commit 5e8f079045
2 changed files with 494 additions and 133 deletions
@@ -20,29 +20,48 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
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.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyItemScope
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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SheetState
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
@@ -51,8 +70,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -64,21 +85,29 @@ 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.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
/**
* 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].
* (43002-43006). A live [RelayStatusBar] pins the relay's health up top; jobs are grouped by
* lifecycle with the **active work** as the visual hero, the queue ordered by the group's
* upvotes. Correlation/state/priority is the shared
* [com.vitorpamplona.amethyst.commons.model.buzz.BuzzJobAggregator]; this screen renders it and
* routes file/upvote/cancel 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.
* Merge is deliberately NOT here — a shipped job's result is its PR; the merge happens on GitHub.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun JobBoardScreen(
channelId: String,
@@ -97,51 +126,60 @@ fun JobBoardScreen(
val jobs by viewModel.jobs.collectAsStateWithLifecycle()
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) }
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")
}
ExtendedFloatingActionButton(
onClick = { composing = true },
icon = { Icon(symbol = MaterialSymbols.Add, contentDescription = null) },
text = { Text("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 }
Column(modifier = Modifier.padding(padding).fillMaxSize()) {
relay?.let { RelayStatusBar(it, accountViewModel) }
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)
Box(modifier = Modifier.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(top = 8.dp, bottom = 96.dp),
) {
section("Working now", running, JobStyle.HERO, me, accountViewModel, nav, viewModel)
section("Up next", queued, JobStyle.QUEUED, me, accountViewModel, nav, viewModel)
section("Shipped", done, JobStyle.SHIPPED, me, accountViewModel, nav, viewModel)
section("Closed", closed, JobStyle.CLOSED, me, accountViewModel, nav, viewModel)
}
}
}
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp).size(24.dp),
)
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp).size(24.dp),
)
}
}
}
}
if (composing) {
NewTaskDialog(
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
NewTaskSheet(
sheetState = sheetState,
onDismiss = { composing = false },
onFile = { text ->
viewModel.file(text)
@@ -151,10 +189,15 @@ fun JobBoardScreen(
}
}
private enum class JobStyle { HERO, QUEUED, SHIPPED, CLOSED }
private fun LazyListScope.section(
title: String,
jobs: List<JobView>,
style: JobStyle,
me: String,
accountViewModel: AccountViewModel,
nav: INav,
viewModel: JobBoardViewModel,
) {
if (jobs.isEmpty()) return
@@ -163,83 +206,152 @@ private fun LazyListScope.section(
text = "$title · ${jobs.size}",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 6.dp, bottom = 2.dp),
)
}
items(jobs, key = { it.jobId }) { job ->
JobCard(job, me, viewModel)
JobCard(job, style, me, accountViewModel, nav, viewModel)
}
}
@Composable
private fun JobCard(
private fun LazyItemScope.JobCard(
job: JobView,
style: JobStyle,
me: String,
accountViewModel: AccountViewModel,
nav: INav,
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,
val (container, accent) = styleColors(style)
Surface(
color = container,
shape = RoundedCornerShape(16.dp),
modifier =
Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min)
.animateItem()
.alpha(if (style == JobStyle.CLOSED) 0.7f else 1f),
) {
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
// Left status rail — a quick-scan colour strip.
Box(modifier = Modifier.fillMaxHeight().width(4.dp).background(accent))
Column(
modifier = Modifier.padding(14.dp).fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
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)}") }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
StatePill(job.state, accent)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
TimeAgo(time = job.createdAt, style = TimeAgoStyle.Short)
UpvoteChip(job.upvotes) { viewModel.upvote(job.jobId) }
}
}
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)
}
}
Text(
text = job.request?.takeIf { it.isNotBlank() } ?: "(no description)",
style = if (style == JobStyle.HERO) MaterialTheme.typography.titleMedium else MaterialTheme.typography.bodyLarge,
fontWeight = if (style == JobStyle.HERO) FontWeight.SemiBold else FontWeight.Normal,
)
// 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")
// Who's involved — real avatars + names, not hex.
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Person("by", job.requester, accountViewModel, nav)
if (job.agent != null && job.agent != job.requester) Person("agent", job.agent, accountViewModel, nav)
}
when (style) {
JobStyle.HERO ->
job.lastProgress?.takeIf { it.isNotBlank() }?.let { WorkingLine(it, accent) }
JobStyle.SHIPPED ->
job.result?.takeIf { it.isNotBlank() }?.let { ResultLine(it) }
JobStyle.CLOSED ->
(job.error ?: job.cancelReason?.let { "Cancelled: $it" })?.let {
Text(it, style = MaterialTheme.typography.bodyMedium, color = if (job.state == JobState.FAILED) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant)
}
JobStyle.QUEUED -> Unit
}
if (!job.isTerminal && job.requester == me) {
TextButton(onClick = { viewModel.cancel(job.jobId) }, modifier = Modifier.align(Alignment.End)) {
Text("Cancel")
}
}
}
}
}
}
/** The active-work pulse: the streaming progress line breathes while the agent runs. */
@Composable
private fun StatePill(state: JobState) {
val (label, symbol, color) = stateStyle(state)
private fun WorkingLine(
text: String,
accent: Color,
) {
val pulse = rememberInfiniteTransition(label = "working")
val a by pulse.animateFloat(
initialValue = 0.45f,
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse),
label = "workingAlpha",
)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.alpha(a)) {
Box(modifier = Modifier.size(8.dp).clip(CircleShape).background(accent))
Text(text, style = MaterialTheme.typography.bodyMedium, color = accent, fontWeight = FontWeight.Medium)
}
}
/** A shipped result — if it carries a URL, offer a prominent "View PR" instead of raw text. */
@Composable
private fun ResultLine(result: String) {
val url = remember(result) { Regex("https?://\\S+").find(result)?.value }
if (url != null) {
val uriHandler = LocalUriHandler.current
Button(onClick = { uriHandler.openUri(url) }) {
Icon(symbol = MaterialSymbols.OpenInBrowser, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("View PR")
}
} else {
Text(result, style = MaterialTheme.typography.bodyMedium)
}
}
@Composable
private fun Person(
prefix: String,
hex: String?,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (hex == null) return
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
Text(prefix, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
UserPicture(userHex = hex, size = Size20dp, accountViewModel = accountViewModel, nav = nav)
LoadUser(baseUserHex = hex, accountViewModel = accountViewModel) { user ->
if (user != null) {
UsernameDisplay(baseUser = user, fontWeight = FontWeight.Medium, accountViewModel = accountViewModel)
}
}
}
}
@Composable
private fun StatePill(
state: JobState,
accent: Color,
) {
val (label, symbol) = pillContent(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)
Icon(symbol = symbol, contentDescription = null, tint = accent, modifier = Modifier.size(16.dp))
Text(text = label, style = MaterialTheme.typography.labelMedium, color = accent, fontWeight = FontWeight.Bold)
}
}
@@ -248,75 +360,95 @@ private fun UpvoteChip(
count: Int,
onClick: () -> Unit,
) {
Row(
modifier = Modifier.clickable(onClick = onClick).padding(4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(20.dp),
modifier = Modifier.clickable(onClick = onClick),
) {
Icon(symbol = MaterialSymbols.ThumbUp, contentDescription = "Upvote", modifier = Modifier.size(18.dp))
Text(text = count.toString(), style = MaterialTheme.typography.labelLarge)
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp).animateContentSize(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(symbol = MaterialSymbols.ThumbUp, contentDescription = "Upvote", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
Text(text = count.toString(), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun NewTaskDialog(
private fun NewTaskSheet(
sheetState: SheetState,
onDismiss: () -> Unit,
onFile: (String) -> Unit,
) {
var text by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("New task") },
text = {
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(modifier = Modifier.padding(horizontal = 20.dp).padding(bottom = 32.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Ask the workspace agent", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text(
"Describe a feature or fix. The whole channel sees it, and the agent opens a PR.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf("Fix a bug", "Add a setting", "Improve a screen").forEach { example ->
SuggestionChip(onClick = { if (text.isBlank()) text = "$example: " }, label = { Text(example) })
}
}
OutlinedTextField(
value = text,
onValueChange = { text = it },
label = { Text("Describe the feature or fix") },
label = { Text("What should we build?") },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
)
},
confirmButton = {
TextButton(
Button(
onClick = { onFile(text.trim()) },
enabled = text.isNotBlank(),
) { Text("File") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
modifier = Modifier.fillMaxWidth(),
) {
Icon(symbol = MaterialSymbols.Bolt, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("File task")
}
}
}
}
@Composable
private fun EmptyBoard() {
Box(modifier = Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
) {
Icon(symbol = MaterialSymbols.Checklist, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(48.dp))
Text("No tasks yet", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
text = "No tasks yet. Tap + to ask the workspace agent to build or fix something — the whole channel will see it.",
"Tap “New task” to ask the workspace agent to build or fix something. The whole channel will see it, upvote it, and watch it ship.",
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 styleColors(style: JobStyle): Pair<Color, Color> =
when (style) {
JobStyle.HERO -> MaterialTheme.colorScheme.primaryContainer to MaterialTheme.colorScheme.primary
JobStyle.QUEUED -> MaterialTheme.colorScheme.surfaceContainer to MaterialTheme.colorScheme.onSurfaceVariant
JobStyle.SHIPPED -> MaterialTheme.colorScheme.tertiaryContainer to MaterialTheme.colorScheme.tertiary
JobStyle.CLOSED -> MaterialTheme.colorScheme.surfaceContainerLow to MaterialTheme.colorScheme.outline
}
private fun shortKey(hex: String?): String =
when {
hex == null -> "unknown"
hex.length <= 16 -> hex
else -> hex.take(8) + "" + hex.takeLast(4)
private fun pillContent(state: JobState): Pair<String, MaterialSymbol> =
when (state) {
JobState.REQUESTED -> "Queued" to MaterialSymbols.Schedule
JobState.ACCEPTED -> "Picked up" to MaterialSymbols.Bolt
JobState.IN_PROGRESS -> "Working" to MaterialSymbols.Bolt
JobState.COMPLETED -> "Shipped" to MaterialSymbols.CheckCircle
JobState.FAILED -> "Failed" to MaterialSymbols.Error
JobState.CANCELLED -> "Cancelled" to MaterialSymbols.Cancel
}
@@ -0,0 +1,229 @@
/*
* 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.animation.AnimatedVisibility
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
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.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.draw.alpha
import androidx.compose.ui.draw.clip
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 com.vitorpamplona.amethyst.Amethyst
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.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.warningColor
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthSnapshot
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* A compact, tappable header that makes **all** of one relay's live state observable in context:
* connection (from `client.connectedRelaysFlow`), NIP-42 auth phase (from the auth coordinator),
* the Buzz-dialect marker, and — expanded — the NIP-11 document (software, supported NIPs,
* limitations, description) plus latency. Every signal here is a real `StateFlow`/`produceState`,
* so the bar reflects reality as it changes rather than a one-shot snapshot.
*
* Reusable across the agent surfaces; the Jobs board pins it above the backlog.
*/
@Composable
fun RelayStatusBar(
relay: NormalizedRelayUrl,
accountViewModel: AccountViewModel,
) {
val connected by accountViewModel.account.client
.connectedRelaysFlow()
.collectAsStateWithLifecycle()
val authMap by Amethyst.instance.authCoordinator.receiver.authStateFlow
.collectAsStateWithLifecycle()
val buzzRelays by BuzzRelayDialect.flow.collectAsStateWithLifecycle()
val info by loadRelayInfo(relay)
val isConnected = relay in connected
val phase = authMap[relay]?.phase ?: RelayAuthSnapshot.Phase.IDLE
val isBuzz = relay in buzzRelays
val stat = remember(relay, isConnected) { Amethyst.instance.relayStats.get(relay) }
val (dotColor, statusLabel) = health(isConnected, phase)
var expanded by remember { mutableStateOf(false) }
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(14.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
) {
Column(modifier = Modifier.clickable { expanded = !expanded }.padding(horizontal = 14.dp, vertical = 10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
LiveDot(dotColor, pulsing = phase == RelayAuthSnapshot.Phase.AUTHENTICATING || (isConnected && phase != RelayAuthSnapshot.Phase.AUTH_FAILED))
Column(modifier = Modifier.weight(1f)) {
Text(
text = info.name?.takeIf { it.isNotBlank() } ?: relay.url,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
Text(
text = statusLabel,
style = MaterialTheme.typography.bodySmall,
color = dotColor,
)
}
if (isBuzz) Chip("Buzz", MaterialSymbols.Bolt)
Icon(
symbol = if (phase == RelayAuthSnapshot.Phase.AUTHENTICATED) MaterialSymbols.Lock else MaterialSymbols.LockOpen,
contentDescription = if (phase == RelayAuthSnapshot.Phase.AUTHENTICATED) "Authenticated" else "Not authenticated",
tint = if (phase == RelayAuthSnapshot.Phase.AUTHENTICATED) MaterialTheme.colorScheme.allGoodColor else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp),
)
Icon(
symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
contentDescription = if (expanded) "Collapse" else "Expand relay details",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
AnimatedVisibility(visible = expanded) {
Column(modifier = Modifier.padding(top = 10.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
InfoLine("Host", relay.url)
InfoLine("Connection", if (isConnected) "Connected" else "Disconnected")
InfoLine("Auth (NIP-42)", phase.name.lowercase().replace('_', ' '))
InfoLine("Dialect", if (isBuzz) "Buzz workspace" else "vanilla NIP-29")
if (stat.pingInMs > 0) InfoLine("Latency", "${stat.pingInMs} ms")
info.software?.takeIf { it.isNotBlank() }?.let { InfoLine("Software", it + (info.version?.let { v -> " $v" } ?: "")) }
info.supported_nips?.takeIf { it.isNotEmpty() }?.let { InfoLine("Supported NIPs", it.joinToString(", ")) }
info.limitation?.let { lim ->
val flags =
buildList {
if (lim.auth_required == true) add("auth required")
if (lim.payment_required == true) add("payment required")
if (lim.restricted_writes == true) add("restricted writes")
}
if (flags.isNotEmpty()) InfoLine("Limitations", flags.joinToString(", "))
}
info.description?.takeIf { it.isNotBlank() }?.let {
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
}
}
@Composable
private fun LiveDot(
color: Color,
pulsing: Boolean,
) {
val alpha =
if (pulsing) {
val t = rememberInfiniteTransition(label = "relayDot")
t
.animateFloat(
initialValue = 0.35f,
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(800), RepeatMode.Reverse),
label = "relayDotAlpha",
).value
} else {
1f
}
Box(
modifier =
Modifier
.size(10.dp)
.alpha(alpha)
.clip(CircleShape)
.background(color),
)
}
@Composable
private fun Chip(
label: String,
symbol: MaterialSymbol,
) {
Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = RoundedCornerShape(8.dp)) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(3.dp),
) {
Icon(symbol = symbol, contentDescription = null, tint = MaterialTheme.colorScheme.onSecondaryContainer, modifier = Modifier.size(13.dp))
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSecondaryContainer, fontWeight = FontWeight.Bold)
}
}
}
@Composable
private fun InfoLine(
label: String,
value: String,
) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text(label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(0.4f))
Text(value, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, modifier = Modifier.weight(0.6f))
}
}
@Composable
private fun health(
isConnected: Boolean,
phase: RelayAuthSnapshot.Phase,
): Pair<Color, String> =
when {
!isConnected -> MaterialTheme.colorScheme.error to "Disconnected"
phase == RelayAuthSnapshot.Phase.AUTH_FAILED -> MaterialTheme.colorScheme.error to "Auth failed"
phase == RelayAuthSnapshot.Phase.AUTHENTICATING -> MaterialTheme.colorScheme.warningColor to "Authenticating…"
phase == RelayAuthSnapshot.Phase.AUTHENTICATED -> MaterialTheme.colorScheme.allGoodColor to "Connected · authenticated"
else -> MaterialTheme.colorScheme.allGoodColor to "Connected"
}