A setup is a machine, and it carries what that machine can run
Providers and hosts were two independent lists, and a session named one of each. They were never independent: a provider only exists on a machine where that program is installed, so the spawn screen offered the whole cross-product, including "the Claude CLI on the box that hasn't got it". The picker could not know, because nothing in the model said. Now a setup is a machine -- optional ssh, plus the providers it has -- and spawning is two choices in order: pick a setup, then one of its providers. The impossible pairs stop being expressible rather than being validated against. Provider names are unique within a setup and only within one, so two machines can each have a `claude-cli`, which was previously either a name collision or two entries called things like "claude" and "claude on the vm". It also settles the "Run on" problem properly. That control was offered for every provider but honoured only by the Claude driver -- an echo session sent to a host ran locally and said otherwise. There is no such control now: the machine is chosen first, and echo is a provider of the setup with no ssh, where it belongs, since it runs in-process and has no transport to cross. The built-in echo provider is gone as a concept. It used to be conjured at read time and never written to the file, which meant a provider nobody could see or edit; it is now seeded into the config on first run alongside claude-cli. What the file says is what there is, and deleting it is a choice rather than a state to be repaired. A config in the old shape is refused with instructions rather than loaded. `Config` defaults unknown fields away, so `providers:` and `hosts:` would otherwise have vanished into an empty config that was then seeded over -- a migration nobody would notice until their setups were gone. Verified against a running server and on the emulator: a fresh install seeds "this machine" with echo and claude-cli and the file reads cleanly; a two-setup config lists both with their own providers; spawning on a setup works and the session row names it; asking for a provider a setup lacks says which it offers, and an unknown setup says which exist. On the phone, selecting "dev vm" narrows the provider chips to that machine's one and shows its address. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
1 parent
7cf36005ae
commit
ecac404fd4
10 files changed
+422
-328
No files matched your search
@@ -106,13 +106,13 @@ private fun JSONArray.strings(): List<String> = (0 until length()).map { getStri
|
||||
/** Percent-encodes a value going into a query string. */
|
||||
private fun String.urlEncoded(): String = java.net.URLEncoder.encode(this, Charsets.UTF_8.name())
|
||||
|
||||
// One row of GET /sessions. `provider` is what runs it, `host` where --
|
||||
// the two are independent, so a session names both.
|
||||
// One row of GET /sessions. A session names the machine it runs on and
|
||||
// which of that machine's providers it runs.
|
||||
data class SessionSummary(
|
||||
val id: String,
|
||||
val setup: String,
|
||||
val provider: String,
|
||||
val title: String,
|
||||
val host: String?,
|
||||
val model: String?,
|
||||
val status: String,
|
||||
val lastActivity: Double,
|
||||
@@ -121,9 +121,9 @@ data class SessionSummary(
|
||||
private fun parseSession(session: JSONObject) =
|
||||
SessionSummary(
|
||||
id = session.getString("id"),
|
||||
setup = session.getString("setup"),
|
||||
provider = session.getString("provider"),
|
||||
title = session.getString("title"),
|
||||
host = session.optString("host").ifEmpty { null },
|
||||
model = session.optString("model").ifEmpty { null },
|
||||
status = session.getString("status"),
|
||||
lastActivity = session.getDouble("lastActivity"),
|
||||
@@ -133,43 +133,48 @@ fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
|
||||
requestFromServer(settings, "/sessions") { it.jsonObjects(::parseSession) }
|
||||
|
||||
// What the server offers, so the spawn screen has no hardcoded lists: a
|
||||
// provider or host added to the server's config.ron appears here with no
|
||||
// app rebuild.
|
||||
// setup added to the server's config.ron appears here with no app rebuild.
|
||||
//
|
||||
// One list rather than two. A provider only exists on a machine that has
|
||||
// it installed, so offering machines and providers as independent choices
|
||||
// would offer pairs that cannot work.
|
||||
data class Provider(val name: String, val kind: String, val models: List<String>)
|
||||
|
||||
data class RemoteHost(val name: String, val address: String)
|
||||
/** A machine, and what it can run. [address] is absent for the backend itself. */
|
||||
data class Setup(val name: String, val address: String?, val providers: List<Provider>)
|
||||
|
||||
fun fetchProviders(settings: ServerSettings): List<Provider> =
|
||||
requestFromServer(settings, "/providers") { connection ->
|
||||
connection.jsonObjects { provider ->
|
||||
Provider(
|
||||
name = provider.getString("name"),
|
||||
kind = provider.getString("kind"),
|
||||
// Omitted entirely when the provider offers none.
|
||||
models = provider.optJSONArray("models")?.strings().orEmpty(),
|
||||
fun fetchSetups(settings: ServerSettings): List<Setup> =
|
||||
requestFromServer(settings, "/setups") { connection ->
|
||||
connection.jsonObjects { setup ->
|
||||
Setup(
|
||||
name = setup.getString("name"),
|
||||
address = setup.optString("address").ifEmpty { null },
|
||||
providers =
|
||||
setup.getJSONArray("providers").mapObjects { provider ->
|
||||
Provider(
|
||||
name = provider.getString("name"),
|
||||
kind = provider.getString("kind"),
|
||||
// Omitted entirely when the provider offers none.
|
||||
models = provider.optJSONArray("models")?.strings().orEmpty(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchHosts(settings: ServerSettings): List<RemoteHost> =
|
||||
requestFromServer(settings, "/hosts") { connection ->
|
||||
connection.jsonObjects { host ->
|
||||
RemoteHost(name = host.getString("name"), address = host.getString("address"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns a session and returns it as the list would show it. [host] is the name of a configured
|
||||
* host, or null to run on the backend machine itself.
|
||||
* Spawns a session and returns it as the list would show it. [setup] names the machine and
|
||||
* [provider] one of the things that machine offers.
|
||||
*/
|
||||
fun spawnSession(
|
||||
settings: ServerSettings,
|
||||
setup: String,
|
||||
provider: String,
|
||||
title: String,
|
||||
host: String? = null,
|
||||
model: String? = null,
|
||||
cwd: String? = null,
|
||||
permissionMode: String? = null,
|
||||
params: Map<String, String> = emptyMap(),
|
||||
): SessionSummary =
|
||||
requestFromServer(
|
||||
settings,
|
||||
@@ -177,13 +182,16 @@ fun spawnSession(
|
||||
method = "POST",
|
||||
jsonBody =
|
||||
JSONObject()
|
||||
.put("setup", setup)
|
||||
.put("provider", provider)
|
||||
.put("title", title)
|
||||
.apply {
|
||||
if (!host.isNullOrBlank()) put("host", host)
|
||||
if (!model.isNullOrBlank()) put("model", model)
|
||||
if (!cwd.isNullOrBlank()) put("cwd", cwd)
|
||||
if (!permissionMode.isNullOrBlank()) put("permissionMode", permissionMode)
|
||||
if (params.isNotEmpty()) {
|
||||
put("params", JSONObject(params.toMap<String, Any>()))
|
||||
}
|
||||
}
|
||||
.toString(),
|
||||
readTimeoutMs = 30000,
|
||||
|
||||
@@ -216,7 +216,7 @@ private fun SessionCard(
|
||||
// than a bare name, so a host isn't mistaken for a model.
|
||||
listOfNotNull(
|
||||
session.provider,
|
||||
session.host?.let { "on $it" },
|
||||
"on ${session.setup}",
|
||||
session.model,
|
||||
)
|
||||
.joinToString(" · "),
|
||||
|
||||
@@ -242,7 +242,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
Text(
|
||||
listOfNotNull(
|
||||
summary.provider,
|
||||
summary.host?.let { "on $it" },
|
||||
"on ${summary.setup}",
|
||||
summary.model,
|
||||
if (totalTokens > 0) "$totalTokens tok" else null,
|
||||
)
|
||||
|
||||
@@ -39,7 +39,6 @@ import kotlinx.coroutines.withContext
|
||||
private val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan")
|
||||
|
||||
/** Runs on the backend machine itself -- the "no host" case. */
|
||||
private const val LOCAL_HOST_LABEL = "backend"
|
||||
|
||||
/**
|
||||
* The spawn screen: what to run, where to run it, and the per-kind fields.
|
||||
@@ -57,10 +56,14 @@ fun SpawnScreen(
|
||||
// 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<SpawnOptions>>(LoadState.Loading) }
|
||||
var options by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
|
||||
|
||||
var provider by remember { mutableStateOf<Provider?>(null) }
|
||||
var host by remember { mutableStateOf<String?>(null) }
|
||||
// Setup first, then one of its providers. Choosing a setup can
|
||||
// invalidate the provider, so the provider is stored by name and
|
||||
// resolved against the current setup rather than held as an object
|
||||
// that could outlive the list it came from.
|
||||
var setupName by remember { mutableStateOf<String?>(null) }
|
||||
var providerName by remember { mutableStateOf<String?>(null) }
|
||||
var title by remember { mutableStateOf("") }
|
||||
var model by remember { mutableStateOf("") }
|
||||
var cwd by remember { mutableStateOf("") }
|
||||
@@ -74,23 +77,16 @@ fun SpawnScreen(
|
||||
LaunchedEffect(Unit) {
|
||||
options =
|
||||
try {
|
||||
val fetched =
|
||||
withContext(Dispatchers.IO) {
|
||||
SpawnOptions(fetchProviders(settings), fetchHosts(settings))
|
||||
}
|
||||
provider = fetched.providers.firstOrNull()
|
||||
val fetched = withContext(Dispatchers.IO) { fetchSetups(settings) }
|
||||
val first = fetched.firstOrNull()
|
||||
setupName = first?.name
|
||||
providerName = first?.providers?.firstOrNull()?.name
|
||||
LoadState.Loaded(fetched)
|
||||
} catch (e: ApiException) {
|
||||
LoadState.failed(e)
|
||||
}
|
||||
}
|
||||
|
||||
val current = provider
|
||||
// Only the Claude CLI has models, a working directory, and permission
|
||||
// modes; keying the extra fields on the kind rather than the provider
|
||||
// name keeps a second Claude provider from needing anything here.
|
||||
val isClaude = current?.kind == "claude_cli"
|
||||
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
@@ -106,7 +102,7 @@ fun SpawnScreen(
|
||||
// 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) =
|
||||
val setups =
|
||||
when (val state = options) {
|
||||
is LoadState.Loading -> {
|
||||
CircularProgressIndicator()
|
||||
@@ -118,33 +114,56 @@ fun SpawnScreen(
|
||||
}
|
||||
is LoadState.Loaded -> state.value
|
||||
}
|
||||
val setup = setups.firstOrNull { it.name == setupName }
|
||||
val current = setup?.providers?.firstOrNull { it.name == providerName }
|
||||
// Only the Claude CLI has models, a working directory and
|
||||
// permission modes; keying the extra fields on the kind rather
|
||||
// than the provider name keeps a second Claude provider from
|
||||
// needing anything here.
|
||||
val isClaude = current?.kind == "claude_cli"
|
||||
|
||||
// The machine first, because it decides what can be run at all.
|
||||
ChipGroup(
|
||||
label = "Provider",
|
||||
options = providers.map { it.name },
|
||||
selected = current?.name,
|
||||
onSelect = { name -> provider = providers.first { it.name == name } },
|
||||
label = "Setup",
|
||||
options = setups.map { it.name },
|
||||
selected = setupName,
|
||||
onSelect = { name ->
|
||||
setupName = name
|
||||
// The provider list changes with the machine, so a name
|
||||
// carried over from the previous one would be a selection
|
||||
// that isn't in the picker. Take that machine's first.
|
||||
providerName =
|
||||
setups.firstOrNull { it.name == name }?.providers?.firstOrNull()?.name
|
||||
},
|
||||
)
|
||||
|
||||
// Always offered, whatever the provider: where a session runs is
|
||||
// independent of what runs it.
|
||||
ChipGroup(
|
||||
label = "Run on",
|
||||
options = listOf(LOCAL_HOST_LABEL) + hosts.map { it.name },
|
||||
selected = host ?: LOCAL_HOST_LABEL,
|
||||
onSelect = { name -> host = name.takeIf { it != LOCAL_HOST_LABEL } },
|
||||
)
|
||||
host?.let { chosen ->
|
||||
hosts
|
||||
.firstOrNull { it.name == chosen }
|
||||
?.let {
|
||||
Text(
|
||||
it.address,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
setup?.address?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// The address belongs to the setup above it, not to the
|
||||
// provider label below; without this they read as one block.
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// Only what this machine actually has. A setup with none says so
|
||||
// rather than showing an empty row that reads as a failure.
|
||||
if (setup != null && setup.providers.isEmpty()) {
|
||||
Text(
|
||||
"\"${setup.name}\" has no providers configured.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
ChipGroup(
|
||||
label = "Provider",
|
||||
options = setup?.providers?.map { it.name }.orEmpty(),
|
||||
selected = providerName,
|
||||
onSelect = { providerName = it },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
@@ -210,9 +229,9 @@ fun SpawnScreen(
|
||||
withContext(Dispatchers.IO) {
|
||||
spawnSession(
|
||||
settings,
|
||||
setup = setup?.name.orEmpty(),
|
||||
provider = chosen.name,
|
||||
title = title.trim(),
|
||||
host = host,
|
||||
model = model.trim().takeIf { isClaude },
|
||||
cwd = cwd.trim().takeIf { isClaude },
|
||||
permissionMode = permissionMode.takeIf { isClaude },
|
||||
@@ -232,9 +251,6 @@ 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.
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user