From 391efc72d219147ef913aedb744f41effb954909 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 13 Sep 2026 00:54:02 +0100 Subject: [PATCH] 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. --- .../pt/naps/maestro/ui/spawn/SpawnSheet.kt | 432 ++++++++++++++++++ .../maestro/ui/spawn/SpawnViewModelTest.kt | 87 ++-- 2 files changed, 484 insertions(+), 35 deletions(-) create mode 100644 android/app/src/main/java/pt/naps/maestro/ui/spawn/SpawnSheet.kt diff --git a/android/app/src/main/java/pt/naps/maestro/ui/spawn/SpawnSheet.kt b/android/app/src/main/java/pt/naps/maestro/ui/spawn/SpawnSheet.kt new file mode 100644 index 0000000..43c5d30 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/ui/spawn/SpawnSheet.kt @@ -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, 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, + 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, + 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, + 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, + ) + } + } + } + } + } + } + } + } +} diff --git a/android/app/src/test/java/pt/naps/maestro/ui/spawn/SpawnViewModelTest.kt b/android/app/src/test/java/pt/naps/maestro/ui/spawn/SpawnViewModelTest.kt index 3b55f03..4479ca6 100644 --- a/android/app/src/test/java/pt/naps/maestro/ui/spawn/SpawnViewModelTest.kt +++ b/android/app/src/test/java/pt/naps/maestro/ui/spawn/SpawnViewModelTest.kt @@ -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>() + + 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"}}"""), )