feat(maestro): add SpawnSheet bottom sheet composable (#269)

Preset chip row, searchable project list, debounced directory
autocomplete, and a branch/worktree picker matching the web spawn
modal, wired to SpawnViewModel and a self-contained snackbar for
submit errors.
This commit is contained in:
Miguel Palhas
2026-09-13 00:54:02 +01:00
parent cf582be306
commit 391efc72d2
2 changed files with 484 additions and 35 deletions
@@ -0,0 +1,432 @@
package pt.naps.maestro.ui.spawn
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.SheetState
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import pt.naps.maestro.data.BranchInfo
import pt.naps.maestro.data.Project
import pt.naps.maestro.data.Session
import pt.naps.maestro.data.SpawnPreset
/** Bottom sheet spawn form (#269), matching the web spawn modal's flow:
* a preset chip row, a searchable project list, a raw-path autocomplete
* fallback, a branch/worktree picker, a session name, and the
* structured-view toggle. [SpawnSheet] wires this to a live [SpawnViewModel];
* [SpawnSheetContent] is the pure, state-driven body used by previews and
* tests. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SpawnSheet(
viewModel: SpawnViewModel,
onDismiss: () -> Unit,
onSpawned: (Session) -> Unit,
modifier: Modifier = Modifier,
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
val state by viewModel.state.collectAsState()
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) {
SpawnSheetContent(
state = state,
onSelectPreset = viewModel::selectPreset,
onCustomCommandChange = viewModel::setCustomCommand,
onStructuredViewChange = viewModel::setStructuredView,
onProjectQueryChange = viewModel::setProjectQuery,
onSelectProject = viewModel::selectProject,
onCwdChange = viewModel::setCwd,
onPickDirSuggestion = viewModel::pickDirSuggestion,
onWorktreeEnabledChange = viewModel::setWorktreeEnabled,
onBranchModeChange = viewModel::setBranchMode,
onNewBranchChange = viewModel::setNewBranch,
onExistingBranchChange = viewModel::setExistingBranch,
onNameChange = viewModel::setName,
onReroll = viewModel::rerollName,
onDismissError = viewModel::dismissError,
onSubmit = { viewModel.submit(onSpawned) },
)
}
}
@Composable
fun SpawnSheetContent(
state: SpawnUiState,
onSelectPreset: (String) -> Unit,
onCustomCommandChange: (String) -> Unit,
onStructuredViewChange: (Boolean) -> Unit,
onProjectQueryChange: (String) -> Unit,
onSelectProject: (Project) -> Unit,
onCwdChange: (String) -> Unit,
onPickDirSuggestion: (String) -> Unit,
onWorktreeEnabledChange: (Boolean) -> Unit,
onBranchModeChange: (WorktreeBranchMode) -> Unit,
onNewBranchChange: (String) -> Unit,
onExistingBranchChange: (String) -> Unit,
onNameChange: (String) -> Unit,
onReroll: () -> Unit,
onDismissError: () -> Unit,
onSubmit: () -> Unit,
modifier: Modifier = Modifier,
) {
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(state.error) {
val message = state.error ?: return@LaunchedEffect
snackbarHostState.showSnackbar(message)
onDismissError()
}
Box(modifier = modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp)
.padding(bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text("Spawn session", style = MaterialTheme.typography.titleMedium)
PresetRow(
presets = state.presets,
selectedPresetId = state.selectedPresetId,
onSelectPreset = onSelectPreset,
)
if (state.isCustomSelected) {
OutlinedTextField(
value = state.customCommand,
onValueChange = onCustomCommandChange,
label = { Text("Command") },
placeholder = { Text("bash -l") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
ProjectPicker(
query = state.projectQuery,
onQueryChange = onProjectQueryChange,
projects = state.visibleProjects,
loading = state.projectsLoading,
selectedPath = state.selectedProjectPath,
homeDir = state.homeDir,
onSelectProject = onSelectProject,
)
DirAutocompleteField(
cwd = state.cwd,
suggestions = state.dirSuggestions,
onCwdChange = onCwdChange,
onPickSuggestion = onPickDirSuggestion,
)
BranchSection(
enabled = state.worktreeEnabled,
onEnabledChange = onWorktreeEnabledChange,
baseRepo = state.baseRepo,
mode = state.branchMode,
onModeChange = onBranchModeChange,
newBranch = state.newBranch,
onNewBranchChange = onNewBranchChange,
branches = state.branches,
branchesLoading = state.branchesLoading,
existingBranch = state.effectiveExistingBranch,
onExistingBranchChange = onExistingBranchChange,
)
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = state.name,
onValueChange = onNameChange,
label = { Text("Session name") },
placeholder = { Text("named after your first prompt") },
singleLine = true,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
TextButton(onClick = onReroll) { Text("Reroll") }
}
if (supportsStructuredView(state.command)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = state.structuredView, onCheckedChange = onStructuredViewChange)
Text("Structured view")
}
}
Button(
onClick = onSubmit,
enabled = !state.submitting,
modifier = Modifier.fillMaxWidth(),
) {
if (state.submitting) {
CircularProgressIndicator(modifier = Modifier.height(18.dp), strokeWidth = 2.dp)
} else {
Text("Spawn")
}
}
}
SnackbarHost(hostState = snackbarHostState, modifier = Modifier.align(Alignment.BottomCenter)) {
Snackbar(snackbarData = it)
}
}
}
/** A row is its own small touch target: tap anywhere in it, not just the
* leading control, to select it. */
private fun Modifier.clickableRow(enabled: Boolean = true, onClick: () -> Unit): Modifier =
this.clickable(enabled = enabled, onClick = onClick)
@Composable
private fun PresetRow(presets: List<SpawnPreset>, selectedPresetId: String, onSelectPreset: (String) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Harness", style = MaterialTheme.typography.labelLarge)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.horizontalScroll(rememberScrollState()),
) {
for (preset in presets) {
val label = if (preset.command.isEmpty()) "Custom…" else preset.label
FilterChip(
selected = preset.id == selectedPresetId,
onClick = { onSelectPreset(preset.id) },
label = { Text(label) },
)
}
}
}
}
@Composable
private fun ProjectPicker(
query: String,
onQueryChange: (String) -> Unit,
projects: List<Project>,
loading: Boolean,
selectedPath: String?,
homeDir: String,
onSelectProject: (Project) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Project", style = MaterialTheme.typography.labelLarge)
OutlinedTextField(
value = query,
onValueChange = onQueryChange,
placeholder = { Text("Filter projects…") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
when {
loading -> Text(
"Loading projects…",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
projects.isEmpty() -> Text(
"No projects match.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
else -> LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 220.dp)) {
items(projects, key = { it.path }) { project ->
ProjectRow(
project = project,
active = project.path == selectedPath,
homeDir = homeDir,
onClick = { onSelectProject(project) },
)
}
}
}
}
}
@Composable
private fun ProjectRow(project: Project, active: Boolean, homeDir: String, onClick: () -> Unit) {
val background = if (active) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface
Column(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.small)
.background(background)
.clickableRow(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
Text(project.name, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
abbreviateHome(parentDir(project.path), homeDir),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
private fun abbreviateHome(path: String, homeDir: String): String =
if (homeDir.isNotEmpty() && path.startsWith(homeDir)) "~${path.removePrefix(homeDir)}" else path
@Composable
private fun DirAutocompleteField(
cwd: String,
suggestions: List<String>,
onCwdChange: (String) -> Unit,
onPickSuggestion: (String) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
OutlinedTextField(
value = cwd,
onValueChange = onCwdChange,
label = { Text("Or type a directory") },
placeholder = { Text("/home/user/project") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
if (suggestions.isNotEmpty()) {
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 160.dp)) {
items(suggestions) { entry ->
Text(
text = "$entry/",
modifier = Modifier
.fillMaxWidth()
.clickableRow { onPickSuggestion(entry) }
.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
}
@Composable
private fun BranchSection(
enabled: Boolean,
onEnabledChange: (Boolean) -> Unit,
baseRepo: String?,
mode: WorktreeBranchMode,
onModeChange: (WorktreeBranchMode) -> Unit,
newBranch: String,
onNewBranchChange: (String) -> Unit,
branches: List<BranchInfo>,
branchesLoading: Boolean,
existingBranch: String,
onExistingBranchChange: (String) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = enabled, onCheckedChange = onEnabledChange)
Text("Create git worktree")
}
if (!enabled) return@Column
if (baseRepo == null) {
Text(
"Choose a directory inside a git repository first.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return@Column
}
Text("repo: $baseRepo", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = mode == WorktreeBranchMode.New, onClick = { onModeChange(WorktreeBranchMode.New) })
Text("New branch")
Spacer(Modifier.width(16.dp))
RadioButton(selected = mode == WorktreeBranchMode.Existing, onClick = { onModeChange(WorktreeBranchMode.Existing) })
Text("Existing branch")
}
if (mode == WorktreeBranchMode.New) {
OutlinedTextField(
value = newBranch,
onValueChange = onNewBranchChange,
label = { Text("New branch name") },
placeholder = { Text("feat/foo") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
} else {
when {
branchesLoading -> Text(
"Loading branches…",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
branches.isEmpty() -> Text(
"No local branches found.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
else -> LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 180.dp)) {
items(branches, key = { it.name }) { branch ->
val held = branch.worktree ?: (if (branch.is_head) "the main checkout" else null)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickableRow(enabled = held == null) { onExistingBranchChange(branch.name) }
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
RadioButton(
selected = branch.name == existingBranch,
onClick = { onExistingBranchChange(branch.name) },
enabled = held == null,
)
Column {
Text(branch.name, style = MaterialTheme.typography.bodyMedium)
if (held != null) {
Text(
"held by $held",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}
}
}
@@ -1,13 +1,17 @@
package pt.naps.maestro.ui.spawn
import app.cash.turbine.test
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
@@ -18,14 +22,33 @@ import org.junit.Test
import pt.naps.maestro.api.MaestroClient
import pt.naps.maestro.data.Project
/** Routes each enqueued response by request path rather than arrival order —
* [SpawnViewModel]'s init fires `/api/info` and `/api/projects` concurrently,
* so a plain FIFO queue can hand either endpoint the other's response. */
private class RoutedDispatcher : Dispatcher() {
private val queues = ConcurrentHashMap<String, ConcurrentLinkedQueue<MockResponse>>()
fun enqueue(path: String, response: MockResponse) {
queues.getOrPut(path) { ConcurrentLinkedQueue() }.add(response)
}
override fun dispatch(request: RecordedRequest): MockResponse {
val path = request.path.orEmpty().substringBefore('?')
return queues[path]?.poll() ?: MockResponse().setResponseCode(404)
}
}
class SpawnViewModelTest {
private lateinit var server: MockWebServer
private lateinit var dispatcher: RoutedDispatcher
private lateinit var client: MaestroClient
private lateinit var scope: CoroutineScope
@Before
fun setUp() {
dispatcher = RoutedDispatcher()
server = MockWebServer()
server.dispatcher = dispatcher
server.start()
client = MaestroClient(server.url("/").toString())
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -40,6 +63,13 @@ class SpawnViewModelTest {
private fun jsonResponse(body: String) =
MockResponse().setHeader("Content-Type", "application/json").setBody(body)
/** Enqueues a passing `/api/info` and an empty `/api/projects`, the pair
* every test needs before it can observe anything past init. */
private fun enqueueInit(infoBody: String = """{"hostname":"h","version":"1","home_dir":"","presets":[],"instance":"i"}""") {
dispatcher.enqueue("/api/info", jsonResponse(infoBody))
dispatcher.enqueue("/api/projects", jsonResponse("[]"))
}
private fun newViewModel() = SpawnViewModel(client, scope)
private suspend fun SpawnViewModel.awaitState(predicate: (SpawnUiState) -> Boolean): SpawnUiState {
@@ -55,15 +85,12 @@ class SpawnViewModelTest {
@Test
fun `loads presets from daemon info and defaults to the first one`() = runBlocking {
server.enqueue(
jsonResponse(
"""{"hostname":"h","version":"1","home_dir":"/home/x","presets":[
{"id":"claude","label":"Claude","command":"claude"},
{"id":"custom","label":"Custom…","command":""}
],"instance":"i"}""",
),
enqueueInit(
"""{"hostname":"h","version":"1","home_dir":"/home/x","presets":[
{"id":"claude","label":"Claude","command":"claude"},
{"id":"custom","label":"Custom…","command":""}
],"instance":"i"}""",
)
server.enqueue(jsonResponse("[]"))
val vm = newViewModel()
val settled = vm.awaitState { it.selectedPresetId == "claude" }
@@ -75,8 +102,8 @@ class SpawnViewModelTest {
@Test
fun `falls back to built-in presets when daemon info fails`() = runBlocking {
server.enqueue(MockResponse().setResponseCode(500))
server.enqueue(jsonResponse("[]"))
dispatcher.enqueue("/api/info", MockResponse().setResponseCode(500))
dispatcher.enqueue("/api/projects", jsonResponse("[]"))
val vm = newViewModel()
val settled = vm.awaitState { it.selectedPresetId.isNotEmpty() }
@@ -86,12 +113,7 @@ class SpawnViewModelTest {
@Test
fun `selecting a preset updates the command and structured view`() = runBlocking {
server.enqueue(
jsonResponse(
"""{"hostname":"h","version":"1","home_dir":"","presets":[],"instance":"i"}""",
),
)
server.enqueue(jsonResponse("[]"))
enqueueInit()
val vm = newViewModel()
vm.awaitState { it.selectedPresetId.isNotEmpty() } // let init settle on "claude"
@@ -107,8 +129,7 @@ class SpawnViewModelTest {
@Test
fun `manual structured view toggle survives a later preset change`() = runBlocking {
server.enqueue(jsonResponse("""{"hostname":"h","version":"1","home_dir":"","presets":[],"instance":"i"}"""))
server.enqueue(jsonResponse("[]"))
enqueueInit()
val vm = newViewModel()
vm.awaitState { it.selectedPresetId == "claude" }
@@ -120,8 +141,7 @@ class SpawnViewModelTest {
@Test
fun `selecting a project fills cwd and base repo`() = runBlocking {
server.enqueue(jsonResponse("""{"hostname":"h","version":"1","home_dir":"","presets":[],"instance":"i"}"""))
server.enqueue(jsonResponse("[]"))
enqueueInit()
val vm = newViewModel()
vm.awaitState { !it.projectsLoading }
@@ -133,13 +153,12 @@ class SpawnViewModelTest {
@Test
fun `typing a raw path debounces dir suggestions and base repo`() = runBlocking {
server.enqueue(jsonResponse("""{"hostname":"h","version":"1","home_dir":"","presets":[],"instance":"i"}"""))
server.enqueue(jsonResponse("[]"))
enqueueInit()
val vm = newViewModel()
vm.awaitState { !it.projectsLoading }
server.enqueue(jsonResponse("""{"path":"/home/user","entries":["proj-a","proj-b"]}"""))
server.enqueue(jsonResponse("""{"root":"/home/user/proj-a"}"""))
dispatcher.enqueue("/api/dirs", jsonResponse("""{"path":"/home/user","entries":["proj-a","proj-b"]}"""))
dispatcher.enqueue("/api/gitroot", jsonResponse("""{"root":"/home/user/proj-a"}"""))
vm.setCwd("/home/user/pro")
val settled = vm.awaitState { it.dirSuggestions.isNotEmpty() }
@@ -150,12 +169,11 @@ class SpawnViewModelTest {
@Test
fun `reroll fills the name field from the daemon`() = runBlocking {
server.enqueue(jsonResponse("""{"hostname":"h","version":"1","home_dir":"","presets":[],"instance":"i"}"""))
server.enqueue(jsonResponse("[]"))
enqueueInit()
val vm = newViewModel()
vm.awaitState { !it.projectsLoading }
server.enqueue(jsonResponse("""{"names":["amber-fox"]}"""))
dispatcher.enqueue("/api/names", jsonResponse("""{"names":["amber-fox"]}"""))
vm.rerollName()
val after = vm.awaitState { it.name == "amber-fox" }
assertEquals("amber-fox", after.name)
@@ -163,8 +181,7 @@ class SpawnViewModelTest {
@Test
fun `submit rejects a missing working directory without a network call`() = runBlocking {
server.enqueue(jsonResponse("""{"hostname":"h","version":"1","home_dir":"","presets":[],"instance":"i"}"""))
server.enqueue(jsonResponse("[]"))
enqueueInit()
val vm = newViewModel()
vm.awaitState { it.selectedPresetId == "claude" }
@@ -177,14 +194,14 @@ class SpawnViewModelTest {
@Test
fun `submit posts the built request and reports the new session`() = runBlocking {
server.enqueue(jsonResponse("""{"hostname":"h","version":"1","home_dir":"","presets":[],"instance":"i"}"""))
server.enqueue(jsonResponse("[]"))
enqueueInit()
val vm = newViewModel()
vm.awaitState { it.selectedPresetId == "claude" }
vm.selectProject(Project(path = "/repo", name = "repo"))
vm.awaitState { it.cwd == "/repo" }
server.enqueue(
dispatcher.enqueue(
"/api/sessions",
jsonResponse(
"""{"id":"s1","cwd":"/repo","command":"claude","status":"Running","created_at":"2026-01-01T00:00:00Z"}""",
),
@@ -202,14 +219,14 @@ class SpawnViewModelTest {
@Test
fun `submit surfaces the daemon's error detail on failure`() = runBlocking {
server.enqueue(jsonResponse("""{"hostname":"h","version":"1","home_dir":"","presets":[],"instance":"i"}"""))
server.enqueue(jsonResponse("[]"))
enqueueInit()
val vm = newViewModel()
vm.awaitState { it.selectedPresetId == "claude" }
vm.selectProject(Project(path = "/repo", name = "repo"))
vm.awaitState { it.cwd == "/repo" }
server.enqueue(
dispatcher.enqueue(
"/api/sessions",
MockResponse().setResponseCode(400).setHeader("Content-Type", "application/json")
.setBody("""{"error":{"code":"bad_request","message":"cwd does not exist"}}"""),
)