Rename setups and add provider reauthentication

This commit is contained in:
iris committed 2026-09-12 22:56:43 -04:00
1 parent e9a0f1b9da
commit 7d9df5d572
36 files changed
+1866 -684

No files matched your search

@@ -28,7 +28,7 @@ class ApiException(message: String, val status: Int? = null, cause: Throwable? =
Exception(message, cause)
/**
* Runs one request against the backend, with the pinned TLS setup, the bearer token, and the
* Runs one request against the backend, with the pinned TLS machine, the bearer token, and the
* failure translation every call needs. [readBody] gets the connected, already-status-checked
* connection.
*
@@ -122,12 +122,12 @@ data class SessionSummary(
val id: String,
/**
* Id of the machine this session runs on. Only ever used to *address* that machine -- to pick
* this session's row out of the per-machine usage snapshots. Never shown; [setupName] is what a
* reader sees, and holding both invites showing the wrong one.
* this session's row out of the per-machine usage snapshots. Never shown; [machineName] is what
* a reader sees, and holding both invites showing the wrong one.
*/
val setup: String,
/** The machine's current label. This is the one to display; [setup] is never shown. */
val setupName: String,
val machine: String,
/** The machine's current label. This is the one to display; [machine] is never shown. */
val machineName: String,
val provider: String,
val title: String,
val model: String?,
@@ -238,10 +238,10 @@ data class SessionSummary(
private fun parseSession(session: JSONObject) =
SessionSummary(
id = session.getString("id"),
setup = session.getString("setup"),
machine = session.getString("machine"),
keepsOwnTranscript = session.optBoolean("keepsOwnTranscript", false),
ownTranscriptName = session.optString("ownTranscriptName").ifEmpty { null },
setupName = session.getString("setupName"),
machineName = session.getString("machineName"),
provider = session.getString("provider"),
title = session.getString("title"),
model = session.optString("model").ifEmpty { null },
@@ -328,7 +328,8 @@ fun deleteSubagents(settings: ServerSettings, sessionId: String, subagentIds: Li
) {}
}
// What the server offers, so the spawn screen has no hardcoded lists: a setup added to the server's
// What the server offers, so the spawn screen has no hardcoded lists: a machine 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
@@ -345,9 +346,9 @@ data class Provider(
* A machine, and what it can run. [address] is absent for the backend itself.
*
* [id] is stable and [name] is not: renaming a machine keeps its sessions, so everything that
* refers to a setup uses the id and everything a person reads uses the name.
* refers to a machine uses the id and everything a person reads uses the name.
*/
data class Setup(
data class Machine(
val id: String,
val name: String,
val address: String?,
@@ -366,16 +367,91 @@ private fun parseProvider(provider: JSONObject): Provider {
)
}
private fun parseSetup(setup: JSONObject) =
Setup(
id = setup.getString("id"),
name = setup.getString("name"),
address = setup.optString("address").ifEmpty { null },
providers = setup.getJSONArray("providers").mapObjects(::parseProvider),
private fun parseMachine(machine: JSONObject) =
Machine(
id = machine.getString("id"),
name = machine.getString("name"),
address = machine.optString("address").ifEmpty { null },
providers = machine.getJSONArray("providers").mapObjects(::parseProvider),
)
fun fetchSetups(settings: ServerSettings): List<Setup> =
requestFromServer(settings, "/setups") { it.jsonObjects(::parseSetup) }
fun fetchMachines(settings: ServerSettings): List<Machine> =
requestFromServer(settings, "/machines") { it.jsonObjects(::parseMachine) }
/** One CLI-owned provider sign-in. The browser URL and pasted code are never persisted. */
data class ProviderLogin(
val attempt: String,
val state: String,
val authorizationUrl: String?,
val detail: String?,
)
private fun parseProviderLogin(login: JSONObject) =
ProviderLogin(
attempt = login.getString("attempt"),
state = login.getString("state"),
authorizationUrl = login.optString("authorizationUrl").ifEmpty { null },
detail = login.optString("detail").ifEmpty { null },
)
private fun providerLoginPath(machine: String, provider: String) =
"/machines/${machine.urlEncoded()}/providers/${provider.urlEncoded()}/auth"
fun startProviderLogin(
settings: ServerSettings,
machine: String,
provider: String,
): ProviderLogin =
requestFromServer(
settings,
providerLoginPath(machine, provider),
method = "POST",
readTimeoutMs = 25_000,
) {
parseProviderLogin(it.jsonObject())
}
fun fetchProviderLogin(
settings: ServerSettings,
machine: String,
provider: String,
attempt: String,
): ProviderLogin =
requestFromServer(
settings,
"${providerLoginPath(machine, provider)}/${attempt.urlEncoded()}",
) {
parseProviderLogin(it.jsonObject())
}
fun submitProviderLoginCode(
settings: ServerSettings,
machine: String,
provider: String,
attempt: String,
code: String,
): ProviderLogin =
requestFromServer(
settings,
"${providerLoginPath(machine, provider)}/${attempt.urlEncoded()}/code",
method = "POST",
jsonBody = JSONObject().put("code", code).toString(),
) {
parseProviderLogin(it.jsonObject())
}
fun cancelProviderLogin(
settings: ServerSettings,
machine: String,
provider: String,
attempt: String,
) {
requestFromServer(
settings,
"${providerLoginPath(machine, provider)}/${attempt.urlEncoded()}",
method = "DELETE",
) {}
}
/**
* A Claude Code session already on a machine, which can be continued here.
@@ -426,7 +502,7 @@ data class Importable(
)
/**
* One frame of `GET /setups/{id}/importable/events`: an operation starting, finishing or failing.
* One frame of `GET /machines/{id}/importable/events`: an operation starting, finishing or failing.
*
* [operation] is only set by a start and [message] only by a failure -- the three states are every
* way an operation can be, and each carries exactly what that state knows.
@@ -461,8 +537,8 @@ fun parseImportableChange(payload: String): ImportableChange? =
* reading every transcript Claude Code has ever written: about four seconds against a gigabyte of
* them before the tunnel adds anything. A timeout is for a server that has stopped answering.
*/
fun fetchImportable(settings: ServerSettings, setup: String): List<Importable> =
requestFromServer(settings, "/setups/$setup/importable", readTimeoutMs = 60000) {
fun fetchImportable(settings: ServerSettings, machine: String): List<Importable> =
requestFromServer(settings, "/machines/$machine/importable", readTimeoutMs = 60000) {
it.jsonObjects { session ->
Importable(
id = session.getString("id"),
@@ -515,10 +591,10 @@ private fun SshDetails.toJson() =
}
/** What a machine turns out to have, without saving anything. */
fun probeSetup(settings: ServerSettings, ssh: SshDetails?): List<Provider> =
fun probeMachine(settings: ServerSettings, ssh: SshDetails?): List<Provider> =
requestFromServer(
settings,
"/setups/probe",
"/machines/probe",
method = "POST",
jsonBody = JSONObject().apply { if (ssh != null) put("ssh", ssh.toJson()) }.toString(),
readTimeoutMs = 40000,
@@ -526,10 +602,10 @@ fun probeSetup(settings: ServerSettings, ssh: SshDetails?): List<Provider> =
it.jsonObjects(::parseProvider)
}
fun addSetup(settings: ServerSettings, name: String, ssh: SshDetails?): Setup =
fun addMachine(settings: ServerSettings, name: String, ssh: SshDetails?): Machine =
requestFromServer(
settings,
"/setups",
"/machines",
method = "POST",
jsonBody =
JSONObject()
@@ -538,19 +614,19 @@ fun addSetup(settings: ServerSettings, name: String, ssh: SshDetails?): Setup =
.toString(),
readTimeoutMs = 40000,
) {
parseSetup(it.jsonObject())
parseMachine(it.jsonObject())
}
/** Renames a machine, and optionally asks it again what it has. */
fun updateSetup(
fun updateMachine(
settings: ServerSettings,
id: String,
name: String? = null,
rediscover: Boolean = false,
): Setup =
): Machine =
requestFromServer(
settings,
"/setups/${id.urlEncoded()}",
"/machines/${id.urlEncoded()}",
method = "PUT",
jsonBody =
JSONObject()
@@ -561,20 +637,20 @@ fun updateSetup(
.toString(),
readTimeoutMs = 40000,
) {
parseSetup(it.jsonObject())
parseMachine(it.jsonObject())
}
fun deleteSetup(settings: ServerSettings, id: String) {
requestFromServer(settings, "/setups/${id.urlEncoded()}", method = "DELETE") {}
fun deleteMachine(settings: ServerSettings, id: String) {
requestFromServer(settings, "/machines/${id.urlEncoded()}", method = "DELETE") {}
}
/**
* Spawns a session and returns it as the list would show it. [setup] names the machine and
* Spawns a session and returns it as the list would show it. [machine] names the machine and
* [provider] one of the things that machine offers.
*/
fun spawnSession(
settings: ServerSettings,
setup: String,
machine: String,
provider: String,
title: String,
model: String? = null,
@@ -592,7 +668,7 @@ fun spawnSession(
method = "POST",
jsonBody =
JSONObject()
.put("setup", setup)
.put("machine", machine)
.put("provider", provider)
.put("title", title)
.apply {
@@ -705,7 +781,7 @@ fun uploadAttachment(
}
/**
* One entry of a directory on the machine a setup names.
* One entry of a directory on a configured machine.
*
* [kind] is the *target's* where the entry is a symlink, so a link to a directory descends; [link]
* still says it is one. Neither is worked out here -- the machine answers both.
@@ -762,11 +838,11 @@ sealed class FileContent {
/** What a file is after a write, so the editor's precondition is fresh without a second read. */
data class Written(val size: Long, val modified: Long, val sha256: String)
/** Everything in [path] on the machine [setup] names, and what [path] resolved to. */
fun fetchDir(settings: ServerSettings, setup: String, path: String): Listing =
/** Everything in [path] on the machine [machine] names, and what [path] resolved to. */
fun fetchDir(settings: ServerSettings, machine: String, path: String): Listing =
requestFromServer(
settings,
"/setups/${setup.urlEncoded()}/dir?path=${path.urlEncoded()}",
"/machines/${machine.urlEncoded()}/dir?path=${path.urlEncoded()}",
readTimeoutMs = 30000,
) { connection ->
val body = connection.jsonObject()
@@ -786,10 +862,10 @@ fun fetchDir(settings: ServerSettings, setup: String, path: String): Listing =
}
/** One file's content, or which of the reasons there is none to show. */
fun fetchFile(settings: ServerSettings, setup: String, path: String): FileContent =
fun fetchFile(settings: ServerSettings, machine: String, path: String): FileContent =
requestFromServer(
settings,
"/setups/${setup.urlEncoded()}/file?path=${path.urlEncoded()}",
"/machines/${machine.urlEncoded()}/file?path=${path.urlEncoded()}",
// A megabyte over the tunnel, and a `stat` plus a `sha256sum` on the far machine before any
// of it moves. Well clear of that rather than just above it.
readTimeoutMs = 60000,
@@ -826,14 +902,14 @@ fun fetchFile(settings: ServerSettings, setup: String, path: String): FileConten
*/
fun writeFile(
settings: ServerSettings,
setup: String,
machine: String,
path: String,
content: String,
ifSha256: String,
): Written =
requestFromServer(
settings,
"/setups/${setup.urlEncoded()}/file",
"/machines/${machine.urlEncoded()}/file",
method = "PUT",
jsonBody =
JSONObject()
@@ -848,10 +924,10 @@ fun writeFile(
}
/** Creates an empty file. Refused, with the machine's own words, if the name is already taken. */
fun createFile(settings: ServerSettings, setup: String, path: String) {
fun createFile(settings: ServerSettings, machine: String, path: String) {
requestFromServer(
settings,
"/setups/${setup.urlEncoded()}/file",
"/machines/${machine.urlEncoded()}/file",
method = "POST",
jsonBody = JSONObject().put("path", path).toString(),
readTimeoutMs = 30000,
@@ -859,10 +935,10 @@ fun createFile(settings: ServerSettings, setup: String, path: String) {
}
/** Creates a directory, with the same refusal as [createFile]. */
fun createDir(settings: ServerSettings, setup: String, path: String) {
fun createDir(settings: ServerSettings, machine: String, path: String) {
requestFromServer(
settings,
"/setups/${setup.urlEncoded()}/dir",
"/machines/${machine.urlEncoded()}/dir",
method = "POST",
jsonBody = JSONObject().put("path", path).toString(),
readTimeoutMs = 30000,
@@ -893,22 +969,23 @@ data class UsageWindow(
data class UsageSnapshot(
val provider: String,
/** Stable id of the machine these numbers belong to. */
val setup: String,
val machine: String,
/** That machine's current label. */
val setupName: String,
val machineName: String,
/** Provider-specific billing pool, such as Codex's regular or Luna Reserve pool. */
val limitId: String?,
/** Provider-specific human-facing pool name, when supplied. */
val limitName: String?,
/**
* What came back: "ok", "notLoggedIn", "unreachable" or "failed".
* What came back: "ok", "notLoggedIn", "authenticating", "loginRequired", "unreachable" or
* "failed".
*
* Four rather than a flag, because the screen has to treat them differently. "notLoggedIn" is a
* machine somebody chose not to put an account on -- a fact, not a fault. Collapsing them made
* a healthy setup read as broken.
* Named states rather than a flag, because the screen has to treat them differently.
* "notLoggedIn" is a machine somebody chose not to put an account on -- a fact, not a fault.
* Collapsing them made a healthy machine read as broken.
*/
val state: String,
/** Why, for the two states that are faults. Absent otherwise. */
/** Why, for states that have a useful explanation. Absent otherwise. */
val detail: String?,
val windows: List<UsageWindow>,
)
@@ -919,8 +996,8 @@ fun fetchUsage(settings: ServerSettings): List<UsageSnapshot> =
connection.jsonObjects { snapshot ->
UsageSnapshot(
provider = snapshot.getString("provider"),
setup = snapshot.optString("setup"),
setupName = snapshot.optString("setupName"),
machine = snapshot.optString("machine"),
machineName = snapshot.optString("machineName"),
limitId = snapshot.optString("limitId").ifEmpty { null },
limitName = snapshot.optString("limitName").ifEmpty { null },
// Unknown to an older backend, and unknown is not "fine": defaulting to "ok" would
@@ -1000,10 +1077,10 @@ fun startSession(settings: ServerSettings, sessionId: String) {
* One request for the whole batch, which is what makes a handover all-or-nothing. One per row meant
* a batch could half-arrive, and the rows that were missed looked exactly like rows not picked.
*/
fun deleteImportable(settings: ServerSettings, setup: String, sessionIds: List<String>) {
fun deleteImportable(settings: ServerSettings, machine: String, sessionIds: List<String>) {
requestFromServer(
settings,
"/setups/$setup/importable/delete",
"/machines/$machine/importable/delete",
method = "POST",
jsonBody = JSONObject().put("sessions", JSONArray(sessionIds)).toString(),
) {}
@@ -1019,7 +1096,7 @@ fun deleteImportable(settings: ServerSettings, setup: String, sessionIds: List<S
*/
fun startImport(
settings: ServerSettings,
setup: String,
machine: String,
sessionIds: List<String>,
provider: String,
permissionMode: String? = null,
@@ -1034,7 +1111,7 @@ fun startImport(
}
requestFromServer(
settings,
"/setups/$setup/importable/import",
"/machines/$machine/importable/import",
method = "POST",
jsonBody = body.toString(),
) {}
@@ -1297,8 +1374,8 @@ private fun parseDownload(o: JSONObject) =
* offering the backend's would name files that are not there, turning a choice that cannot work
* into a session that fails when it tries to load one.
*/
fun fetchSetupModels(settings: ServerSettings, setupId: String): List<LocalModel> =
requestFromServer(settings, "/setups/${setupId.urlEncoded()}/models") { connection ->
fun fetchMachineModels(settings: ServerSettings, machineId: String): List<LocalModel> =
requestFromServer(settings, "/machines/${machineId.urlEncoded()}/models") { connection ->
JSONArray(connection.inputStream.bufferedReader().readText()).mapObjects { m ->
LocalModel(
key = m.getString("key"),
@@ -1312,12 +1389,12 @@ fun fetchSetupModels(settings: ServerSettings, setupId: String): List<LocalModel
/** The current model catalog for one CLI provider on the machine where it runs. */
fun fetchProviderModels(
settings: ServerSettings,
setupId: String,
machineId: String,
provider: String,
): List<String> =
requestFromServer(
settings,
"/setups/${setupId.urlEncoded()}/providers/${provider.urlEncoded()}/models",
"/machines/${machineId.urlEncoded()}/providers/${provider.urlEncoded()}/models",
) { connection ->
JSONArray(connection.inputStream.bufferedReader().readText()).strings()
}
@@ -32,9 +32,9 @@ import kotlinx.coroutines.withContext
* One `when` rather than a navigation library: a handful of screens, with [Screen.Main] as the root
* and the back button the only other way between them.
*
* Import, models and setups are tabs inside [MainScreen] -- four views of the same backend, none of
* them a step down from another -- and what is left here is only what genuinely is a step down: one
* session, spawning one, and settings.
* Import, models and machines are tabs inside [MainScreen] -- four views of the same backend, none
* of them a step down from another -- and what is left here is only what genuinely is a step down:
* one session, spawning one, and settings.
*/
private sealed class Screen {
/**
@@ -44,13 +44,13 @@ import kotlinx.coroutines.withContext
/**
* Which machine's files to show, and where to start.
*
* A **setup**, not a session: a filesystem is a property of a machine, and a session only says
* where it was working. That is what makes a second way in -- from the setups tab -- one more
* A **machine**, not a session: a filesystem is a property of a machine, and a session only says
* where it was working. That is what makes a second way in -- from the machines tab -- one more
* caller rather than any new code here.
*/
data class FilesTarget(
val setup: String,
val setupName: String,
val machine: String,
val machineName: String,
val start: String,
/** A document to open immediately; [start] remains the fallback directory. */
val file: String? = null,
@@ -59,8 +59,8 @@ data class FilesTarget(
/** The explorer target for this session's machine, optionally opened on [file]. */
fun SessionSummary.filesTarget(file: String? = null) =
FilesTarget(
setup = setup,
setupName = setupName,
machine = machine,
machineName = machineName,
start = cwd?.takeIf { it.isNotBlank() } ?: "~",
file = file,
)
@@ -131,7 +131,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
listings[path] =
try {
withContext(Dispatchers.IO) {
LoadState.Loaded(fetchDir(settings, target.setup, path))
LoadState.Loaded(fetchDir(settings, target.machine, path))
}
} catch (e: ApiException) {
LoadState.failed(e)
@@ -162,7 +162,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
// A file link can open without visiting the project first, but Back still needs to know where
// the project is. Home is likewise resolved by the machine rather than guessed on the phone;
// it is what lets every path beneath it be displayed with `~`, including over ssh.
LaunchedEffect(target.setup, target.start) {
LaunchedEffect(target.machine, target.start) {
if (target.file != null) load(target.start, again = false)
if (target.start != "~") load("~", again = false)
}
@@ -187,7 +187,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
FilesHeader(
title = baseName(shownAt),
path = shownAt,
machine = target.setupName,
machine = target.machineName,
onBack = { leave(UnsavedDestination.Session) },
) {
GlyphButton(
@@ -241,7 +241,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
if (creating && dir != null && listing != null) {
CreateDialog(
settings = settings,
setup = target.setup,
machine = target.machine,
directory = listing.path,
onDismiss = { creating = false },
onCreated = { path, isDirectory ->
@@ -444,7 +444,7 @@ private fun ColumnScope.DocPane(
state = LoadState.Loading
state =
try {
val got = withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) }
val got = withContext(Dispatchers.IO) { fetchFile(settings, target.machine, path) }
if (got is FileContent.Text) draft = TextFieldValue(got.content)
LoadState.Loaded(got)
} catch (e: ApiException) {
@@ -467,7 +467,7 @@ private fun ColumnScope.DocPane(
try {
val written =
withContext(Dispatchers.IO) {
writeFile(settings, target.setup, path, draft.text, against)
writeFile(settings, target.machine, path, draft.text, against)
}
state =
LoadState.Loaded(
@@ -496,7 +496,7 @@ private fun ColumnScope.DocPane(
FilesHeader(
title = name,
path = tildePath(path, homeDirectory),
machine = target.setupName,
machine = target.machineName,
onBack = onBack,
) {
if (editing) {
@@ -595,7 +595,9 @@ private fun ColumnScope.DocPane(
scope.launch {
val fresh =
try {
withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) }
withContext(Dispatchers.IO) {
fetchFile(settings, target.machine, path)
}
} catch (e: ApiException) {
saveError = e.message
conflict = null
@@ -639,7 +641,7 @@ private fun Note(text: String) {
@Composable
private fun CreateDialog(
settings: ServerSettings,
setup: String,
machine: String,
directory: String,
onDismiss: () -> Unit,
onCreated: (String, Boolean) -> Unit,
@@ -659,8 +661,8 @@ private fun CreateDialog(
scope.launch {
try {
withContext(Dispatchers.IO) {
if (isDirectory) createDir(settings, setup, path)
else createFile(settings, setup, path)
if (isDirectory) createDir(settings, machine, path)
else createFile(settings, machine, path)
}
onCreated(path, isDirectory)
} catch (e: ApiException) {
@@ -82,8 +82,8 @@ private const val SETTLE_MS = 500L
@Composable
fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (SessionSummary) -> Unit) {
val scope = rememberCoroutineScope()
var setups by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
var chosen by remember { mutableStateOf<Setup?>(null) }
var machines by remember { mutableStateOf<LoadState<List<Machine>>>(LoadState.Loading) }
var chosen by remember { mutableStateOf<Machine?>(null) }
var sessions by remember { mutableStateOf<LoadState<List<Importable>>>(LoadState.Loading) }
// What is happening to each row right now, as the word the row shows. A map keyed by id rather
@@ -113,9 +113,9 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
* Taken from the answer rather than kept across the load: the server is what knows what is
* running, and this screen may be opening on work another phone started.
*/
suspend fun fetchInto(setup: Setup): LoadState<List<Importable>> =
suspend fun fetchInto(machine: Machine): LoadState<List<Importable>> =
try {
val rows = withContext(Dispatchers.IO) { fetchImportable(settings, setup.id) }
val rows = withContext(Dispatchers.IO) { fetchImportable(settings, machine.id) }
running = rows.mapNotNull { row -> row.pending?.let { row.id to it } }.toMap()
rowErrors = rows.mapNotNull { row -> row.error?.let { row.id to it } }.toMap()
LoadState.Loaded(rows)
@@ -123,10 +123,10 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
LoadState.Error(err.message ?: "Couldn't list sessions")
}
fun loadSessions(setup: Setup) {
fun loadSessions(machine: Machine) {
sessions = LoadState.Loading
selected = emptySet()
scope.launch { sessions = fetchInto(setup) }
scope.launch { sessions = fetchInto(machine) }
}
/** Takes a row out of the list, once the machine no longer has it to offer. */
@@ -140,9 +140,9 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
}
LaunchedEffect(reloadToken) {
setups =
machines =
try {
val found = withContext(Dispatchers.IO) { fetchSetups(settings) }
val found = withContext(Dispatchers.IO) { fetchMachines(settings) }
found.firstOrNull()?.let {
chosen = it
loadSessions(it)
@@ -170,7 +170,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
selected = emptySet()
running = running + targets.associate { it.id to WAITING }
rowErrors = rowErrors - targets.map { it.id }.toSet()
val setup = chosen
val machine = chosen
val ids = targets.map { it.id }
scope.launch {
// One request for the whole batch, not one per row. Sent row by row, a handover was
@@ -197,11 +197,11 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
// The listing is the repair, because it carries the same state the events do. Only when
// something still looks outstanding, so the ordinary case does not pay for a second
// listing, which is the most expensive call this screen makes.
if (setup != null && targets.any { running.containsKey(it.id) }) {
if (machine != null && targets.any { running.containsKey(it.id) }) {
// Quietly: no Loading, because blanking the list to report on rows that are already
// saying what is happening to them is the flicker this screen avoids everywhere
// else.
sessions = fetchInto(setup)
sessions = fetchInto(machine)
}
}
}
@@ -213,12 +213,12 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
/** Continues [targets] in the background, leaving the screen where it is. */
fun importAll(targets: List<Importable>) {
val setup = chosen ?: return
val machine = chosen ?: return
val useProvider = provider ?: return
handOver(targets) { ids ->
startImport(
settings,
setup = setup.id,
machine = machine.id,
sessionIds = ids,
provider = useProvider.name,
permissionMode = permissionMode,
@@ -234,7 +234,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
* it, which is the case where waiting is the right thing anyway.
*/
fun importAndOpen(target: Importable) {
val setup = chosen ?: return
val machine = chosen ?: return
val useProvider = provider ?: return
running = running + (target.id to IMPORTING)
rowErrors = rowErrors - target.id
@@ -244,7 +244,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
withContext(Dispatchers.IO) {
spawnSession(
settings,
setup = setup.id,
machine = machine.id,
provider = useProvider.name,
// Nothing to say: the server titles it from the session it continues.
title = "",
@@ -272,10 +272,10 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
java.util.concurrent.atomic.AtomicReference<ImportableStream?>(null)
}
LaunchedEffect(chosen?.id) {
val setup = chosen?.id ?: return@LaunchedEffect
val machine = chosen?.id ?: return@LaunchedEffect
try {
while (true) {
val stream = ImportableStream(settings, setup)
val stream = ImportableStream(settings, machine)
liveChanges.set(stream)
try {
withContext(Dispatchers.IO) {
@@ -343,24 +343,24 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
)
Spacer(Modifier.height(12.dp))
when (val loaded = setups) {
when (val loaded = machines) {
is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error -> Text(loaded.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded -> {
// Only worth choosing when there is a choice.
if (loaded.value.size > 1) {
Row(Modifier.fillMaxWidth()) {
loaded.value.forEach { setup ->
loaded.value.forEach { machine ->
TextButton(
onClick = {
chosen = setup
loadSessions(setup)
chosen = machine
loadSessions(machine)
}
) {
Text(
setup.name,
machine.name,
color =
if (setup.id == chosen?.id)
if (machine.id == chosen?.id)
MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -441,9 +441,9 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
confirmButton = {
TextButton(
onClick = {
val setup = chosen ?: return@TextButton
val machine = chosen ?: return@TextButton
confirming = null
handOver(targets) { ids -> deleteImportable(settings, setup.id, ids) }
handOver(targets) { ids -> deleteImportable(settings, machine.id, ids) }
}
) {
// Coloured by consequence: this takes something away, wherever it appears.
@@ -12,13 +12,13 @@ package com.example.aiapp
* the caller owns reconnecting -- there is no cursor to resume from, because anything missed is in
* the next listing.
*/
class ImportableStream(settings: ServerSettings, private val setup: String) {
class ImportableStream(settings: ServerSettings, private val machine: String) {
private val stream = Sse(settings)
fun close() = stream.close()
fun run(onOpen: () -> Unit, onChange: (ImportableChange) -> Unit) {
stream.run("/setups/$setup/importable/events", onOpen) { _, data ->
stream.run("/machines/$machine/importable/events", onOpen) { _, data ->
if (data.isNotEmpty()) parseImportableChange(data)?.let(onChange)
}
}
@@ -37,19 +37,20 @@ import kotlinx.coroutines.withContext
* which is what keeps the enrolled token from being able to introduce commands.
*/
@Composable
fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
fun MachinesScreen(settings: ServerSettings, reloadToken: Int) {
val scope = rememberCoroutineScope()
var state by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
var state by remember { mutableStateOf<LoadState<List<Machine>>>(LoadState.Loading) }
var adding by remember { mutableStateOf(false) }
var renaming by remember { mutableStateOf<Setup?>(null) }
var confirmingDelete by remember { mutableStateOf<Setup?>(null) }
var renaming by remember { mutableStateOf<Machine?>(null) }
var confirmingDelete by remember { mutableStateOf<Machine?>(null) }
var signingIn by remember { mutableStateOf<Pair<Machine, Provider>?>(null) }
var busy by remember { mutableStateOf<String?>(null) }
var actionError by remember { mutableStateOf<String?>(null) }
suspend fun reload() {
state =
try {
withContext(Dispatchers.IO) { LoadState.Loaded(fetchSetups(settings)) }
withContext(Dispatchers.IO) { LoadState.Loaded(fetchMachines(settings)) }
} catch (e: ApiException) {
LoadState.failed(e)
}
@@ -82,19 +83,19 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded ->
LazyColumn(Modifier.fillMaxSize()) {
uniqueItems(current.value, key = { it.id }) { setup ->
SetupCard(
setup = setup,
onRename = { renaming = setup },
uniqueItems(current.value, key = { it.id }) { machine ->
MachineCard(
machine = machine,
onRename = { renaming = machine },
onRediscover = {
scope.launch {
busy = "Asking ${setup.name} what it has…"
busy = "Asking ${machine.name} what it has…"
actionError =
runCatching {
withContext(Dispatchers.IO) {
updateSetup(
updateMachine(
settings,
setup.id,
machine.id,
rediscover = true,
)
}
@@ -105,7 +106,8 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
reload()
}
},
onDelete = { confirmingDelete = setup },
onDelete = { confirmingDelete = machine },
onSignIn = { provider -> signingIn = machine to provider },
)
}
}
@@ -113,7 +115,7 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
}
if (adding) {
AddSetupDialog(
AddMachineDialog(
onDismiss = { adding = false },
onAdd = { name, ssh ->
adding = false
@@ -121,7 +123,7 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
busy = "Asking $name what it has…"
actionError =
runCatching {
withContext(Dispatchers.IO) { addSetup(settings, name, ssh) }
withContext(Dispatchers.IO) { addMachine(settings, name, ssh) }
}
.exceptionOrNull()
?.message
@@ -129,13 +131,13 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
reload()
}
},
onTest = { ssh -> withContext(Dispatchers.IO) { probeSetup(settings, ssh) } },
onTest = { ssh -> withContext(Dispatchers.IO) { probeMachine(settings, ssh) } },
)
}
renaming?.let { setup ->
renaming?.let { machine ->
RenameDialog(
setup = setup,
machine = machine,
onDismiss = { renaming = null },
onRename = { name ->
renaming = null
@@ -143,7 +145,7 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
actionError =
runCatching {
withContext(Dispatchers.IO) {
updateSetup(settings, setup.id, name = name)
updateMachine(settings, machine.id, name = name)
}
}
.exceptionOrNull()
@@ -154,10 +156,10 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
)
}
confirmingDelete?.let { setup ->
confirmingDelete?.let { machine ->
AlertDialog(
onDismissRequest = { confirmingDelete = null },
title = { Text("Remove \"${setup.name}\"?") },
title = { Text("Remove \"${machine.name}\"?") },
text = {
Text(
"The machine is left alone -- this only stops this app offering it. " +
@@ -171,7 +173,9 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
scope.launch {
actionError =
runCatching {
withContext(Dispatchers.IO) { deleteSetup(settings, setup.id) }
withContext(Dispatchers.IO) {
deleteMachine(settings, machine.id)
}
}
.exceptionOrNull()
?.message
@@ -187,34 +191,60 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
},
)
}
signingIn?.let { (machine, provider) ->
ProviderLoginDialog(
settings = settings,
machineId = machine.id,
machineName = machine.name,
provider = provider.name,
onDismiss = { signingIn = null },
onSignedIn = {
signingIn = null
scope.launch { reload() }
},
)
}
}
@Composable
private fun SetupCard(
setup: Setup,
private fun MachineCard(
machine: Machine,
onRename: () -> Unit,
onRediscover: () -> Unit,
onDelete: () -> Unit,
onSignIn: (Provider) -> Unit,
) {
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column(Modifier.padding(12.dp)) {
Text(setup.name, style = MaterialTheme.typography.titleSmall)
Text(machine.name, style = MaterialTheme.typography.titleSmall)
Text(
// Not "this machine": the seeded setup is *called* that, and the card read "this
// Not "this machine": the seeded machine is *called* that, and the card read "this
// machine / this machine".
setup.address ?: "runs where the backend does",
machine.address ?: "runs where the backend does",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(4.dp))
Text(
if (setup.providers.isEmpty()) {
"Nothing found on it. Install something and rediscover."
} else {
setup.providers.joinToString(" · ") { it.name }
},
style = MaterialTheme.typography.bodySmall,
)
if (machine.providers.isEmpty()) {
Text(
"Nothing found on it. Install something and rediscover.",
style = MaterialTheme.typography.bodySmall,
)
} else {
machine.providers.forEach { provider ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(provider.name, style = MaterialTheme.typography.bodySmall)
if (provider.kind == "claude_cli") {
Spacer(Modifier.weight(1f))
TextButton(onClick = { onSignIn(provider) }) { Text("Sign in") }
}
}
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = onRename) { Text("Rename") }
TextButton(onClick = onRediscover) { Text("Rediscover") }
@@ -226,7 +256,7 @@ private fun SetupCard(
}
@Composable
private fun AddSetupDialog(
private fun AddMachineDialog(
onDismiss: () -> Unit,
onAdd: (String, SshDetails?) -> Unit,
onTest: suspend (SshDetails?) -> List<Provider>,
@@ -349,8 +379,8 @@ private fun AddSetupDialog(
}
@Composable
private fun RenameDialog(setup: Setup, onDismiss: () -> Unit, onRename: (String) -> Unit) {
var name by remember { mutableStateOf(setup.name) }
private fun RenameDialog(machine: Machine, onDismiss: () -> Unit, onRename: (String) -> Unit) {
var name by remember { mutableStateOf(machine.name) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Rename") },
@@ -38,7 +38,7 @@ private enum class MainTab(val label: String) {
Sessions("Sessions"),
Import("Import"),
Models("Models"),
Setups("Setups"),
Machines("Machines"),
}
@Composable
@@ -147,7 +147,7 @@ fun MainScreen(
MainTab.Import ->
ImportScreen(settings = settings, reloadToken = token, onImported = onImported)
MainTab.Models -> ModelsScreen(settings = settings, reloadToken = token)
MainTab.Setups -> SetupsScreen(settings = settings, reloadToken = token)
MainTab.Machines -> MachinesScreen(settings = settings, reloadToken = token)
}
}
}
@@ -0,0 +1,215 @@
package com.example.aiapp
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.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Relays a provider CLI's headless browser login without ever owning its credentials.
*
* The URL and code live only in this composition. The CLI process on [machineId] remains the one
* OAuth client and the only writer of its credential file.
*/
@Composable
fun ProviderLoginDialog(
settings: ServerSettings,
machineId: String,
machineName: String,
provider: String,
onDismiss: () -> Unit,
onSignedIn: () -> Unit,
) {
val scope = rememberCoroutineScope()
val uriHandler = LocalUriHandler.current
var login by remember(machineId, provider) { mutableStateOf<ProviderLogin?>(null) }
var code by remember(machineId, provider) { mutableStateOf("") }
var error by remember(machineId, provider) { mutableStateOf<String?>(null) }
var retry by remember(machineId, provider) { mutableIntStateOf(0) }
suspend fun follow(initial: ProviderLogin): ProviderLogin {
var current = initial
val wasSubmitting = initial.state == "submitting"
while (current.state == "starting" || current.state == "submitting") {
delay(400)
current =
withContext(Dispatchers.IO) {
fetchProviderLogin(
settings,
machineId,
provider,
current.attempt,
)
}
login = current
}
if (wasSubmitting && current.state == "waitingForCode" && current.detail == null) {
current =
current.copy(
detail = "That code was not accepted. Copy the complete code and try again."
)
login = current
}
return current
}
LaunchedEffect(machineId, provider, retry) {
error = null
code = ""
login = null
try {
val started =
withContext(Dispatchers.IO) { startProviderLogin(settings, machineId, provider) }
login = started
if (follow(started).state == "succeeded") {
onSignedIn()
}
} catch (e: ApiException) {
error = e.message
}
}
fun dismiss() {
login
?.takeUnless { it.state in setOf("succeeded", "failed", "cancelled") }
?.let {
scope.launch(Dispatchers.IO) {
runCatching { cancelProviderLogin(settings, machineId, provider, it.attempt) }
}
}
onDismiss()
}
AlertDialog(
onDismissRequest = ::dismiss,
title = { Text("Sign in to Claude") },
text = {
Column {
Text(
"Claude will sign in on $machineName. Open the authorization page, then " +
"paste the code it gives you here."
)
Spacer(Modifier.height(12.dp))
when (val current = login) {
null ->
if (error == null) {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator()
Text("Starting sign-in…")
}
}
else ->
when (current.state) {
"starting",
"submitting" ->
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator()
Text(
if (current.state == "submitting") "Checking code…"
else "Starting sign-in…"
)
}
"waitingForCode" -> {
TextButton(
onClick = {
runCatching {
current.authorizationUrl?.let(uriHandler::openUri)
}
.onFailure {
error = "Couldn't open the authorization page."
}
},
enabled = current.authorizationUrl != null,
) {
Text("Open authorization page")
}
OutlinedTextField(
value = code,
onValueChange = { code = it },
label = { Text("Authorization code") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
current.detail?.let {
Text(it, color = MaterialTheme.colorScheme.error)
}
}
"succeeded" -> Text("Signed in on $machineName.")
"cancelled" -> Text("Sign-in was cancelled.")
else ->
Text(
current.detail ?: "Sign-in failed.",
color = MaterialTheme.colorScheme.error,
)
}
}
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
},
confirmButton = {
val current = login
when {
current?.state == "waitingForCode" ->
TextButton(
onClick = {
scope.launch {
error = null
try {
val submitted =
withContext(Dispatchers.IO) {
submitProviderLoginCode(
settings,
machineId,
provider,
current.attempt,
code,
)
}
login = submitted
if (follow(submitted).state == "succeeded") {
onSignedIn()
}
} catch (e: ApiException) {
error = e.message
}
}
},
enabled = code.isNotBlank(),
) {
Text("Continue")
}
error != null || current?.state == "failed" || current?.state == "cancelled" ->
TextButton(onClick = { retry++ }) { Text("Try again") }
current?.state == "succeeded" -> TextButton(onClick = onDismiss) { Text("Done") }
}
},
dismissButton = {
if (login?.state != "succeeded") {
TextButton(onClick = ::dismiss) { Text("Cancel") }
}
},
)
}
@@ -562,7 +562,7 @@ private fun SessionCard(
// separator as the session screen's header and the usage dialog, so one
// pair of facts is not written three ways.
listOfNotNull(
session.setupName,
session.machineName,
session.provider,
session.model?.let { modelLabel(it) },
)
@@ -1117,11 +1117,11 @@ fun SessionScreen(
// Only for the model picker, which a subagent does not have.
if (!isSubagent) {
LaunchedEffect(summary.setup, summary.provider) {
LaunchedEffect(summary.machine, summary.provider) {
val provider = runCatching {
withContext(Dispatchers.IO) {
fetchSetups(settings)
.firstOrNull { it.id == summary.setup }
fetchMachines(settings)
.firstOrNull { it.id == summary.machine }
?.providers
?.firstOrNull { it.name == summary.provider }
}
@@ -1133,7 +1133,7 @@ fun SessionScreen(
?.let {
runCatching {
withContext(Dispatchers.IO) {
fetchProviderModels(settings, summary.setup, summary.provider)
fetchProviderModels(settings, summary.machine, summary.provider)
}
}
.getOrDefault(emptyList())
@@ -1412,7 +1412,7 @@ fun SessionScreen(
// to, and showing it twice means two things to keep in step -- they
// disagreed for a moment on every model change.
Text(
"${summary.setupName} · ${summary.provider}",
"${summary.machineName} · ${summary.provider}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -2044,7 +2044,12 @@ fun SessionScreen(
fullImage?.let { ref -> SessionImageViewer(settings, summary.id, ref) { fullImage = null } }
if (usageOpen) {
usageFeed?.let {
UsageDialog(feed = it, session = summary, onDismiss = { usageOpen = false })
UsageDialog(
settings = settings,
feed = it,
session = summary,
onDismiss = { usageOpen = false },
)
}
}
if (settingsOpen) {
@@ -83,7 +83,7 @@ class UsageFeed(
return when (val state = snapshots) {
is LoadState.Loading -> SessionUsage.Waiting
is LoadState.Error -> SessionUsage.Unavailable(state.message)
is LoadState.Loaded -> usageFor(state.value, session.setup, provider, session.model)
is LoadState.Loaded -> usageFor(state.value, session.machine, provider, session.model)
}
}
}
@@ -168,7 +168,7 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
}
// Nothing at all for a session that meters nothing: a row saying "unknown" there would report
// a problem about a setup somebody chose, on every screen, forever.
// a problem about a machine somebody chose, on every screen, forever.
//
// And nothing while the first fetch is out, which is a different silence. A request in flight
// is not a state to report -- and the session that meters nothing is exactly the one this
@@ -250,7 +250,7 @@ private fun usageWindowLabel(window: UsageWindow, now: OffsetDateTime): String {
}
/**
* One meter's snapshot, out of every machine's: [setup]'s row for [provider].
* One meter's snapshot, out of every machine's: [machine]'s row for [provider].
*
* Both halves are needed to pick it. A machine can hold more than one meter -- the Claude CLI's
* account and, while a test has one set, an echo session's invented one -- and a snapshot is one
@@ -262,19 +262,27 @@ private fun usageWindowLabel(window: UsageWindow, now: OffsetDateTime): String {
*/
fun usageFor(
snapshots: List<UsageSnapshot>,
setup: String,
machine: String,
provider: String,
model: String?,
): SessionUsage {
// No snapshot at all means the backend never asked, which it only does where there is nothing
// to ask about. That is a different answer from having asked and failed.
val pools = usageSnapshotsFor(snapshots, setup, provider)
val pools = usageSnapshotsFor(snapshots, machine, provider)
if (pools.isEmpty()) return SessionUsage.NotMetered
val mine =
usagePoolFor(pools, model)
?: return SessionUsage.Unavailable("couldn't tell which usage pool this session uses")
if (mine.state != "ok") {
return SessionUsage.Unavailable(mine.detail ?: mine.state)
val why =
mine.detail
?: when (mine.state) {
"notLoggedIn" -> "no Claude account is signed in on this machine"
"authenticating" -> "Claude sign-in is in progress"
"loginRequired" -> "Claude sign-in is required"
else -> mine.state
}
return SessionUsage.Unavailable(why)
}
return SessionUsage.Known(mine.windows)
}
@@ -282,11 +290,11 @@ fun usageFor(
/** Every billing pool reported for one provider on one machine. */
internal fun usageSnapshotsFor(
snapshots: List<UsageSnapshot>,
setup: String,
machine: String,
provider: String?,
): List<UsageSnapshot> =
if (provider == null) emptyList()
else snapshots.filter { it.setup == setup && it.provider == provider }
else snapshots.filter { it.machine == machine && it.provider == provider }
/** The pool an explicit model names, or the provider's generic pool for every other model. */
internal fun usagePoolFor(pools: List<UsageSnapshot>, model: String?): UsageSnapshot? {
@@ -48,12 +48,13 @@ fun SpawnScreen(
val scope = rememberCoroutineScope()
// 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.
var options by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
var options by remember { mutableStateOf<LoadState<List<Machine>>>(LoadState.Loading) }
// 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
// Machine first, then one of its providers. Choosing a machine can invalidate the provider, so
// the
// provider is stored by name and resolved against the current machine rather than held as an
// object that could outlive the list it came from.
var setupName by remember { mutableStateOf<String?>(null) }
var machineName by remember { mutableStateOf<String?>(null) }
var providerName by remember { mutableStateOf<String?>(null) }
var title by remember { mutableStateOf("") }
var model by remember { mutableStateOf("") }
@@ -80,7 +81,7 @@ fun SpawnScreen(
var temperature by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
// Separate from the setups fetch below and deliberately not fatal: failing to learn the
// Separate from the machines fetch below and deliberately not fatal: failing to learn the
// default must leave a screen you can still spawn from, so the picker stays on "default"
// and says so rather than the whole form refusing to draw.
runCatching { withContext(Dispatchers.IO) { fetchDefaultEffort(settings) } }
@@ -88,9 +89,9 @@ fun SpawnScreen(
defaultsAsked = true
options =
try {
val fetched = withContext(Dispatchers.IO) { fetchSetups(settings) }
val fetched = withContext(Dispatchers.IO) { fetchMachines(settings) }
val first = fetched.firstOrNull()
setupName = first?.name
machineName = first?.name
providerName = first?.providers?.firstOrNull()?.name
LoadState.Loaded(fetched)
} catch (e: ApiException) {
@@ -112,7 +113,7 @@ fun SpawnScreen(
// 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 setups =
val machines =
when (val state = options) {
is LoadState.Loading -> {
CircularProgressIndicator()
@@ -124,19 +125,19 @@ fun SpawnScreen(
}
is LoadState.Loaded -> state.value
}
val setup = setups.firstOrNull { it.name == setupName }
val machine = machines.firstOrNull { it.name == machineName }
// Whichever machine is chosen now, asked again when that changes. The old machine's list
// is dropped first rather than left on screen: a file name from another machine looks
// exactly like one from this one.
LaunchedEffect(setup?.id) {
LaunchedEffect(machine?.id) {
models = emptyList()
modelKey = null
val id = setup?.id ?: return@LaunchedEffect
val id = machine?.id ?: return@LaunchedEffect
models =
runCatching { withContext(Dispatchers.IO) { fetchSetupModels(settings, id) } }
runCatching { withContext(Dispatchers.IO) { fetchMachineModels(settings, id) } }
.getOrDefault(emptyList())
}
val current = setup?.providers?.firstOrNull { it.name == providerName }
val current = machine?.providers?.firstOrNull { it.name == providerName }
// Coding CLIs take a working directory, model, permission mode and thinking level. Keying
// the extra fields on the kind rather than the provider name keeps a second installation
// from needing anything here.
@@ -145,7 +146,7 @@ fun SpawnScreen(
val isCodingCli = isClaude || isCodex
val isLlama = current?.kind == "llama_cpp"
LaunchedEffect(setup?.id, current?.name) {
LaunchedEffect(machine?.id, current?.name) {
model = ""
providerModels = emptyList()
providerModelsError = null
@@ -155,7 +156,7 @@ fun SpawnScreen(
try {
providerModels =
withContext(Dispatchers.IO) {
fetchProviderModels(settings, setup.id, current.name)
fetchProviderModels(settings, machine.id, current.name)
}
} catch (e: ApiException) {
providerModelsError = e.message
@@ -169,41 +170,41 @@ fun SpawnScreen(
// The machine first, because it decides what can be run at all.
ChipGroup(
label = "Setup",
options = setups.map { it.name },
selected = setupName,
label = "Machine",
options = machines.map { it.name },
selected = machineName,
onSelect = { name ->
setupName = name
machineName = 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
machines.firstOrNull { it.name == name }?.providers?.firstOrNull()?.name
},
)
setup?.address?.let {
machine?.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
// The address belongs to the machine 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
// Only what this machine actually has. A machine with none says so rather than showing an
// empty row that reads as a failure.
if (setup != null && setup.providers.isEmpty()) {
if (machine != null && machine.providers.isEmpty()) {
Text(
"\"${setup.name}\" has no providers configured.",
"\"${machine.name}\" has no providers configured.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
ChipGroup(
label = "Provider",
options = setup?.providers?.map { it.name }.orEmpty(),
options = machine?.providers?.map { it.name }.orEmpty(),
selected = providerName,
onSelect = { providerName = it },
)
@@ -225,7 +226,7 @@ fun SpawnScreen(
// disk is a session that cannot start.
if (models.isEmpty()) {
Text(
"No models on ${setup.name}. The Models screen downloads " +
"No models on ${machine.name}. The Models screen downloads " +
"to the backend; another machine needs the file put there itself.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -277,7 +278,7 @@ fun SpawnScreen(
)
providerModels.isEmpty() ->
Text(
"This setup reported no selectable models.",
"This machine reported no selectable models.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -358,8 +359,8 @@ fun SpawnScreen(
settings,
// The id, not the label: labels are editable and the server
// resolves by id. Non-null here, since `chosen` came from
// `setup`'s own provider list.
setup = setup.id,
// `machine`'s own provider list.
machine = machine.id,
provider = chosen.name,
title = title.trim(),
model =
@@ -15,6 +15,10 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@@ -30,7 +34,13 @@ import java.time.OffsetDateTime
* own, so the only thing its Back could ever have meant was "put this away".
*/
@Composable
fun UsageDialog(feed: UsageFeed, session: SessionSummary, onDismiss: () -> Unit) {
fun UsageDialog(
settings: ServerSettings,
feed: UsageFeed,
session: SessionSummary,
onDismiss: () -> Unit,
) {
var signingIn by remember { mutableStateOf(false) }
// A plain Dialog rather than an AlertDialog, for the spacing alone. AlertDialog fixes the gaps
// between its title, content and buttons at sizes meant for a sentence of prose and a decision;
// this is a dense read-out, and those gaps left a band of empty dialog above Close that was
@@ -73,12 +83,12 @@ fun UsageDialog(feed: UsageFeed, session: SessionSummary, onDismiss: () -> Unit)
LoadState.Loaded(
usageSnapshotsFor(
snapshots.value,
session.setup,
session.machine,
session.usageProvider,
)
)
}
UsageBody(state)
UsageBody(state, onSignIn = { signingIn = true })
}
TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) {
Text("Close")
@@ -86,11 +96,24 @@ fun UsageDialog(feed: UsageFeed, session: SessionSummary, onDismiss: () -> Unit)
}
}
}
if (signingIn) {
ProviderLoginDialog(
settings = settings,
machineId = session.machine,
machineName = session.machineName,
provider = session.provider,
onDismiss = { signingIn = false },
onSignedIn = {
signingIn = false
feed.refresh()
},
)
}
}
/** What came back, or why nothing did. Split out so the dialog above reads as its own shape. */
@Composable
private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
private fun UsageBody(state: LoadState<List<UsageSnapshot>>, onSignIn: () -> Unit) {
Column {
when (val current = state) {
is LoadState.Loading -> CircularProgressIndicator()
@@ -122,7 +145,7 @@ private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
SnapshotState(snapshot)
SnapshotState(snapshot, onSignIn)
snapshot.windows.forEachIndexed { windowIndex, window ->
// Between the bars, not after the last one: a trailing gap here is what
// put a band of empty dialog above the Close button.
@@ -138,7 +161,7 @@ private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
}
private fun usageSectionTitle(snapshot: UsageSnapshot): String {
val machine = snapshot.setupName.ifEmpty { snapshot.setup }
val machine = snapshot.machineName.ifEmpty { snapshot.machine }
val provider = snapshot.provider
val pool =
if (provider == "codex" && snapshot.limitId != "codex") {
@@ -156,15 +179,25 @@ private fun usageSectionTitle(snapshot: UsageSnapshot): String {
*
* The distinction the old single message could not draw. A machine nobody has logged in on is
* working exactly as somebody set it up, so it reads as a plain statement -- marking it would be
* the interface nagging about a decision already made. Only the two faults are coloured as faults.
* the interface nagging about a decision already made. It still offers the direct sign-in action;
* unreachable and provider failures are the states coloured as faults.
*/
@Composable
private fun SnapshotState(snapshot: UsageSnapshot) {
private fun SnapshotState(snapshot: UsageSnapshot, onSignIn: () -> Unit) {
when (snapshot.state) {
"ok" -> {}
"notLoggedIn" ->
"notLoggedIn",
"loginRequired" -> {
Text(
"No Claude account on this machine.",
snapshot.detail ?: "No Claude account on this machine.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(onClick = onSignIn) { Text("Sign in") }
}
"authenticating" ->
Text(
"Claude sign-in is in progress.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)