feat(maestro): add sidebar composables (#265)

ActivityRail (coloured gutter bar, blink on AwaitingInput, flash on
Error), SessionCard (two-line card with a long-press context menu for
rename/kill/spawn-similar), FolderNode (collapsible repo group), and
SidebarContent assembling the drawer's session list from the
CatalogStore built in #264.
This commit is contained in:
Miguel Palhas
2026-09-13 00:39:13 +01:00
parent 3ef1decbdd
commit a9c3cfe6d0
4 changed files with 514 additions and 0 deletions
@@ -0,0 +1,91 @@
package pt.naps.maestro.ui.sidebar
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.InfiniteTransition
import androidx.compose.animation.core.LinearEasing
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.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import pt.naps.maestro.data.ActivityState
/** docs/SIDEBAR.md: the rail's width in the web client. Android mirrors it in
* density-independent pixels rather than raw pixels. */
val ACTIVITY_RAIL_WIDTH = 7.dp
private val RAIL_ACTIVE = Color(0xFF3DDC84)
private val RAIL_IDLE = Color(0xFF9AA0A6)
private val RAIL_AWAITING = Color(0xFFF5A623)
private val RAIL_ERROR = Color(0xFFE5484D)
/** Colour per activity state (docs/SIDEBAR.md's colour table): `Starting`
* shares `Idle`'s colour, and an unrecognised state degrades to `Idle` so
* rendering never fails (Precedence). */
fun activityColor(activity: ActivityState): Color =
when (activity) {
ActivityState.Active -> RAIL_ACTIVE
ActivityState.AwaitingInput -> RAIL_AWAITING
ActivityState.Error -> RAIL_ERROR
ActivityState.Idle, ActivityState.Starting -> RAIL_IDLE
}
/** The accessible name the rail carries, since colour is never the only
* signal (docs/SIDEBAR.md). */
fun activityStateWord(activity: ActivityState): String =
when (activity) {
ActivityState.Active -> "Active"
ActivityState.Idle -> "Idle"
ActivityState.Starting -> "Starting"
ActivityState.AwaitingInput -> "Awaiting input"
ActivityState.Error -> "Error"
}
/** A held bar that pulses its alpha between 1 and [minAlpha] every
* [periodMs] — the blink/flash motion `AwaitingInput`/`Error` earn. */
@Composable
private fun InfiniteTransition.pulsingAlpha(minAlpha: Float, periodMs: Int): Float {
val alpha by animateFloat(
initialValue = 1f,
targetValue = minAlpha,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = periodMs, easing = LinearEasing),
repeatMode = RepeatMode.Reverse,
),
label = "activity-rail-alpha",
)
return alpha
}
/** The card's rail: a coloured bar in the gutter, full card height. It paints
* for every card, `Idle` included, and carries motion only for states that
* need attention — a hard blink for `AwaitingInput`, a fast flash for
* `Error`. `Active` and `Idle` stay solid; colour alone is their signal. */
@Composable
fun ActivityRail(activity: ActivityState, modifier: Modifier = Modifier) {
val color by animateColorAsState(targetValue = activityColor(activity), label = "activity-rail-color")
val infinite = rememberInfiniteTransition(label = "activity-rail-motion")
val alpha = when (activity) {
ActivityState.AwaitingInput -> infinite.pulsingAlpha(minAlpha = 0.35f, periodMs = 600)
ActivityState.Error -> infinite.pulsingAlpha(minAlpha = 0.25f, periodMs = 220)
else -> 1f
}
Box(
modifier = modifier
.fillMaxHeight()
.width(ACTIVITY_RAIL_WIDTH)
.semantics { contentDescription = activityStateWord(activity) }
.background(color.copy(alpha = alpha)),
)
}
@@ -0,0 +1,58 @@
package pt.naps.maestro.ui.sidebar
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/** docs/SIDEBAR.md: a collapsible directory in the sidebar tree. An expanded
* folder's cards are one row away, so it carries no highlight of its own —
* only a collapsed header is ever a keyboard-walk stop, but that distinction
* belongs to the caller driving the walk, not this composable. */
@Composable
fun FolderNode(
label: String,
collapsed: Boolean,
onToggle: () -> Unit,
modifier: Modifier = Modifier,
depthPadding: Int = 0,
content: @Composable () -> Unit,
) {
Column(modifier = modifier) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onToggle)
.padding(start = depthPadding.dp, top = 6.dp, bottom = 6.dp, end = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = if (collapsed) "▸" else "▾",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(6.dp))
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
AnimatedVisibility(visible = !collapsed) {
Column { content() }
}
}
}
@@ -0,0 +1,242 @@
package pt.naps.maestro.ui.sidebar
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import pt.naps.maestro.data.InTheWay
import pt.naps.maestro.data.Session
import pt.naps.maestro.data.SessionStatus
/** docs/SIDEBAR.md: a card is always two lines behind an activity rail —
* line 1 the name, line 2 the adaptive meta line. Long-press opens a context
* menu (Rename / Kill / Spawn similar); the daemon confirms rename and kill
* only through the callbacks the caller supplies. */
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SessionCard(
session: Session,
nowMs: Long,
modifier: Modifier = Modifier,
depthPadding: Int = 0,
onOpen: (Session) -> Unit = {},
onRename: (Session, String) -> Unit = { _, _ -> },
onKill: (Session) -> Unit = {},
onSpawnSimilar: (Session) -> Unit = {},
) {
var menuExpanded by remember { mutableStateOf(false) }
var renaming by remember { mutableStateOf(false) }
var confirmingKill by remember { mutableStateOf(false) }
val running = session.status == SessionStatus.Running
val name = sessionName(session)
val muted = isProvisionalName(session)
Row(
modifier = modifier
.fillMaxWidth()
.combinedClickable(
onClick = { onOpen(session) },
onLongClick = { menuExpanded = true },
)
.padding(start = (8 + depthPadding).dp, end = 8.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ActivityRail(activity = session.activity, modifier = Modifier.height(36.dp))
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = when {
!running -> MaterialTheme.colorScheme.onSurfaceVariant
muted -> MaterialTheme.colorScheme.onSurfaceVariant
else -> MaterialTheme.colorScheme.onSurface
},
style = MaterialTheme.typography.bodyMedium,
)
MetaLine(session = session, nowMs = nowMs)
}
}
DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
DropdownMenuItem(
text = { Text("Rename") },
onClick = {
menuExpanded = false
renaming = true
},
)
DropdownMenuItem(
text = { Text("Spawn similar") },
onClick = {
menuExpanded = false
onSpawnSimilar(session)
},
)
DropdownMenuItem(
text = { Text("Kill") },
onClick = {
menuExpanded = false
confirmingKill = true
},
)
}
if (renaming) {
RenameDialog(
initial = customSessionName(session) ?: "",
onCommit = {
renaming = false
onRename(session, it)
},
onCancel = { renaming = false },
)
}
if (confirmingKill) {
KillConfirmDialog(
session = session,
onConfirm = {
confirmingKill = false
onKill(session)
},
onCancel = { confirmingKill = false },
)
}
}
/** Line 2, left to right: `[duration] [detail] command [diff stat | exited]`
* — the line itself is unconditional, only its fragments are adaptive. */
@Composable
fun MetaLine(session: Session, nowMs: Long, modifier: Modifier = Modifier) {
val heldMs = activityHeldMs(session.activity_since, nowMs)
val duration = if (heldMs != null && heldMs >= META_LINE_MIN_HOLD_MS) {
activityDurationLabel(heldMs)
} else {
null
}
val blockers = sessionBlockers(session)
Row(modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
if (duration != null) {
Text(
text = duration,
color = activityColor(session.activity),
style = MaterialTheme.typography.labelSmall,
)
Spacer(Modifier.width(6.dp))
}
if (session.activity_detail != null) {
Text(
text = session.activity_detail,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.weight(1f, fill = false),
)
Spacer(Modifier.width(6.dp))
}
Text(
text = programName(session.command),
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.labelSmall,
)
if (blockers != null) {
Spacer(Modifier.width(6.dp))
BlockedFragment(blockers)
}
if (session.status == SessionStatus.Dead) {
Spacer(Modifier.width(6.dp))
Text(
text = "exited",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelSmall,
)
}
}
}
@Composable
private fun BlockedFragment(blockers: InTheWay) {
Row(verticalAlignment = Alignment.CenterVertically) {
blockerStats(blockers).forEachIndexed { index, stat ->
if (index > 0) Spacer(Modifier.width(4.dp))
Text(
text = stat.text,
style = MaterialTheme.typography.labelSmall,
color = when (stat.kind) {
"add" -> MaterialTheme.colorScheme.primary
"del" -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}
@Composable
private fun RenameDialog(initial: String, onCommit: (String) -> Unit, onCancel: () -> Unit) {
var text by remember { mutableStateOf(initial) }
AlertDialog(
onDismissRequest = onCancel,
title = { Text("Rename session") },
text = {
OutlinedTextField(
value = text,
onValueChange = { text = it },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
)
},
confirmButton = { TextButton(onClick = { onCommit(text.trim()) }) { Text("Rename") } },
dismissButton = { TextButton(onClick = onCancel) { Text("Cancel") } },
)
}
@Composable
private fun KillConfirmDialog(session: Session, onConfirm: () -> Unit, onCancel: () -> Unit) {
val blockers = sessionBlockers(session)
AlertDialog(
onDismissRequest = onCancel,
title = { Text("Kill “${sessionName(session)}”?") },
text = {
Text(
if (blockers != null) {
"This worktree has unsaved work: ${formatInTheWay(blockers)}. Killing may be refused unless forced."
} else {
"This ends the session's process. This can't be undone."
},
)
},
confirmButton = { TextButton(onClick = onConfirm) { Text("Kill") } },
dismissButton = { TextButton(onClick = onCancel) { Text("Cancel") } },
)
}
@@ -0,0 +1,123 @@
package pt.naps.maestro.ui.sidebar
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
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.unit.dp
import kotlinx.coroutines.delay
import pt.naps.maestro.connection.CatalogStore
import pt.naps.maestro.data.Session
import pt.naps.maestro.data.Uuid
/** A clock that advances at least once every 30s (docs/SIDEBAR.md), ticked
* faster than that so a duration's cutover lands within one visible frame. */
@Composable
fun rememberTickingNowMs(periodMs: Long = 5_000L): Long {
var now by remember { mutableStateOf(System.currentTimeMillis()) }
LaunchedEffect(periodMs) {
while (true) {
delay(periodMs)
now = System.currentTimeMillis()
}
}
return now
}
/** Assembles the full sidebar from the [CatalogStore] built in #264: sessions
* grouped into collapsible folders by repository, each rendered as a
* two-line [SessionCard] behind its [ActivityRail]. */
@Composable
fun SidebarContent(
catalogStore: CatalogStore,
onOpenSession: (Session) -> Unit,
modifier: Modifier = Modifier,
onRenameSession: (Uuid, String) -> Unit = { _, _ -> },
onKillSession: (Uuid) -> Unit = {},
onSpawnSimilar: (Session) -> Unit = {},
) {
val sessions by catalogStore.sessions.collectAsState()
SidebarContent(
sessions = sessions.values.toList(),
onOpenSession = onOpenSession,
modifier = modifier,
onRenameSession = onRenameSession,
onKillSession = onKillSession,
onSpawnSimilar = onSpawnSimilar,
)
}
/** Same as the [CatalogStore]-driven overload, but takes the session list
* directly — the shape used by tests and previews. */
@Composable
fun SidebarContent(
sessions: List<Session>,
onOpenSession: (Session) -> Unit,
modifier: Modifier = Modifier,
onRenameSession: (Uuid, String) -> Unit = { _, _ -> },
onKillSession: (Uuid) -> Unit = {},
onSpawnSimilar: (Session) -> Unit = {},
) {
val nowMs = rememberTickingNowMs()
val groups = remember(sessions) { groupSessions(sessions) }
val collapsed = remember { mutableStateMapOf<String, Boolean>() }
Column(modifier = modifier.fillMaxSize()) {
Text(
text = "Sessions",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp),
)
if (groups.isEmpty()) {
Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Text(
text = "No sessions",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
return
}
LazyColumn(modifier = Modifier.fillMaxSize()) {
for (group in groups) {
val isCollapsed = collapsed[group.key] == true
item(key = "folder:${group.key}") {
FolderNode(
label = group.label,
collapsed = isCollapsed,
onToggle = { collapsed[group.key] = !isCollapsed },
depthPadding = 8,
) {
Column {
for (session in group.sessions) {
SessionCard(
session = session,
nowMs = nowMs,
depthPadding = 16,
onOpen = onOpenSession,
onRename = { s, name -> onRenameSession(s.id, name) },
onKill = { s -> onKillSession(s.id) },
onSpawnSimilar = onSpawnSimilar,
)
}
}
}
}
}
}
}
}