Compare commits

..
Author SHA1 Message Date
Miguel Palhas 3403224dec feat(maestro): wire sidebar drawer into MainActivity
ModalNavigationDrawer opened by a hamburger button in the top bar,
holding SidebarContent bound to the app's DaemonLink.catalog.
2026-09-13 00:39:15 +01:00
Miguel Palhas a9c3cfe6d0 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.
2026-09-13 00:39:13 +01:00
Miguel Palhas 3ef1decbdd feat(maestro): add sidebar session formatting helpers
Pure Kotlin ports of the web/TUI SIDEBAR.md logic for the Android
sidebar: duration floor-format, program-name extraction (env/sudo/env
wrappers, npx/pnpm/yarn dlx, path), diff-stat notation, and
name/name_source/provisional-name reads. Covered by unit tests so they
run without an Android SDK.
2026-09-13 00:39:09 +01:00
Miguel Palhas b22539bd5b feat(maestro): wire DaemonLink into MainActivity, allow cleartext traffic
Reads the EXTRA_DAEMON_URL intent extra, starts DaemonLink, and shows its
LinkState in the placeholder screen so the connection indicator has
something to bind to. usesCleartextTraffic is required for the daemon's
plain-HTTP LAN endpoint; ACCESS_NETWORK_STATE is required for
ConnectivityManager.registerNetworkCallback.
2026-09-13 00:24:41 +01:00
Miguel Palhas 8f0d73d89c feat(maestro): add Android daemon connection state machine
Ports the DaemonLink state machine from web/src/components/daemon/DaemonLink.tsx
and crates/maestro-tui/src/daemon_link.rs to Android: LinkState (Connecting,
Online, Reconnecting, Down, Updating), build-stamp identity comparison, a
session catalog folded from the events WebSocket, and exponential backoff
(500ms-10s) in place of the flat poll the LAN clients use, since a phone's
network changes are bigger and longer-lived than a wifi blip.

