Don't offer an empty spawn form when the options never arrived

SpawnScreen kept its own `loading` flag and `error` string, the third
mechanism in this app for a state two screens already share. That was not
just untidy: on a failed fetch it set the error, left `providers` and
`hosts` at the empty lists they started as, and rendered the form anyway --
so "couldn't reach the server" arrived as a Provider row with no providers
in it, which is what a server offering nothing would also look like. The
error sat below both empty pickers.

It now holds `LoadState<SpawnOptions>` like the others: loading shows the
spinner, a failure reports and stops, and the form exists only where there
is something to fill it with.

The spawn action keeps its own error, renamed `spawnError` so the two can't
be confused again. They are different in kind and the distinction is the
one the session list just learned: a fetch that never answered leaves no
form worth showing, while a spawn the server refused leaves a filled-in
form the user still wants, so that one stays beside the button that
produced it.

Verified on the emulator: the form loading with both providers, the Claude
fields appearing when claude-cli is selected (which also exercises the
snake_case kind the phone now compares against), and the failure state with
the server stopped -- reporting alone, with Cancel still working.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-28 03:45:42 -04:00
1 parent 4e760a4a72
commit 78c054918f
1 file changed
+32 -15
@@ -55,9 +55,10 @@ fun SpawnScreen(
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var providers by remember { mutableStateOf<List<Provider>>(emptyList()) } // What the form is made of, and whether we have it yet. A failure here
var hosts by remember { mutableStateOf<List<RemoteHost>>(emptyList()) } // is not the same as a server with nothing to offer, so it must not
var loading by remember { mutableStateOf(true) } // reach the pickers as empty lists -- see LoadState.
var options by remember { mutableStateOf<LoadState<SpawnOptions>>(LoadState.Loading) }
var provider by remember { mutableStateOf<Provider?>(null) } var provider by remember { mutableStateOf<Provider?>(null) }
var host by remember { mutableStateOf<String?>(null) } var host by remember { mutableStateOf<String?>(null) }
@@ -66,20 +67,21 @@ fun SpawnScreen(
var cwd by remember { mutableStateOf("") } var cwd by remember { mutableStateOf("") }
var permissionMode by remember { mutableStateOf("manual") } var permissionMode by remember { mutableStateOf("manual") }
var busy by remember { mutableStateOf(false) } var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) } // Only the spawn's own failure. The fetch's lives in `options`: this
// one leaves a filled-in form worth keeping, and that one leaves
// nothing to fill in.
var spawnError by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
try { options = try {
val loaded = withContext(Dispatchers.IO) { val fetched = withContext(Dispatchers.IO) {
fetchProviders(settings) to fetchHosts(settings) SpawnOptions(fetchProviders(settings), fetchHosts(settings))
} }
providers = loaded.first provider = fetched.providers.firstOrNull()
hosts = loaded.second LoadState.Loaded(fetched)
provider = providers.firstOrNull()
} catch (e: ApiException) { } catch (e: ApiException) {
error = e.message LoadState.failed(e)
} }
loading = false
} }
val current = provider val current = provider
@@ -99,10 +101,21 @@ fun SpawnScreen(
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
if (loading) { // Nothing below is fillable until the options are here, and a
// failure to fetch them leaves no form worth showing -- so this
// reports and stops, rather than offering empty pickers under an
// error message.
val (providers, hosts) = when (val state = options) {
is LoadState.Loading -> {
CircularProgressIndicator() CircularProgressIndicator()
return@Column return@Column
} }
is LoadState.Error -> {
Text(state.message, color = MaterialTheme.colorScheme.error)
return@Column
}
is LoadState.Loaded -> state.value
}
ChipGroup( ChipGroup(
label = "Provider", label = "Provider",
@@ -177,7 +190,8 @@ fun SpawnScreen(
} }
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
error?.let { // Beside the button that produced it.
spawnError?.let {
Text(it, color = MaterialTheme.colorScheme.error) Text(it, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
} }
@@ -201,7 +215,7 @@ fun SpawnScreen(
} }
onSpawned(spawned) onSpawned(spawned)
} catch (e: ApiException) { } catch (e: ApiException) {
error = e.message spawnError = e.message
busy = false busy = false
} }
} }
@@ -211,6 +225,9 @@ fun SpawnScreen(
} }
} }
/** What the spawn form is built from, fetched as one thing. */
private data class SpawnOptions(val providers: List<Provider>, val hosts: List<RemoteHost>)
/** /**
* A labeled row of choices that wraps onto as many lines as it needs. * A labeled row of choices that wraps onto as many lines as it needs.
* *