The settings dialog had outgrown a dialog: it scrolled inside itself, covered the session it is about, and had nowhere to put a second tab. It is a screen now, drawn over the session like the file explorer so the session under it stays composed, with the back gesture to leave it. The second tab is ProviderScreen itself -- the same composable the machines tab opens -- so a provider's settings have two ways in and one implementation. Every text field in the app goes through LabelledField: the label is a line above the box rather than a thing floating inside it, the hint says what leaving it blank means, and the padding is one line's worth. Material's outlined field spends the height of three lines to hold one, which on a form of a dozen settings is a screen and a half of scrolling. The value's own text is unchanged -- the framing was what cost. A session also gets a system prompt, which for llama.cpp is one entry in the params table and no app change: it rides in front of the conversation on every request rather than being recorded as the first thing in it, so changing it takes effect on the next message. ParamKind::Prose is new because a paragraph in a one-line box shows six words of itself.
212 lines
8.6 KiB
Kotlin
212 lines
8.6 KiB
Kotlin
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.height
|
|
import androidx.compose.material3.AlertDialog
|
|
import androidx.compose.material3.CircularProgressIndicator
|
|
import androidx.compose.material3.MaterialTheme
|
|
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")
|
|
}
|
|
LabelledField(
|
|
label = "Authorization code",
|
|
value = code,
|
|
onValueChange = { code = it },
|
|
)
|
|
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") }
|
|
}
|
|
},
|
|
)
|
|
}
|