DaemonLink reacts to ProcessLifecycleOwner (foreground resume) and
ConnectivityManager (network change) to re-probe and reconnect. DaemonPrefs
persists the daemon base URL via SharedPreferences and exposes an intent
extra key for overriding it at launch.
2026-09-13 00:24:36 +01:00
20 changed files with 1578 additions and 3 deletions
+1
View File
@@ -58,6 +58,7 @@ android {
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
+2
View File
@@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="true"
@@ -9,6 +10,7 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.Maestro">
<activity
android:name=".MainActivity"
@@ -8,33 +8,90 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import pt.naps.maestro.connection.DaemonLink
import pt.naps.maestro.connection.DaemonPrefs
import pt.naps.maestro.connection.LinkState
import pt.naps.maestro.ui.sidebar.SidebarContent
import pt.naps.maestro.ui.theme.MaestroTheme
class MainActivity : ComponentActivity() {
private lateinit var daemonLink: DaemonLink
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val prefs = DaemonPrefs(applicationContext)
intent?.getStringExtra(DaemonPrefs.EXTRA_DAEMON_URL)?.let { prefs.setBaseUrl(it) }
daemonLink = DaemonLink(applicationContext, prefs).also { it.start() }
setContent {
MaestroTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
PlaceholderScreen(modifier = Modifier.padding(innerPadding))
val linkState by daemonLink.state.collectAsState()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet(modifier = Modifier.width(320.dp)) {
SidebarContent(
catalogStore = daemonLink.catalog,
onOpenSession = { scope.launch { drawerState.close() } },
)
}
},
) {
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text("Maestro") },
navigationIcon = {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Text("☰")
}
},
)
},
) { innerPadding ->
PlaceholderScreen(modifier = Modifier.padding(innerPadding), linkState = linkState)
}
}
}
}
}
override fun onDestroy() {
daemonLink.close()
super.onDestroy()
}
}
@Composable
fun PlaceholderScreen(modifier: Modifier = Modifier) {
fun PlaceholderScreen(modifier: Modifier = Modifier, linkState: LinkState = LinkState.CONNECTING) {
Column(
modifier = modifier
.fillMaxSize()
@@ -49,6 +106,9 @@ fun PlaceholderScreen(modifier: Modifier = Modifier) {
Text(
text = stringResource(id = R.string.placeholder_message),
)
Text(
text = "Daemon: ${linkState.name.lowercase()}",
)
}
}
@@ -0,0 +1,32 @@
package pt.naps.maestro.connection
/**
* Exponential backoff for daemon reconnect attempts: doubles from
* [initialMs] up to [maxMs], then holds. A phone's radio swinging between
* wifi and cellular (or losing signal outright) is a bigger, longer-lived
* change than the LAN blips the web/TUI clients ride out with a flat poll,
* so this backs off instead of hammering the daemon while nothing changed.
*/
class Backoff(
private val initialMs: Long = INITIAL_MS,
private val maxMs: Long = MAX_MS,
) {
private var currentMs = initialMs
/** Delay before the next attempt; advances the sequence for the one after. */
fun nextDelayMs(): Long {
val delay = currentMs
currentMs = (currentMs * 2).coerceAtMost(maxMs)
return delay
}
/** Back to [initialMs] after a successful connection. */
fun reset() {
currentMs = initialMs
}
companion object {
const val INITIAL_MS = 500L
const val MAX_MS = 10_000L
}
}
@@ -0,0 +1,49 @@
package pt.naps.maestro.connection
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import pt.naps.maestro.data.CatalogEvent
import pt.naps.maestro.data.Session
import pt.naps.maestro.data.Uuid
/**
* Session catalog folded from `WS /api/events`. A [CatalogEvent.Snapshot]
* replaces the map outright; deltas mutate one entry.
*/
class CatalogStore {
private val _sessions = MutableStateFlow<Map<Uuid, Session>>(emptyMap())
val sessions: StateFlow<Map<Uuid, Session>> = _sessions.asStateFlow()
/** Applies one event. Returns the skip count for [CatalogEvent.Lagged] —
* the caller re-fetches a full snapshot over REST and calls [replace],
* mirroring how the web client reacts to a dropped broadcast frame. */
fun apply(event: CatalogEvent): Long? =
when (event) {
is CatalogEvent.Snapshot -> {
_sessions.value = event.sessions.associateBy { it.id }
null
}
is CatalogEvent.SessionAdded -> {
_sessions.value = _sessions.value + (event.session.id to event.session)
null
}
is CatalogEvent.SessionUpdated -> {
_sessions.value = _sessions.value + (event.session.id to event.session)
null
}
is CatalogEvent.SessionRemoved -> {
_sessions.value = _sessions.value - event.id
null
}
is CatalogEvent.Lagged -> event.skipped
}
fun replace(sessions: List<Session>) {
_sessions.value = sessions.associateBy { it.id }
}
fun clear() {
_sessions.value = emptyMap()
}
}
@@ -0,0 +1,51 @@
package pt.naps.maestro.connection
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
/**
* Notifies [onAvailable] whenever a usable network appears — including the
* wifi/cellular handoff, which Android reports as one network disappearing
* and another becoming available rather than a single "changed" event.
* [DaemonLink] treats every call as "worth reconnecting for" rather than
* inspecting which network it is.
*/
class ConnectivityObserver(
context: Context,
private val onAvailable: () -> Unit,
) {
private val connectivityManager =
context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) = onAvailable.invoke()
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
if (networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
) {
onAvailable.invoke()
}
}
}
private var registered = false
fun start() {
if (registered) return
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, callback)
registered = true
}
fun stop() {
if (!registered) return
runCatching { connectivityManager.unregisterNetworkCallback(callback) }
registered = false
}
}
@@ -0,0 +1,37 @@
package pt.naps.maestro.connection
import pt.naps.maestro.data.BuildInfo
import pt.naps.maestro.data.DaemonInfo
/** What happened between two `/api/info` replies. */
enum class RestartKind { SAME, RESTARTED, UPGRADED }
/**
* Compare two daemon replies. Mirrors `compareIdentity` in
* `web/src/components/daemon/DaemonLink.tsx` / `compare_identity` in
* `crates/maestro-tui/src/daemon_link.rs`.
*
* The commit decides first: a different one is new server code. `instance`
* catches everything else — a restart of the same build — and is the only
* signal for a daemon built outside a git checkout, where the commit is
* always empty and an empty-vs-empty pair must never read as an upgrade.
*/
fun compareIdentity(before: DaemonInfo, after: DaemonInfo): RestartKind {
val a = before.build.commit
val b = after.build.commit
if (a.isNotEmpty() && b.isNotEmpty() && a != b) return RestartKind.UPGRADED
if (before.instance.isNotEmpty() && after.instance.isNotEmpty() && before.instance != after.instance) {
return RestartKind.RESTARTED
}
return RestartKind.SAME
}
/** One line naming a release: `f89be4a716c7 · 2026-09-02`, `(dirty)` when the
* build carried uncommitted changes. Mirrors `formatRelease` / `format_release`. */
fun formatRelease(build: BuildInfo): String {
if (build.commit.isEmpty()) return "unknown release"
val parts = mutableListOf(build.commit)
if (build.commit_date.length >= 10) parts.add("· ${build.commit_date.take(10)}")
if (build.dirty) parts.add("(dirty)")
return parts.joinToString(" ")
}
@@ -0,0 +1,287 @@
package pt.naps.maestro.connection
import android.content.Context
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import pt.naps.maestro.api.EventsConnection
import pt.naps.maestro.api.MaestroClient
import pt.naps.maestro.api.MaestroWebSocket
import pt.naps.maestro.data.BuildInfo
import pt.naps.maestro.data.DaemonInfo
/** How long a dropped link stays [LinkState.RECONNECTING] before flipping to
* [LinkState.DOWN] — long enough to ride out a blip, short enough that a real
* outage shows up. Mirrors the grace window in `docs/RESTART.md`. */
private const val GRACE_MS = 1_200L
/**
* Owns the connection to one daemon for the app's whole life: liveness,
* identity, and the session catalog. A Kotlin port of the state machine in
* `web/src/components/daemon/DaemonLink.tsx` / `crates/maestro-tui/src/daemon_link.rs`
* — see `docs/RESTART.md` for the contract both mirror — with exponential
* [Backoff] standing in for their flat poll, since a phone's network changes
* are bigger and longer-lived than the LAN blips those clients ride out.
*
* Everything that can change the link — a probe landing, the events socket
* dropping, the app resuming, the network coming back, a new daemon URL —
* goes through one [signals] channel consumed by one coroutine, so state
* transitions never race each other.
*/
class DaemonLink(
context: Context,
private val prefs: DaemonPrefs,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
private val httpClient: OkHttpClient = OkHttpClient(),
) : DefaultLifecycleObserver {
private val appContext = context.applicationContext
private val _state = MutableStateFlow(LinkState.CONNECTING)
val state: StateFlow<LinkState> = _state.asStateFlow()
private val _build = MutableStateFlow<BuildInfo?>(null)
val build: StateFlow<BuildInfo?> = _build.asStateFlow()
private val _attempts = MutableStateFlow(0)
val attempts: StateFlow<Int> = _attempts.asStateFlow()
val catalog = CatalogStore()
private val backoff = Backoff()
private val signals = Channel<Signal>(Channel.UNLIMITED)
private val connectivity = ConnectivityObserver(appContext) { signals.trySend(Signal.NetworkAvailable) }
private var generation = 0
private var currentBaseUrl: String = prefs.baseUrl.value
private var client = MaestroClient(currentBaseUrl, httpClient)
private var socket = MaestroWebSocket(currentBaseUrl, httpClient)
private var identity: DaemonInfo? = null
private var eventsJob: Job? = null
private var eventsConnection: EventsConnection? = null
private var retryJob: Job? = null
private var graceJob: Job? = null
private var loopJob: Job? = null
private var started = false
private sealed class Signal {
object Start : Signal()
object Resume : Signal()
object NetworkAvailable : Signal()
object SocketDropped : Signal()
data class BaseUrlChanged(val url: String) : Signal()
data class ProbeResult(val generation: Int, val info: DaemonInfo?) : Signal()
data class Lagged(val skipped: Long) : Signal()
}
/** Starts the link: subscribes to process lifecycle and connectivity, and
* kicks off the first probe. Call once. */
fun start() {
if (started) return
started = true
loopJob = scope.launch { runLoop() }
connectivity.start()
scope.launch(Dispatchers.Main.immediate) {
ProcessLifecycleOwner.get().lifecycle.addObserver(this@DaemonLink)
}
scope.launch {
prefs.baseUrl.collect { url ->
if (url != currentBaseUrl) signals.trySend(Signal.BaseUrlChanged(url))
}
}
signals.trySend(Signal.Start)
}
/** Tears everything down. Only needed by tests and by an owner that is
* itself going away for good (this link is meant to outlive an Activity). */
fun close() {
connectivity.stop()
cancelRetry()
cancelGrace()
closeEvents()
loopJob?.cancel()
signals.close()
scope.launch(Dispatchers.Main.immediate) {
runCatching { ProcessLifecycleOwner.get().lifecycle.removeObserver(this@DaemonLink) }
}
}
override fun onStart(owner: LifecycleOwner) {
signals.trySend(Signal.Resume)
}
private suspend fun runLoop() {
for (signal in signals) {
when (signal) {
is Signal.Start -> probeNow()
is Signal.Resume -> {
probeNow()
ensureEventsConnected()
}
is Signal.NetworkAvailable -> {
cancelRetry()
probeNow()
}
is Signal.SocketDropped -> handleSocketDropped()
is Signal.BaseUrlChanged -> handleBaseUrlChanged(signal.url)
is Signal.ProbeResult -> {
if (signal.generation == generation) handleProbeResult(signal.info)
}
is Signal.Lagged -> handleLagged()
}
}
}
private fun probeNow() {
val gen = generation
val currentClient = client
scope.launch {
val info = runCatching { currentClient.getDaemonInfo() }.getOrNull()
signals.trySend(Signal.ProbeResult(gen, info))
}
}
private fun handleProbeResult(info: DaemonInfo?) {
if (info == null) {
onProbeFailed()
return
}
val before = identity
identity = info
_build.value = info.build
if (before != null && compareIdentity(before, info) == RestartKind.UPGRADED) {
_state.value = LinkState.UPDATING
fullReconnect()
return
}
backoff.reset()
cancelRetry()
cancelGrace()
_attempts.value = 0
_state.value = LinkState.ONLINE
ensureEventsConnected()
}
private fun onProbeFailed() {
_attempts.value += 1
if (_state.value != LinkState.DOWN && _state.value != LinkState.UPDATING) {
_state.value = LinkState.RECONNECTING
armGrace()
}
scheduleRetry()
}
private fun armGrace() {
if (graceJob?.isActive == true) return
graceJob = scope.launch {
delay(GRACE_MS)
if (_state.value == LinkState.RECONNECTING) _state.value = LinkState.DOWN
}
}
private fun cancelGrace() {
graceJob?.cancel()
graceJob = null
}
private fun scheduleRetry() {
if (retryJob?.isActive == true) return
val delayMs = backoff.nextDelayMs()
retryJob = scope.launch {
delay(delayMs)
retryJob = null
probeNow()
}
}
private fun cancelRetry() {
retryJob?.cancel()
retryJob = null
backoff.reset()
}
/** A build-stamp change: drop the events socket and the catalog it fed,
* then probe again shortly. The re-probe sees the same identity it just
* recorded, so it resolves straight to [LinkState.ONLINE] and reopens
* the socket — this *is* the full reconnect. */
private fun fullReconnect() {
closeEvents()
catalog.clear()
backoff.reset()
scope.launch {
delay(200)
probeNow()
}
}
private fun handleSocketDropped() {
if (_state.value == LinkState.DOWN || _state.value == LinkState.UPDATING) return
_state.value = LinkState.RECONNECTING
armGrace()
probeNow()
}
private fun handleBaseUrlChanged(url: String) {
currentBaseUrl = url
generation += 1
client = MaestroClient(url, httpClient)
socket = MaestroWebSocket(url, httpClient)
identity = null
_build.value = null
_attempts.value = 0
backoff.reset()
cancelRetry()
cancelGrace()
closeEvents()
catalog.clear()
_state.value = LinkState.CONNECTING
probeNow()
}
/** The broadcast channel dropped frames: re-fetch the full list over REST
* rather than trusting the map to still be complete. */
private fun handleLagged() {
val currentClient = client
scope.launch {
runCatching { currentClient.listSessions() }.onSuccess { catalog.replace(it) }
}
}
private fun ensureEventsConnected() {
if (eventsJob?.isActive == true) return
val connection = socket.connectEvents()
eventsConnection = connection
eventsJob = scope.launch {
try {
connection.events.collect { event ->
val skipped = catalog.apply(event)
if (skipped != null) signals.trySend(Signal.Lagged(skipped))
}
signals.trySend(Signal.SocketDropped)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
signals.trySend(Signal.SocketDropped)
}
}
}
private fun closeEvents() {
eventsJob?.cancel()
eventsJob = null
eventsConnection?.close()
eventsConnection = null
}
}
@@ -0,0 +1,40 @@
package pt.naps.maestro.connection
import android.content.Context
import androidx.core.content.edit
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Where the daemon base URL lives between launches. A settings screen and
* the [EXTRA_DAEMON_URL] intent extra both write through here; [DaemonLink]
* reads [baseUrl] and reconnects on change.
*/
class DaemonPrefs(context: Context) {
private val prefs = context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
private val _baseUrl =
MutableStateFlow(prefs.getString(KEY_BASE_URL, DEFAULT_BASE_URL) ?: DEFAULT_BASE_URL)
val baseUrl: StateFlow<String> = _baseUrl.asStateFlow()
fun setBaseUrl(url: String) {
val trimmed = url.trim()
if (trimmed.isEmpty() || trimmed == _baseUrl.value) return
prefs.edit { putString(KEY_BASE_URL, trimmed) }
_baseUrl.value = trimmed
}
companion object {
private const val FILE = "daemon_prefs"
private const val KEY_BASE_URL = "base_url"
/** Shown until the user points the app at their own daemon. */
const val DEFAULT_BASE_URL = "http://192.168.1.x:7070"
/** Intent extra a launcher may set to point this launch at a specific
* daemon without going through settings. Persisted on read, so it
* sticks after the launch that carried it. */
const val EXTRA_DAEMON_URL = "pt.naps.maestro.EXTRA_DAEMON_URL"
}
}
@@ -0,0 +1,14 @@
package pt.naps.maestro.connection
/**
* Connection lifecycle to the daemon. Mirrors the states in
* `web/src/components/daemon/DaemonLink.tsx` / `crates/maestro-tui/src/daemon_link.rs`
* (see `docs/RESTART.md`), plus [UPDATING] for a build-stamp change.
*
* - [CONNECTING] — first probe hasn't landed yet.
* - [ONLINE] — the daemon answered and identity is settled.
* - [RECONNECTING] — the link dropped, still inside the grace window.
* - [DOWN] — unreachable past the grace window; the restart overlay blocks.
* - [UPDATING] — the daemon answered on a different commit; reconnecting fresh.
*/
enum class LinkState { CONNECTING, ONLINE, RECONNECTING, DOWN, UPDATING }
@@ -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,161 @@
package pt.naps.maestro.ui.sidebar
import java.time.Instant
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonPrimitive
import pt.naps.maestro.data.InTheWay
import pt.naps.maestro.data.NameSource
import pt.naps.maestro.data.Session
/** docs/SIDEBAR.md: a state held shorter than this has no duration fragment. */
const val META_LINE_MIN_HOLD_MS: Long = 30_000L
/** Payloads predating #102 default `activity_since` to the Unix epoch;
* anything implausibly old is "never observed" rather than a huge duration. */
private val PLAUSIBLE_EPOCH_MS = Instant.parse("2010-01-01T00:00:00Z").toEpochMilli()
/** Milliseconds held in the current activity state, or `null` when `since` is
* missing/unparsable/implausible. Mirrors the web's `activityHeldMs`. */
fun activityHeldMs(since: String?, nowMs: Long): Long? {
if (since.isNullOrEmpty()) return null
val t = runCatching { Instant.parse(since).toEpochMilli() }.getOrNull() ?: return null
if (t < PLAUSIBLE_EPOCH_MS) return null
return (nowMs - t).coerceAtLeast(0L)
}
/** `<60s` whole seconds, `<60m` whole minutes, else whole hours — floored
* everywhere so the counter never shows a duration the state hasn't reached. */
fun activityDurationLabel(heldMs: Long): String {
val seconds = heldMs / 1000
if (seconds < 60) return "${seconds}s"
val minutes = seconds / 60
if (minutes < 60) return "${minutes}m"
return "${minutes / 60}h"
}
/** The session's own name (#125), without the command fallback: `null` for a
* session that was never given one. */
fun customSessionName(session: Session): String? {
val element = session.metadata["name"] ?: return null
val primitive = element as? JsonPrimitive ?: return null
if (!primitive.isString) return null
return primitive.content.takeIf { it.isNotEmpty() }
}
/** Line 1's name: `metadata.name` if present, else the command line. */
fun sessionName(session: Session): String = customSessionName(session) ?: session.command
/** Where the session's name came from (#131), read leniently: anything
* unrecognised — including a session named before the field existed — reads
* as [NameSource.User], so an existing name is never dimmed or overwritten. */
fun sessionNameSource(session: Session): NameSource {
val element = session.metadata["name_source"] as? JsonPrimitive ?: return NameSource.User
return when (element.jsonPrimitive.content) {
"Generated" -> NameSource.Generated
"Haiku" -> NameSource.Haiku
else -> NameSource.User
}
}
/** Whether line 1 renders the name muted (#131): a handle the daemon
* generated at spawn and may still replace with one derived from the first
* prompt. */
fun isProvisionalName(session: Session): Boolean =
customSessionName(session) != null && sessionNameSource(session) == NameSource.Generated
/** One fragment of the compact blocked-work stat (docs/SIDEBAR.md). `kind`
* picks the colour; `text` is what the card prints. */
data class BlockerStat(val kind: String, val text: String)
/** #83: this session's blocked-work breakdown when something actually blocks
* an unforced kill; `null` while clear or not yet probed. */
fun sessionBlockers(session: Session): InTheWay? {
val w = session.blocked_work ?: return null
val blocked = w.uncommitted_files > 0 || w.untracked_files > 0 || w.unpushed_commits > 0
return if (blocked) w else null
}
/** The blocked-work breakdown as diff notation: `+124 -18 ?3 ↑2`. A tree whose
* only tracked change carries no line delta falls back to the file count
* (`~2`) rather than claiming `+0 -0`. */
fun blockerStats(w: InTheWay): List<BlockerStat> {
val stats = mutableListOf<BlockerStat>()
if (w.insertions > 0 || w.deletions > 0) {
if (w.insertions > 0) stats += BlockerStat("add", "+${w.insertions}")
if (w.deletions > 0) stats += BlockerStat("del", "-${w.deletions}")
} else if (w.uncommitted_files > 0) {
stats += BlockerStat("files", "~${w.uncommitted_files}")
}
if (w.untracked_files > 0) stats += BlockerStat("untracked", "?${w.untracked_files}")
if (w.unpushed_commits > 0) stats += BlockerStat("unpushed", "↑${w.unpushed_commits}")
return stats
}
/** The spelled-out phrasing shared by the kill refusal and every client's
* tooltip, so no two describe the same tree differently. */
fun formatInTheWay(w: InTheWay): String {
val parts = mutableListOf<String>()
fun push(n: Int, unit: String) {
if (n <= 0) return
parts += if (n == 1) "1 $unit" else "$n ${unit}s"
}
push(w.uncommitted_files, "uncommitted file")
push(w.untracked_files, "untracked file")
push(w.unpushed_commits, "unpushed commit")
return if (parts.isNotEmpty()) parts.joinToString(", ") else "no unsaved work"
}
private val ENV_ASSIGNMENT = Regex("^[A-Za-z_][A-Za-z0-9_]*=.*$")
private val PLAIN_WRAPPERS = setOf("sudo", "env")
private val DLX_CAPABLE = setOf("pnpm", "yarn")
/** The program a command line runs — never its arguments (docs/SIDEBAR.md).
* Looks through leading `FOO=bar` assignments, `sudo`/`env` wrappers (and
* their own flags), and JS-toolchain launchers (`npx`, `bunx`, `pnpm`/`yarn
* dlx`), then basenames what is left. A command that is nothing but wrappers
* is returned as written rather than as an empty string. */
fun programName(command: String): String {
val trimmed = command.trim()
val words = trimmed.split(Regex("\\s+")).filter { it.isNotEmpty() }
if (words.isEmpty()) return trimmed
var i = 0
while (i < words.size && ENV_ASSIGNMENT.matches(words[i])) i++
while (i < words.size && words[i] in PLAIN_WRAPPERS) {
i++
while (i < words.size && (ENV_ASSIGNMENT.matches(words[i]) || words[i].startsWith("-"))) i++
}
if (i >= words.size) return basename(words.last())
when {
words[i] in DLX_CAPABLE && words.getOrNull(i + 1) == "dlx" -> i += 2
words[i] == "npx" || words[i] == "bunx" -> i += 1
words[i] == "bun" && words.getOrNull(i + 1) == "x" -> i += 2
}
val program = words.getOrNull(i) ?: return basename(words.last())
return basename(program)
}
private fun basename(path: String): String = path.substringAfterLast('/')
/** Sessions are grouped by the repository they belong to (#265): a
* worktree's `base_repo`, or the session's own `cwd` when it owns none. */
fun sessionGroupKey(session: Session): String = session.worktree?.base_repo ?: session.cwd
/** One folder in the sidebar tree: a repo/cwd group and the sessions in it,
* sorted by name. */
data class SessionGroup(val key: String, val label: String, val sessions: List<Session>)
/** Group sessions by [sessionGroupKey], each group sorted by name and the
* groups themselves sorted by label. */
fun groupSessions(sessions: Collection<Session>): List<SessionGroup> =
sessions
.groupBy(::sessionGroupKey)
.map { (key, members) ->
SessionGroup(
key = key,
label = key.trimEnd('/').substringAfterLast('/').ifEmpty { key },
sessions = members.sortedBy { sessionName(it).lowercase() },
)
}
.sortedBy { it.label.lowercase() }
@@ -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,
)
}
}
}
}
}
}
}
}
@@ -0,0 +1,21 @@
package pt.naps.maestro.connection
import org.junit.Assert.assertEquals
import org.junit.Test
class BackoffTest {
@Test
fun `doubles from 500ms and caps at 10s`() {
val backoff = Backoff(initialMs = 500L, maxMs = 10_000L)
val delays = List(8) { backoff.nextDelayMs() }
assertEquals(listOf(500L, 1000L, 2000L, 4000L, 8000L, 10_000L, 10_000L, 10_000L), delays)
}
@Test
fun `reset returns to the initial delay`() {
val backoff = Backoff(initialMs = 500L, maxMs = 10_000L)
repeat(4) { backoff.nextDelayMs() }
backoff.reset()
assertEquals(500L, backoff.nextDelayMs())
}
}
@@ -0,0 +1,60 @@
package pt.naps.maestro.connection
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import pt.naps.maestro.data.CatalogEvent
import pt.naps.maestro.data.Session
import pt.naps.maestro.data.SessionStatus
class CatalogStoreTest {
private fun session(id: String) = Session(
id = id,
cwd = "/tmp",
command = "bash",
status = SessionStatus.Running,
created_at = "2026-05-24T12:00:00Z",
)
@Test
fun `snapshot replaces the map keyed by id`() {
val store = CatalogStore()
store.apply(CatalogEvent.Snapshot(listOf(session("a"), session("b"))))
assertEquals(setOf("a", "b"), store.sessions.value.keys)
}
@Test
fun `added and updated upsert one entry`() {
val store = CatalogStore()
store.apply(CatalogEvent.Snapshot(emptyList()))
store.apply(CatalogEvent.SessionAdded(session("a")))
assertEquals(1, store.sessions.value.size)
val renamed = session("a").copy(command = "zsh")
store.apply(CatalogEvent.SessionUpdated(renamed))
assertEquals("zsh", store.sessions.value.getValue("a").command)
}
@Test
fun `removed drops the entry`() {
val store = CatalogStore()
store.apply(CatalogEvent.Snapshot(listOf(session("a"), session("b"))))
store.apply(CatalogEvent.SessionRemoved("a"))
assertEquals(setOf("b"), store.sessions.value.keys)
}
@Test
fun `lagged reports the skip count without touching the map`() {
val store = CatalogStore()
store.apply(CatalogEvent.Snapshot(listOf(session("a"))))
val skipped = store.apply(CatalogEvent.Lagged(3))
assertEquals(3L, skipped)
assertEquals(setOf("a"), store.sessions.value.keys)
}
@Test
fun `non-lagged events report no skip`() {
val store = CatalogStore()
assertNull(store.apply(CatalogEvent.Snapshot(emptyList())))
}
}
@@ -0,0 +1,55 @@
package pt.naps.maestro.connection
import org.junit.Assert.assertEquals
import org.junit.Test
import pt.naps.maestro.data.BuildInfo
import pt.naps.maestro.data.DaemonInfo
class DaemonIdentityTest {
private fun info(commit: String = "", instance: String = "") =
DaemonInfo(build = BuildInfo(commit = commit), instance = instance)
@Test
fun `same commit and instance is Same`() {
val a = info(commit = "abc123", instance = "i1")
assertEquals(RestartKind.SAME, compareIdentity(a, a.copy()))
}
@Test
fun `different commit is Upgraded`() {
val before = info(commit = "abc123", instance = "i1")
val after = info(commit = "def456", instance = "i2")
assertEquals(RestartKind.UPGRADED, compareIdentity(before, after))
}
@Test
fun `same commit but different instance is Restarted`() {
val before = info(commit = "abc123", instance = "i1")
val after = info(commit = "abc123", instance = "i2")
assertEquals(RestartKind.RESTARTED, compareIdentity(before, after))
}
@Test
fun `empty commits on both sides never read as an upgrade`() {
val before = info(commit = "", instance = "i1")
val after = info(commit = "", instance = "i2")
assertEquals(RestartKind.RESTARTED, compareIdentity(before, after))
}
@Test
fun `format release names the commit and short date`() {
val build = BuildInfo(commit = "f89be4a716c7", commit_date = "2026-09-02T10:00:00Z", dirty = false)
assertEquals("f89be4a716c7 · 2026-09-02", formatRelease(build))
}
@Test
fun `dirty build is flagged`() {
val build = BuildInfo(commit = "f89be4a716c7", commit_date = "2026-09-02T10:00:00Z", dirty = true)
assertEquals("f89be4a716c7 · 2026-09-02 (dirty)", formatRelease(build))
}
@Test
fun `empty commit is unknown release`() {
assertEquals("unknown release", formatRelease(BuildInfo()))
}
}
@@ -0,0 +1,190 @@
package pt.naps.maestro.ui.sidebar
import kotlinx.serialization.json.JsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import pt.naps.maestro.data.InTheWay
import pt.naps.maestro.data.Session
import pt.naps.maestro.data.SessionStatus
import pt.naps.maestro.data.SessionWorktree
private fun session(
command: String = "claude",
cwd: String = "/home/user/project",
metadata: Map<String, JsonPrimitive> = emptyMap(),
worktree: SessionWorktree? = null,
blockedWork: InTheWay? = null,
activitySince: String = "1970-01-01T00:00:00Z",
) = Session(
id = "s1",
cwd = cwd,
command = command,
metadata = metadata,
worktree = worktree,
status = SessionStatus.Running,
created_at = "2026-01-01T00:00:00Z",
blocked_work = blockedWork,
activity_since = activitySince,
)
class DurationFormattingTest {
@Test
fun `floors seconds under a minute`() {
assertEquals("30s", activityDurationLabel(30_000))
assertEquals("59s", activityDurationLabel(59_999))
}
@Test
fun `floors minutes under an hour`() {
assertEquals("4m", activityDurationLabel(4 * 60_000 + 59_000))
}
@Test
fun `floors hours with no days unit`() {
assertEquals("2h", activityDurationLabel(2 * 3_600_000 + 59 * 60_000))
}
@Test
fun `held ms is null for missing or implausible since`() {
assertNull(activityHeldMs(null, 1_000))
assertNull(activityHeldMs("", 1_000))
assertNull(activityHeldMs("1970-01-01T00:00:00Z", 1_000))
assertNull(activityHeldMs("not-a-timestamp", 1_000))
}
@Test
fun `held ms is the gap to now for a plausible timestamp`() {
val since = "2026-01-01T00:00:00Z"
val nowMs = java.time.Instant.parse("2026-01-01T00:00:31Z").toEpochMilli()
assertEquals(31_000L, activityHeldMs(since, nowMs))
}
}
class SessionNameTest {
@Test
fun `falls back to the command when no name is set`() {
assertEquals("claude --resume", sessionName(session(command = "claude --resume")))
}
@Test
fun `uses the custom name when set`() {
val s = session(metadata = mapOf("name" to JsonPrimitive("api-work")))
assertEquals("api-work", sessionName(s))
}
@Test
fun `an empty stored name is treated as unset`() {
val s = session(metadata = mapOf("name" to JsonPrimitive("")))
assertNull(customSessionName(s))
}
@Test
fun `generated name is provisional, user and haiku are not`() {
val generated = session(
metadata = mapOf(
"name" to JsonPrimitive("brave-otter"),
"name_source" to JsonPrimitive("Generated"),
),
)
val user = session(
metadata = mapOf(
"name" to JsonPrimitive("api-work"),
"name_source" to JsonPrimitive("User"),
),
)
val unknownSource = session(
metadata = mapOf(
"name" to JsonPrimitive("api-work"),
"name_source" to JsonPrimitive("something-new"),
),
)
assertTrue(isProvisionalName(generated))
assertFalse(isProvisionalName(user))
assertFalse(isProvisionalName(unknownSource))
}
}
class BlockerStatsTest {
@Test
fun `renders insertions and deletions in diff notation`() {
val w = InTheWay(uncommitted_files = 2, untracked_files = 3, unpushed_commits = 2, insertions = 124, deletions = 18)
val stats = blockerStats(w).associate { it.kind to it.text }
assertEquals("+124", stats["add"])
assertEquals("-18", stats["del"])
assertEquals("?3", stats["untracked"])
assertEquals("↑2", stats["unpushed"])
assertNull(stats["files"])
}
@Test
fun `falls back to a file count when a tracked change carries no line delta`() {
val w = InTheWay(uncommitted_files = 2, insertions = 0, deletions = 0)
val stats = blockerStats(w)
assertEquals(listOf(BlockerStat("files", "~2")), stats)
}
@Test
fun `session blockers are null when nothing blocks a kill`() {
assertNull(sessionBlockers(session(blockedWork = null)))
assertNull(sessionBlockers(session(blockedWork = InTheWay())))
}
@Test
fun `format in the way spells out zero as no unsaved work`() {
assertEquals("no unsaved work", formatInTheWay(InTheWay()))
assertEquals("1 uncommitted file, 2 untracked files", formatInTheWay(InTheWay(uncommitted_files = 1, untracked_files = 2)))
}
}
class ProgramNameTest {
@Test
fun `drops arguments and basenames the path`() {
assertEquals("claude", programName("/usr/local/bin/claude -p hi"))
assertEquals("claude", programName("claude --dangerously-skip-permissions"))
}
@Test
fun `looks through leading env assignments`() {
assertEquals("claude", programName("FOO=bar BAZ=qux claude --resume"))
}
@Test
fun `looks through sudo and env wrappers with their own flags`() {
assertEquals("claude", programName("sudo claude"))
assertEquals("claude", programName("env -i FOO=bar claude"))
}
@Test
fun `looks through js-toolchain launchers`() {
assertEquals("claude-code", programName("npx claude-code"))
assertEquals("claude", programName("pnpm dlx claude"))
assertEquals("claude", programName("bunx claude"))
}
@Test
fun `a command that is nothing but wrappers is shown as written`() {
assertEquals("sudo", programName("sudo"))
}
}
class GroupingTest {
@Test
fun `groups by worktree base_repo when present`() {
val a = session(cwd = "/wt/a", worktree = SessionWorktree(path = "/wt/a", base_repo = "/home/user/maestro"))
val b = session(cwd = "/wt/b", worktree = SessionWorktree(path = "/wt/b", base_repo = "/home/user/maestro"))
val groups = groupSessions(listOf(a, b))
assertEquals(1, groups.size)
assertEquals("maestro", groups.single().label)
assertEquals(2, groups.single().sessions.size)
}
@Test
fun `falls back to cwd with no worktree`() {
val a = session(cwd = "/home/user/scratch")
val groups = groupSessions(listOf(a))
assertEquals("scratch", groups.single().label)
}
}
+1
View File
@@ -13,6 +13,7 @@ kotlinxCoroutines = "1.9.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }