Let a llama session actually be started from the phone

The driver worked and the models could be downloaded, but the spawn screen
had no idea llama.cpp existed: the model field and every extra setting were
gated behind `isClaude`, so a llama provider offered nothing, `model`
arrived null, and the driver refused with "a llama.cpp session needs a
model". The feature was reachable only by curl, which is not what was asked
for.

A llama provider now gets the models this backend has downloaded, as a
picker rather than free text -- there is nothing sensible to type, and a
name that is not on disk is a session that cannot start. Context size and
temperature are there too, blank meaning llama.cpp's own default rather
than a zero. Spawn stays disabled until a model is chosen, because without
one the button could only fail.

**Two bugs that only appeared by pressing the button**, both mine, both
from changing the server without re-driving the app:

- The app sent the setup's *label* where the server had started resolving
  by *id*. The failure was almost self-diagnosing -- `no setup named "this
  machine" -- configured: this machine` -- and that message now says "no
  setup with id" and lists ids, since listing labels was what made it read
  as a contradiction.
- The session header showed `on local`, the id, because the app read
  `setup` where the server had begun sending both `setup` (id) and
  `setupName` (label). The app now carries only the label: nothing in it
  addresses a setup, and holding both is what let it show the wrong one.

Verified by doing it: rediscovered the local machine from the phone so
`local-llama` appeared, spawned a session on Qwen3-0.6B-Q8_0 with a 4096
context, sent "Reply with exactly one word: ready", and it replied "ready"
with 125 tokens counted.

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 13:38:00 -04:00
1 parent d0b6b66a44
commit 6187958de3
5 files changed
+91 -9

No files matched your search

@@ -110,7 +110,11 @@ private fun String.urlEncoded(): String = java.net.URLEncoder.encode(this, Chars
// which of that machine's providers it runs.
data class SessionSummary(
val id: String,
val setup: String,
/**
* The machine's current label. The id is deliberately not carried: nothing here addresses a
* setup, and holding both invites showing the wrong one, which is what happened.
*/
val setupName: String,
val provider: String,
val title: String,
val model: String?,
@@ -121,7 +125,7 @@ data class SessionSummary(
private fun parseSession(session: JSONObject) =
SessionSummary(
id = session.getString("id"),
setup = session.getString("setup"),
setupName = session.getString("setupName"),
provider = session.getString("provider"),
title = session.getString("title"),
model = session.optString("model").ifEmpty { null },
@@ -218,7 +218,7 @@ private fun SessionCard(
// than a bare name, so a host isn't mistaken for a model.
listOfNotNull(
session.provider,
"on ${session.setup}",
"on ${session.setupName}",
session.model,
)
.joinToString(" · "),
@@ -242,7 +242,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
Text(
listOfNotNull(
summary.provider,
"on ${summary.setup}",
"on ${summary.setupName}",
summary.model,
if (totalTokens > 0) "$totalTokens tok" else null,
)
@@ -73,6 +73,13 @@ fun SpawnScreen(
// one leaves a filled-in form worth keeping, and that one leaves
// nothing to fill in.
var spawnError by remember { mutableStateOf<String?>(null) }
// Downloaded models, for a llama provider to choose between. Fetched
// beside the setups but kept separate: a Claude session needs none, so
// failing to list them must not stop the screen rendering.
var models by remember { mutableStateOf<List<LocalModel>>(emptyList()) }
var modelKey by remember { mutableStateOf<String?>(null) }
var contextSize by remember { mutableStateOf("") }
var temperature by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
options =
@@ -85,6 +92,9 @@ fun SpawnScreen(
} catch (e: ApiException) {
LoadState.failed(e)
}
models =
runCatching { withContext(Dispatchers.IO) { fetchModels(settings).local } }
.getOrDefault(emptyList())
}
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
@@ -121,6 +131,7 @@ fun SpawnScreen(
// than the provider name keeps a second Claude provider from
// needing anything here.
val isClaude = current?.kind == "claude_cli"
val isLlama = current?.kind == "llama_cpp"
// The machine first, because it decides what can be run at all.
ChipGroup(
@@ -174,6 +185,49 @@ fun SpawnScreen(
modifier = Modifier.fillMaxWidth(),
)
if (isLlama) {
// A llama session names one of the models this backend has
// downloaded, so the choice is that list rather than free
// text -- there is nothing sensible to type here, and a name
// that is not on disk is a session that cannot start.
if (models.isEmpty()) {
Text(
"No models downloaded yet. Get one from the Models screen first.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
ChipGroup(
label = "Model",
// The file, not the whole key: the repository is the
// same for every quantisation of a model, so the file
// name is what tells two of them apart.
options = models.map { it.file },
selected = models.firstOrNull { it.key == modelKey }?.file,
onSelect = { file -> modelKey = models.first { it.file == file }.key },
)
}
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = contextSize,
onValueChange = { contextSize = it },
label = { Text("Context size (blank = the model's default)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = temperature,
onValueChange = { temperature = it },
label = { Text("Temperature (blank = llama.cpp's default)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp))
}
if (isClaude) {
if (current.models.isNotEmpty()) {
Spacer(Modifier.height(16.dp))
@@ -229,12 +283,32 @@ fun SpawnScreen(
withContext(Dispatchers.IO) {
spawnSession(
settings,
setup = setup?.name.orEmpty(),
// The id, not the label: labels are
// editable and the server resolves by
// id.
setup = setup?.id.orEmpty(),
provider = chosen.name,
title = title.trim(),
model = model.trim().takeIf { isClaude },
model =
if (isLlama) modelKey else model.trim().takeIf { isClaude },
cwd = cwd.trim().takeIf { isClaude },
permissionMode = permissionMode.takeIf { isClaude },
// Sent only when set, so blank means
// "whatever llama.cpp does by default"
// rather than a zero.
params =
buildMap {
if (isLlama) {
contextSize
.trim()
.takeIf { it.isNotEmpty() }
?.let { put("contextSize", it) }
temperature
.trim()
.takeIf { it.isNotEmpty() }
?.let { put("temperature", it) }
}
},
)
}
onSpawned(spawned)
@@ -244,7 +318,7 @@ fun SpawnScreen(
}
}
},
enabled = !busy && current != null,
enabled = !busy && current != null && !(isLlama && modelKey == null),
) {
Text(if (busy) "Spawning..." else "Spawn")
}