Rename setups and add provider reauthentication
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
|
||||
+68
-38
@@ -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,
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ class SessionUsageTest {
|
||||
listOf(codex, reserve),
|
||||
usageSnapshotsFor(
|
||||
listOf(claude, codex, reserve, elsewhere),
|
||||
setup = "machine",
|
||||
machine = "machine",
|
||||
provider = "codex",
|
||||
),
|
||||
)
|
||||
@@ -27,7 +27,7 @@ class SessionUsageTest {
|
||||
emptyList(),
|
||||
usageSnapshotsFor(
|
||||
listOf(snapshot("machine", "claude", null)),
|
||||
setup = "machine",
|
||||
machine = "machine",
|
||||
provider = null,
|
||||
),
|
||||
)
|
||||
@@ -62,15 +62,15 @@ class SessionUsageTest {
|
||||
}
|
||||
|
||||
private fun snapshot(
|
||||
setup: String,
|
||||
machine: String,
|
||||
provider: String,
|
||||
limitId: String?,
|
||||
limitName: String? = null,
|
||||
) =
|
||||
UsageSnapshot(
|
||||
provider = provider,
|
||||
setup = setup,
|
||||
setupName = setup,
|
||||
machine = machine,
|
||||
machineName = machine,
|
||||
limitId = limitId,
|
||||
limitName = limitName,
|
||||
state = "ok",
|
||||
|
||||
Reference in new issue
Block a user