From 78c054918f0b7164dad3f952b5a58c7dbf39e645 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Fri, 28 Aug 2026 03:45:42 -0400 Subject: [PATCH] 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` 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 Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw --- .../kotlin/com/example/aiapp/SpawnScreen.kt | 51 ++++++++++++------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt index b0cdab9..63cee19 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt @@ -55,9 +55,10 @@ fun SpawnScreen( onBack: () -> Unit, ) { val scope = rememberCoroutineScope() - var providers by remember { mutableStateOf>(emptyList()) } - var hosts by remember { mutableStateOf>(emptyList()) } - var loading by remember { mutableStateOf(true) } + // What the form is made of, and whether we have it yet. A failure here + // is not the same as a server with nothing to offer, so it must not + // reach the pickers as empty lists -- see LoadState. + var options by remember { mutableStateOf>(LoadState.Loading) } var provider by remember { mutableStateOf(null) } var host by remember { mutableStateOf(null) } @@ -66,20 +67,21 @@ fun SpawnScreen( var cwd by remember { mutableStateOf("") } var permissionMode by remember { mutableStateOf("manual") } var busy by remember { mutableStateOf(false) } - var error by remember { mutableStateOf(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(null) } LaunchedEffect(Unit) { - try { - val loaded = withContext(Dispatchers.IO) { - fetchProviders(settings) to fetchHosts(settings) + options = try { + val fetched = withContext(Dispatchers.IO) { + SpawnOptions(fetchProviders(settings), fetchHosts(settings)) } - providers = loaded.first - hosts = loaded.second - provider = providers.firstOrNull() + provider = fetched.providers.firstOrNull() + LoadState.Loaded(fetched) } catch (e: ApiException) { - error = e.message + LoadState.failed(e) } - loading = false } val current = provider @@ -99,9 +101,20 @@ fun SpawnScreen( } Spacer(Modifier.height(16.dp)) - if (loading) { - CircularProgressIndicator() - return@Column + // 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() + return@Column + } + is LoadState.Error -> { + Text(state.message, color = MaterialTheme.colorScheme.error) + return@Column + } + is LoadState.Loaded -> state.value } ChipGroup( @@ -177,7 +190,8 @@ fun SpawnScreen( } Spacer(Modifier.height(24.dp)) - error?.let { + // Beside the button that produced it. + spawnError?.let { Text(it, color = MaterialTheme.colorScheme.error) Spacer(Modifier.height(8.dp)) } @@ -201,7 +215,7 @@ fun SpawnScreen( } onSpawned(spawned) } catch (e: ApiException) { - error = e.message + spawnError = e.message 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, val hosts: List) + /** * A labeled row of choices that wraps onto as many lines as it needs. *