Resuming a Claude Code session from a different working directory makes the CLI write a second transcript with the same id under that directory's project folder. This machine has one: 160 KB under `-home-bob-repos-tdep-survey` and a 614-byte stub under `-home-bob-repos-tdep`. Everything downstream addresses a session by id -- `--resume` takes it, the delete glob resolves it, the in-flight registry is keyed on it -- so two rows sharing an id are two rows no operation can tell apart, and the phone keys its list on it, so scrolling to them closed the app on Compose's duplicate-key throw. The listing now keeps the copy with the most in it. Size rather than recency, because the stub is often the newer of the two, and picking it describes the session by the wrong size, the wrong cwd and the wrong title. Deleting removes every copy rather than stopping at the first, which had left the row to come back on the next listing after a delete that reported success. The phone's half is `uniqueItems`: every list keyed on a server-chosen id goes through it, since none of them could rule the repeat out locally and a data problem must not be able to close the app. Verified both ways against the real duplicate -- the unguarded build reproduces the reported stack on the same id, the guarded one scrolls the whole list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
395 lines
16 KiB
Kotlin
395 lines
16 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.fillMaxSize
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.lazy.LazyColumn
|
|
import androidx.compose.material3.AlertDialog
|
|
import androidx.compose.material3.Card
|
|
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.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.unit.dp
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.withContext
|
|
|
|
/**
|
|
* The machines this backend can run things on.
|
|
*
|
|
* Note what this screen cannot do: name a program. Providers are what the server found when it
|
|
* asked the machine, so adding one is "here is how to reach it" and never "here is what to run" --
|
|
* which is what keeps the enrolled token from being able to introduce commands.
|
|
*/
|
|
@Composable
|
|
fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
|
val scope = rememberCoroutineScope()
|
|
var state by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
|
|
var adding by remember { mutableStateOf(false) }
|
|
var renaming by remember { mutableStateOf<Setup?>(null) }
|
|
var confirmingDelete by remember { mutableStateOf<Setup?>(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)) }
|
|
} catch (e: ApiException) {
|
|
LoadState.failed(e)
|
|
}
|
|
}
|
|
|
|
LaunchedEffect(reloadToken) { reload() }
|
|
|
|
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
|
// The heading and Back are the tab row's now; adding a machine is this tab's own work
|
|
// and stays with the list it adds to.
|
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
|
TextButton(onClick = { adding = true }) { Text("Add machine") }
|
|
}
|
|
Spacer(Modifier.height(8.dp))
|
|
|
|
actionError?.let {
|
|
Text(it, color = MaterialTheme.colorScheme.error)
|
|
Spacer(Modifier.height(8.dp))
|
|
}
|
|
busy?.let {
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
CircularProgressIndicator(Modifier.height(16.dp).padding(end = 8.dp))
|
|
Text(it, style = MaterialTheme.typography.bodySmall)
|
|
}
|
|
Spacer(Modifier.height(8.dp))
|
|
}
|
|
|
|
when (val current = state) {
|
|
is LoadState.Loading -> CircularProgressIndicator()
|
|
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 },
|
|
onRediscover = {
|
|
scope.launch {
|
|
busy = "Asking ${setup.name} what it has…"
|
|
actionError =
|
|
runCatching {
|
|
withContext(Dispatchers.IO) {
|
|
updateSetup(
|
|
settings,
|
|
setup.id,
|
|
rediscover = true,
|
|
)
|
|
}
|
|
}
|
|
.exceptionOrNull()
|
|
?.message
|
|
busy = null
|
|
reload()
|
|
}
|
|
},
|
|
onDelete = { confirmingDelete = setup },
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (adding) {
|
|
AddSetupDialog(
|
|
onDismiss = { adding = false },
|
|
onAdd = { name, ssh ->
|
|
adding = false
|
|
scope.launch {
|
|
busy = "Asking $name what it has…"
|
|
actionError =
|
|
runCatching {
|
|
withContext(Dispatchers.IO) { addSetup(settings, name, ssh) }
|
|
}
|
|
.exceptionOrNull()
|
|
?.message
|
|
busy = null
|
|
reload()
|
|
}
|
|
},
|
|
onTest = { ssh -> withContext(Dispatchers.IO) { probeSetup(settings, ssh) } },
|
|
)
|
|
}
|
|
|
|
renaming?.let { setup ->
|
|
RenameDialog(
|
|
setup = setup,
|
|
onDismiss = { renaming = null },
|
|
onRename = { name ->
|
|
renaming = null
|
|
scope.launch {
|
|
actionError =
|
|
runCatching {
|
|
withContext(Dispatchers.IO) {
|
|
updateSetup(settings, setup.id, name = name)
|
|
}
|
|
}
|
|
.exceptionOrNull()
|
|
?.message
|
|
reload()
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
confirmingDelete?.let { setup ->
|
|
AlertDialog(
|
|
onDismissRequest = { confirmingDelete = null },
|
|
title = { Text("Remove \"${setup.name}\"?") },
|
|
text = {
|
|
Text(
|
|
"The machine is left alone -- this only stops this app offering it. " +
|
|
"Sessions still running on it must be deleted first."
|
|
)
|
|
},
|
|
confirmButton = {
|
|
TextButton(
|
|
onClick = {
|
|
confirmingDelete = null
|
|
scope.launch {
|
|
actionError =
|
|
runCatching {
|
|
withContext(Dispatchers.IO) { deleteSetup(settings, setup.id) }
|
|
}
|
|
.exceptionOrNull()
|
|
?.message
|
|
reload()
|
|
}
|
|
}
|
|
) {
|
|
Text("Remove")
|
|
}
|
|
},
|
|
dismissButton = {
|
|
TextButton(onClick = { confirmingDelete = null }) { Text("Cancel") }
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun SetupCard(
|
|
setup: Setup,
|
|
onRename: () -> Unit,
|
|
onRediscover: () -> Unit,
|
|
onDelete: () -> Unit,
|
|
) {
|
|
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
|
Column(Modifier.padding(12.dp)) {
|
|
Text(setup.name, style = MaterialTheme.typography.titleSmall)
|
|
Text(
|
|
// Not "this machine": the seeded setup is *called* that,
|
|
// and the card read "this machine / this machine". The
|
|
// line has to say something the name cannot also be.
|
|
setup.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,
|
|
)
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
TextButton(onClick = onRename) { Text("Rename") }
|
|
TextButton(onClick = onRediscover) { Text("Rediscover") }
|
|
Spacer(Modifier.weight(1f))
|
|
TextButton(onClick = onDelete) { Text("Remove") }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun AddSetupDialog(
|
|
onDismiss: () -> Unit,
|
|
onAdd: (String, SshDetails?) -> Unit,
|
|
onTest: suspend (SshDetails?) -> List<Provider>,
|
|
) {
|
|
val scope = rememberCoroutineScope()
|
|
var name by remember { mutableStateOf("") }
|
|
var address by remember { mutableStateOf("") }
|
|
var identity by remember { mutableStateOf("") }
|
|
var tested by remember { mutableStateOf<String?>(null) }
|
|
var testing by remember { mutableStateOf(false) }
|
|
|
|
fun details(): SshDetails? =
|
|
address
|
|
.trim()
|
|
.takeIf { it.isNotEmpty() }
|
|
?.let { typed ->
|
|
val (host, typedPort) = splitHostAndPort(typed)
|
|
SshDetails(
|
|
address = host,
|
|
port = typedPort,
|
|
identityFile = identity.trim().ifEmpty { null },
|
|
)
|
|
}
|
|
|
|
AlertDialog(
|
|
onDismissRequest = onDismiss,
|
|
title = { Text("Add a machine") },
|
|
text = {
|
|
Column {
|
|
Text(
|
|
"Leave the address blank for the machine the backend runs on. " +
|
|
"What it can run is discovered, not typed.",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
Spacer(Modifier.height(8.dp))
|
|
OutlinedTextField(
|
|
value = name,
|
|
onValueChange = { name = it },
|
|
label = { Text("Name") },
|
|
singleLine = true,
|
|
)
|
|
OutlinedTextField(
|
|
value = address,
|
|
onValueChange = { address = it },
|
|
// Just the shape. What a blank one means is said once, in the text above
|
|
// this form -- repeating it here wrapped the label onto a second line and
|
|
// made this field taller than the two beside it for no information.
|
|
label = { Text("user@host[:port]") },
|
|
singleLine = true,
|
|
)
|
|
OutlinedTextField(
|
|
value = identity,
|
|
onValueChange = { identity = it },
|
|
label = { Text("Key path on the backend") },
|
|
singleLine = true,
|
|
)
|
|
tested?.let {
|
|
Spacer(Modifier.height(8.dp))
|
|
Text(it, style = MaterialTheme.typography.bodySmall)
|
|
}
|
|
}
|
|
},
|
|
confirmButton = {
|
|
TextButton(enabled = name.isNotBlank(), onClick = { onAdd(name.trim(), details()) }) {
|
|
Text("Add")
|
|
}
|
|
},
|
|
dismissButton = {
|
|
Row {
|
|
// Tried before saving, so a wrong address or an
|
|
// unauthorised key is caught while this form is still on
|
|
// screen rather than at the first spawn.
|
|
TextButton(
|
|
enabled = !testing,
|
|
onClick = {
|
|
testing = true
|
|
tested = "Asking…"
|
|
scope.launch {
|
|
tested =
|
|
runCatching { onTest(details()) }
|
|
.fold(
|
|
onSuccess = { found ->
|
|
if (found.isEmpty()) {
|
|
"Reached it, but found nothing it can run."
|
|
} else {
|
|
"Found ${found.joinToString(", ") { it.name }}"
|
|
}
|
|
},
|
|
onFailure = { it.message ?: "Couldn't reach it" },
|
|
)
|
|
testing = false
|
|
}
|
|
},
|
|
) {
|
|
Text("Test")
|
|
}
|
|
TextButton(onClick = onDismiss) { Text("Cancel") }
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
private fun RenameDialog(setup: Setup, onDismiss: () -> Unit, onRename: (String) -> Unit) {
|
|
var name by remember { mutableStateOf(setup.name) }
|
|
AlertDialog(
|
|
onDismissRequest = onDismiss,
|
|
title = { Text("Rename") },
|
|
text = {
|
|
Column {
|
|
OutlinedTextField(
|
|
value = name,
|
|
onValueChange = { name = it },
|
|
label = { Text("Name") },
|
|
singleLine = true,
|
|
)
|
|
Spacer(Modifier.height(8.dp))
|
|
Text(
|
|
"Sessions already running on it keep working -- they refer to the machine, " +
|
|
"not to what it is called.",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
}
|
|
},
|
|
confirmButton = {
|
|
TextButton(enabled = name.isNotBlank(), onClick = { onRename(name.trim()) }) {
|
|
Text("Rename")
|
|
}
|
|
},
|
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Splits `user@host:port` into its two halves, with the port left null when none was typed.
|
|
*
|
|
* One field rather than two because that is how an address is written and read everywhere else --
|
|
* and because a port that is almost always 22 does not deserve a box of its own on a phone
|
|
* keyboard. Null rather than 22: the backend already decides the default, and writing 22 here would
|
|
* put a second answer to that question in a second place.
|
|
*
|
|
* A colon only means "port" when it can. A bracketed IPv6 literal is unwrapped as ssh writes it,
|
|
* `[::1]:22`; a bare `::1` keeps every colon, because an address with several is an address, not an
|
|
* address and a port. So the rule is: brackets, or exactly one colon followed by digits.
|
|
*/
|
|
private fun splitHostAndPort(typed: String): Pair<String, Int?> {
|
|
if (typed.startsWith("[")) {
|
|
val close = typed.indexOf(']')
|
|
if (close > 0) {
|
|
val host = typed.substring(1, close)
|
|
val rest = typed.substring(close + 1)
|
|
val port = rest.removePrefix(":").toIntOrNull().takeIf { rest.startsWith(":") }
|
|
return host to port
|
|
}
|
|
}
|
|
if (typed.count { it == ':' } == 1) {
|
|
val host = typed.substringBeforeLast(':')
|
|
val port = typed.substringAfterLast(':').toIntOrNull()
|
|
if (port != null && host.isNotEmpty()) return host to port
|
|
}
|
|
return typed to null
|
|
}
|