diff --git a/AGENTS.md b/AGENTS.md index 8840c1c..56a3405 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -203,6 +203,33 @@ first if a remote spawn ever mangles an argument. and lint, the app-side equivalent of the line above. Then `./build-apk.sh` to produce the APK to install on a phone (through Dev Updater), or `./run-android.sh` to build, install, and launch on the emulator. +- **A row something is happening to is dimmed, drained of colour, inert, + and says which operation in a word** -- `BusyItem`, used by both the + session list and the import list so the appearance is learned once. The + word rather than a bare spinner because "deleting" and "importing" differ + in kind, and the inertness is the overlay consuming pointer events rather + than each caller remembering to disable its own click handler. +- **The import screen selects in batches: hold to enter, tap to add.** The + options that act on a selection appear along the bottom, and are Delete + and Import only. Submitting clears the selection immediately and marks + every chosen row -- the one in flight as "importing" or "deleting", the + rest as "waiting" -- so the bar goes away and the affected set is what + says the work is happening. Rows are taken out as each one lands rather + than all at the end: a finished row still sitting there looks exactly + like one that has not been imported, and tapping it starts a second CLI + on the same transcript. What that costs is that the rows below slide up + under the reader's finger, so a row that has just moved ignores taps for + half a second (`SETTLE_MS`). +- **Deleting a session offers to take the machine's own transcript with + it.** `DELETE /sessions/{id}?deleteForeign=true`, behind a switch in the + confirmation, and only where the driver keeps a record of its own + (`keepsOwnTranscript`, which today means Claude Code). Off by default, + because leaving that copy is what makes an ordinary delete recoverable -- + and the dialog's paragraph is rewritten when it is on rather than + appended to, since the sentence promising the conversation "should still + be there to import again" is exactly the one the switch makes false. The + server deletes the machine's copy *first*, so a machine it cannot reach + leaves the session where it was instead of half-deleted. - **Android Lint is not optional and is not run by a build.** It found a crash that had been shipping: `java.time` on a minSdk-24 app with desugaring off — and later a permission check that silently dropped every @@ -234,6 +261,17 @@ first if a remote spawn ever mangles an argument. conversation somebody may still be in. **A transcript never goes in this repository**: they hold whatever was said, read and written in that session, and `~/repos` is shared with the host besides. +- **`app/ui-sandbox.sh` is the rig for anything that lists or deletes + sessions.** It starts a second `ai-server` with its own `$HOME`, config + and data directory, holding eight invented Claude Code transcripts and a + `claude` that is two lines of shell. That isolation is the point: the + import screen lists whatever is in `~/.claude/projects`, which in this VM + is real agent transcripts, so exercising *delete* against the ordinary + server deletes somebody's conversation and exercising *import* starts a + real `--resume` on Bryan's account. Neither is a price worth paying to + look at a list. It shares the real TLS certificates, because the + installed APK pins that CA, so run it while the ordinary server is down. + It passes `--delay` by default for the reason the next entry gives. - **`ai-server --delay MS` holds every response back.** Over the tunnel a phone's requests take tens to hundreds of milliseconds, and several faults live entirely in what the app does *while* one is outstanding. On diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt index b83f787..49828e1 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -694,8 +694,17 @@ fun compactSession(settings: ServerSettings, sessionId: String) { requestFromServer(settings, "/sessions/$sessionId/compact", method = "POST") {} } -fun deleteSession(settings: ServerSettings, sessionId: String) { - requestFromServer(settings, "/sessions/$sessionId", method = "DELETE") {} +/** + * Removes a session, and optionally the machine's own transcript of the same conversation. + * + * [deleteForeign] is the delete this app cannot otherwise reach: Claude Code keeps its own record + * under `~/.claude/projects`, and leaving it is what makes an ordinary delete recoverable. The + * server does both halves, and does the unrecoverable one first, so a machine it cannot reach + * leaves the session exactly where it was rather than half-deleted. + */ +fun deleteSession(settings: ServerSettings, sessionId: String, deleteForeign: Boolean = false) { + val query = if (deleteForeign) "?deleteForeign=true" else "" + requestFromServer(settings, "/sessions/$sessionId$query", method = "DELETE") {} } // Models: what this backend has downloaded, what it is downloading, and diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/BusyItem.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/BusyItem.kt new file mode 100644 index 0000000..01f9d36 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/BusyItem.kt @@ -0,0 +1,113 @@ +package com.example.aiapp + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp + +/** + * An item something is happening to: dimmed, drained of colour, inert, with a spinner and the name + * of the operation over it. + * + * One composable rather than a pattern each list repeats, because "this row is busy" has to look + * the same in the import list and the session list or the appearance becomes a per-screen dialect + * rather than something the reader learns once. + * + * [label] names the operation and `null` means none is running. One parameter rather than a boolean + * beside a string, which can disagree: there is no such thing as busy with nothing happening. It is + * a *word* because a spinner alone cannot say which operation this is — deleting and importing are + * different in kind, and losing a session to the wrong one is not recoverable by waiting. + * + * Inert by consuming pointer events above the content rather than by asking every caller to disable + * its own click handler: the row is covered, so there is nothing left to remember. + */ +@Composable +fun BusyItem(label: String?, content: @Composable () -> Unit) { + Box { + Box(Modifier.busy(label != null)) { content() } + if (label != null) { + Box( + Modifier.matchParentSize().pointerInput(Unit) { + // Consumed on the initial pass, so nothing underneath sees the gesture at + // all -- a press that produced a ripple on a row that cannot be pressed + // would say the opposite of everything else here. + awaitPointerEventScope { + while (true) { + awaitPointerEvent(PointerEventPass.Initial).changes.forEach { + it.consume() + } + } + } + }, + contentAlignment = Alignment.Center, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator( + modifier = Modifier.width(16.dp).height(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.width(8.dp)) + // Full strength, over content that is not: the operation is the one thing on + // this row that is still current, and it has to read against a card whose own + // text is still visible behind it. + Text( + label, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + } +} + +/** + * How an item looks while it is being acted on: darker, and nearly grey. + * + * Both, rather than either alone. Dimming by itself is what this app already used for a row on its + * way out, and it is the same cue as a disabled control, so a busy row read as one more thing that + * could not be tapped. Draining the colour is what says the row is *suspended* — the status word, + * the accent on a warning and everything else that means something by its colour stop meaning it + * for as long as the operation runs, which is exactly true: none of them is being kept up to date. + * + * Not all the way to grey. A row with no colour left is hard to find again in a list, and the + * reader is watching this one. + */ +private fun Modifier.busy(busy: Boolean): Modifier = + if (!busy) this + else + this.graphicsLayer { alpha = 0.5f } + .drawWithContent { + drawIntoCanvas { canvas -> + canvas.saveLayer( + Rect(Offset.Zero, size), + Paint().apply { + colorFilter = + ColorFilter.colorMatrix( + ColorMatrix().apply { setToSaturation(0.2f) } + ) + }, + ) + drawContent() + canvas.restore() + } + } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt index b310f1e..eb789ee 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt @@ -1,7 +1,11 @@ package com.example.aiapp -import androidx.compose.foundation.clickable +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -13,8 +17,10 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -26,38 +32,93 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +/** What a row says about itself while an operation is running on it. See [BusyItem]. */ +private const val IMPORTING = "importing" +private const val DELETING = "deleting" + +/** + * What the rows further down a batch say while they wait their turn. + * + * Its own word rather than the operation's, because it is its own state and the difference is the + * kind that matters: nothing has been done to this session yet, so a batch stopped here leaves it + * exactly as it was. Marked from the moment the batch is handed over all the same -- a queued row + * that still looked ordinary was still tappable, and tapping it would import it a second time + * behind the batch already coming for it. + */ +private const val WAITING = "waiting" + +/** + * How long a row that has just moved ignores being touched. + * + * A batch takes rows out of the list as each one lands, so everything below the one that went + * slides up -- and a tap already on its way then arrives at whichever row moved into that place. On + * this screen that means importing a session nobody chose, which is not something a second tap can + * undo. + * + * Swallowed silently rather than shown, because anything drawn on every row a batch passes would be + * a flicker running down the list. Half a second: long enough to cover a tap already travelling + * when the row moved, short enough that it is not in the way of a deliberate one. + */ +private const val SETTLE_MS = 500L + /** * Continuing a Claude Code session the machine already has. * * The list is the machine's answer, not this app's: it asks a setup what sessions it holds and * shows them. Choosing one sends its **id**, never a path, so an enrolled phone cannot turn this * screen into a file reader. + * + * Holding a row selects it and puts the screen in selection mode, where the options that act on a + * selection appear along the bottom. That exists because these arrive in bulk — a machine + * accumulates dozens of abandoned sessions — and one confirmation dialog per row is the reason + * clearing them out was not worth doing. */ +@OptIn(ExperimentalFoundationApi::class) @Composable fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (SessionSummary) -> Unit) { val scope = rememberCoroutineScope() var setups by remember { mutableStateOf>>(LoadState.Loading) } var chosen by remember { mutableStateOf(null) } var sessions by remember { mutableStateOf>>(LoadState.Loading) } - // Which row is being imported. Spawning resumes a CLI, which is not instant, and a tap with - // no acknowledgement invites a second tap and a second session. - var importing by remember { mutableStateOf(null) } - var failure by remember { mutableStateOf(null) } - // Deleting a transcript cannot be undone, so it is asked rather than done. Held as the row - // itself, not a flag, so the dialog can say which session it is about. - var confirming by remember { mutableStateOf(null) } + + // What is happening to each row right now, as the word the row shows: "importing" or + // "deleting". A map keyed by id rather than a flag per row, because the rows are rebuilt from + // whatever the server last said and this belongs to the request rather than to the session -- + // the same arrangement the session list uses for its deletes. + var running by remember { mutableStateOf>(emptyMap()) } + // Which rows the reader has picked out. Empty means selection mode is off: there is no + // separate flag, because a selection mode with nothing selected is a state with no controls + // in it and no way to leave except Back. + var selected by remember { mutableStateOf>(emptySet()) } + // Failures that belong to one row rather than to the screen, shown on that row. A batch is + // exactly where a single banner fails: nine deletes succeeded and one did not, and the + // banner cannot say which. + var rowErrors by remember { mutableStateOf>(emptyMap()) } + // Deleting a transcript cannot be undone, so it is asked rather than done. Held as the rows + // themselves, not a flag, so the dialog can say what it is about. + var confirming by remember { mutableStateOf?>(null) } // Same default as the spawn screen, and for the same reason: a phone // is the wrong place to answer "allow Bash?" forty times. var permissionMode by remember { mutableStateOf("auto") } + // When each row last slid upwards, as a plain map rather than state: nothing is drawn from + // it, so a tap reading it needs no recomposition and there is no timer to cancel when a + // second removal lands on top of the first. + val movedAt = remember { mutableMapOf() } + fun settling(id: String) = System.currentTimeMillis() - (movedAt[id] ?: 0L) < SETTLE_MS fun loadSessions(setup: Setup) { sessions = LoadState.Loading + selected = emptySet() + rowErrors = emptyMap() scope.launch { sessions = try { @@ -84,110 +145,208 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio } } - Column(Modifier.fillMaxSize().padding(16.dp)) { - // No heading: the tab that selected this one already says "Import". The sentence below - // stays, because it says what importing *does*, which the tab label cannot. - Text( - "Sessions Claude Code already has on the machine. Importing continues one where it " + - "left off; the transcript here shows its recent history.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(12.dp)) - - when (val loaded = setups) { - 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 -> - TextButton( - onClick = { - chosen = setup - loadSessions(setup) - } - ) { - Text( - setup.name, - color = - if (setup.id == chosen?.id) - MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } + /** + * Runs [operation] over [targets] one at a time, marking each row with [label] while its turn + * lasts and taking it off the list when it succeeds. + * + * One runner for both operations and for both the single tap and the batch, so "what a row + * looks like while something is happening to it" and "what happens when one of ten fails" are + * decided once. Sequentially, because each import starts a CLI on the machine and ten at once + * is a load nobody asked for; the reader sees the work walk down the list, which is also the + * only honest progress this screen can show. + * + * The selection is dropped the moment the work is handed over, not when it finishes: the screen + * goes back to how it started, and what says the work is happening is the rows it is happening + * to. Holding the selection until the end left the bar up over rows that could no longer be + * pressed, offering to start again something already running. + * + * A failure keeps its row and puts the server's words on it. Selecting those rows again is then + * the reader's decision rather than a state the screen carried for them — and it is the + * decision worth making deliberately, because retrying a delete that the server refused is + * usually not what somebody wants to do by pressing the same button twice. + */ + fun runOn(targets: List, label: String, operation: suspend (Importable) -> Unit) { + selected = emptySet() + running = running + targets.associate { it.id to WAITING } + scope.launch { + for (target in targets) { + running = running + (target.id to label) + rowErrors = rowErrors - target.id + try { + operation(target) + val loaded = sessions + if (loaded is LoadState.Loaded) { + // As each one lands, not all of them at the end. Holding the finished + // rows in place to keep the list still was tried and is worse: a row + // that has been imported but is still sitting there looks exactly like + // one that has not, and tapping it starts a second CLI on the same + // transcript. A row that is gone cannot be tapped at all. + // + // Only this row, and only what changed -- refetching instead put every + // other row back through a loading spinner to report a change that was + // never in doubt. + val now = System.currentTimeMillis() + loaded.value + .asSequence() + .dropWhile { it.id != target.id } + .drop(1) + .forEach { movedAt[it.id] = now } + sessions = LoadState.Loaded(loaded.value.filterNot { it.id == target.id }) } - } - val provider = chosen?.providers?.firstOrNull { it.kind == "claude_cli" } - if (chosen != null && provider == null) { - Text( - "${chosen?.name} has no Claude CLI, so there is nothing here to continue.", - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else { - ChipGroup( - label = "Permissions", - options = PERMISSION_MODES, - selected = permissionMode, - onSelect = { permissionMode = it }, - ) - Spacer(Modifier.height(8.dp)) - failure?.let { - Text(it, color = MaterialTheme.colorScheme.error) - Spacer(Modifier.height(8.dp)) - } - ImportableList( - state = sessions, - importing = importing, - onDelete = { confirming = it }, - onPick = { session -> - val setup = chosen ?: return@ImportableList - val useProvider = provider ?: return@ImportableList - importing = session.id - failure = null - scope.launch { - try { - val spawned = - withContext(Dispatchers.IO) { - spawnSession( - settings, - setup = setup.id, - provider = useProvider.name, - // Nothing to say: the - // server titles it from - // the session it is - // continuing. - title = "", - permissionMode = permissionMode, - import = session.id, - ) - } - onImported(spawned) - } catch (err: Exception) { - failure = err.message ?: "Couldn't import that session" - } finally { - importing = null - } - } - }, - ) + } catch (err: Exception) { + rowErrors = rowErrors + (target.id to (err.message ?: "Didn't work")) + } finally { + running = running - target.id } } } } - confirming?.let { session -> + val provider = chosen?.providers?.firstOrNull { it.kind == "claude_cli" } + + /** + * Imports [targets], and goes to the session it made when [thenOpen]. + * + * One function for the tap and for the bar, differing in that one flag: continuing a session + * and then looking at it is what a tap on a row means, and a batch has several results and no + * reason to pick one of them to become the screen. + */ + fun importAll(targets: List, thenOpen: Boolean) { + val setup = chosen ?: return + val useProvider = provider ?: return + runOn(targets, IMPORTING) { session -> + val spawned = + withContext(Dispatchers.IO) { + spawnSession( + settings, + setup = setup.id, + provider = useProvider.name, + // Nothing to say: the server titles it from the session it is continuing. + title = "", + permissionMode = permissionMode, + import = session.id, + ) + } + if (thenOpen) onImported(spawned) + } + } + + // Back leaves selection mode rather than the tab, which is the level it is one step above. + // Nested inside MainScreen's own handler, so it wins while there is a selection. + BackHandler(enabled = selected.isNotEmpty()) { selected = emptySet() } + + // Measured rather than assumed: the list reserves exactly what the bar covers, so the last + // row can still be scrolled to while it is up, and nothing is nudged by a number that was + // right for one font size. + var barHeight by remember { mutableStateOf(0.dp) } + val density = LocalDensity.current + + Box(Modifier.fillMaxSize()) { + Column(Modifier.fillMaxSize().padding(16.dp)) { + // No heading: the tab that selected this one already says "Import". The sentence below + // stays, because it says what importing *does*, which the tab label cannot. + Text( + "Sessions Claude Code already has on the machine. Importing continues one where " + + "it left off; the transcript here shows its recent history. Hold one to " + + "select it, and several at a time.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + + when (val loaded = setups) { + 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 -> + TextButton( + onClick = { + chosen = setup + loadSessions(setup) + } + ) { + Text( + setup.name, + color = + if (setup.id == chosen?.id) + MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + if (chosen != null && provider == null) { + Text( + "${chosen?.name} has no Claude CLI, so there is nothing here to " + + "continue.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + ChipGroup( + label = "Permissions", + options = PERMISSION_MODES, + selected = permissionMode, + onSelect = { permissionMode = it }, + ) + Spacer(Modifier.height(8.dp)) + ImportableList( + state = sessions, + running = running, + settling = ::settling, + selected = selected, + errors = rowErrors, + bottomInset = barHeight, + onToggle = { session -> + selected = + if (session.id in selected) selected - session.id + else selected + session.id + }, + onOpen = { session -> importAll(listOf(session), thenOpen = true) }, + ) + } + } + } + } + + // Beside nothing in particular, because a selection is not one row: the options that act + // on it belong to the screen, and the bottom is where a thumb already is. + if (selected.isNotEmpty()) { + val picked = + (sessions as? LoadState.Loaded)?.value?.filter { it.id in selected }.orEmpty() + SelectionBar( + count = picked.size, + modifier = + Modifier.align(Alignment.BottomCenter).onSizeChanged { + barHeight = with(density) { it.height.toDp() } + }, + onDelete = { confirming = picked }, + onImport = { importAll(picked, thenOpen = false) }, + ) + } + } + + confirming?.let { targets -> AlertDialog( onDismissRequest = { confirming = null }, - title = { Text("Delete this session?") }, + title = { + Text( + if (targets.size == 1) "Delete this session?" + else "Delete ${targets.size} sessions?" + ) + }, text = { Text( - "\"${session.title}\"\n\nClaude Code keeps no copy: its transcript is the " + - "session, so this ends any chance of resuming that conversation. " + - "Sessions already imported here keep the history they replayed, but " + - "cannot be continued." + // One name is worth showing and twelve are not, so the count stands in for + // them. The sentence after it is the same either way, because what deleting + // costs does not change with how many. + (if (targets.size == 1) "\"${targets.first().title}\"\n\n" else "") + + "Claude Code keeps no copy: its transcript is the session, so this ends " + + "any chance of resuming that conversation. Sessions already imported " + + "here keep the history they replayed, but cannot be continued." ) }, confirmButton = { @@ -195,24 +354,9 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio onClick = { val setup = chosen ?: return@TextButton confirming = null - scope.launch { - try { - withContext(Dispatchers.IO) { - deleteImportable(settings, setup.id, session.id) - } - // Only this row, and only what changed -- the same rule as the - // session list's delete. Refetching instead put every other row - // back through a loading spinner to report a change that was - // never in doubt. - val loaded = sessions - if (loaded is LoadState.Loaded) { - sessions = - LoadState.Loaded( - loaded.value.filterNot { it.id == session.id } - ) - } - } catch (err: Exception) { - failure = err.message ?: "Couldn't delete that session" + runOn(targets, DELETING) { session -> + withContext(Dispatchers.IO) { + deleteImportable(settings, setup.id, session.id) } } } @@ -226,12 +370,58 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio } } +/** + * What can be done to the rows that are selected. + * + * Delete and Import only, for now: they are the two things this screen has ever done to a session, + * and an option that appears here has to work on every row in a selection rather than on the one + * somebody was thinking of. + */ +@Composable +private fun SelectionBar( + count: Int, + modifier: Modifier = Modifier, + onDelete: () -> Unit, + onImport: () -> Unit, +) { + Surface( + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 3.dp, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Text( + "$count selected", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onDelete) { + Text("Delete", color = MaterialTheme.colorScheme.error) + } + Spacer(Modifier.width(4.dp)) + TextButton(onClick = onImport) { Text("Import") } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) @Composable private fun ImportableList( state: LoadState>, - importing: String?, - onDelete: (Importable) -> Unit, - onPick: (Importable) -> Unit, + /** Rows an operation is running on, as the word each one shows. */ + running: Map, + /** Whether this row has just moved and should ignore being touched -- see [SETTLE_MS]. */ + settling: (String) -> Boolean, + selected: Set, + errors: Map, + /** What the selection bar covers, so the last row can still be reached under it. */ + bottomInset: Dp, + onToggle: (Importable) -> Unit, + onOpen: (Importable) -> Unit, ) { when (state) { is LoadState.Loading -> CircularProgressIndicator() @@ -243,87 +433,111 @@ private fun ImportableList( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { - LazyColumn(Modifier.fillMaxSize()) { - items(state.value) { session -> - Card( - Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable( - // Disabled rather than hidden: a row that vanished would make - // its own absence the signal, and the reader could not tell a - // session in use from one that is not there. See `detailOf` for - // what it says instead. - enabled = importing == null && session.inUse != "yes" + val selecting = selected.isNotEmpty() + LazyColumn( + Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = bottomInset), + ) { + items(state.value, key = { it.id }) { session -> + val picked = session.id in selected + BusyItem(label = running[session.id]) { + Card( + colors = + if (picked) + CardDefaults.cardColors( + containerColor = + MaterialTheme.colorScheme.secondaryContainer, + contentColor = + MaterialTheme.colorScheme.onSecondaryContainer, + ) + else CardDefaults.cardColors(), + modifier = + Modifier.fillMaxWidth() + .padding(vertical = 4.dp) + .combinedClickable( + onClick = { + if (settling(session.id)) return@combinedClickable + // In selection mode a tap is a selection, so the + // reader is never one mis-tap away from starting + // a CLI they were only picking rows for. + // + // Outside it, a tap continues the session -- + // except on a row that cannot be continued, + // where it selects instead. That row's only + // remaining action is Delete, and a tap that + // did nothing at all would be a worse answer + // than one that offers the thing it can do. + // Two `--resume` processes on one transcript + // each replay the other's writes, which is why + // this must not simply try. + if (selecting || session.inUse == "yes") + onToggle(session) + else onOpen(session) + }, + onLongClick = { + if (!settling(session.id)) onToggle(session) + }, + ), ) { - onPick(session) - } - ) { - Column(Modifier.padding(12.dp)) { - Row(verticalAlignment = Alignment.Top) { - Text( - session.title, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.weight(1f), - ) - Spacer(Modifier.width(8.dp)) - // Beside the title, because "which one was I just in" is the - // question this list answers and the order already reflects - // it -- the reader should be able to see the ordering they - // are being given rather than infer it. - Text( - relativeTime(session.modified), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Spacer(Modifier.height(4.dp)) - // Top, not centred: the text beside it is now several lines, and - // a control centred against it would sit halfway down the row - // rather than beside the line it belongs to. - Row(verticalAlignment = Alignment.Top) { - Column(Modifier.weight(1f)) { - // The path first, and the only thing here that is cut: - // it is one long value with no natural break, where the - // lines below it are short enough to wrap readably. - // Cut at the head, because a path is identified by its - // tail and these all share a long prefix. By the row's - // real width rather than a character count, which was - // one guess for every font size and screen. - session.cwd - .takeIf { it.isNotEmpty() } - ?.let { cwd -> - Text( - cwd, - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - overflow = TextOverflow.StartEllipsis, - color = - MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + Column(Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.Top) { Text( - statsOf(session, importing), + session.title, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(8.dp)) + // Beside the title, because "which one was I just in" is + // the question this list answers and the order already + // reflects it -- the reader should be able to see the + // ordering they are being given rather than infer it. + Text( + relativeTime(session.modified), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - // Its own line and its own colour, because it differs - // in kind from the stats above rather than in degree: - // those describe the session, this says whether taking - // it is safe at all. - warningOf(session)?.let { warning -> + } + Spacer(Modifier.height(4.dp)) + // The path first, and the only thing here that is cut: it is + // one long value with no natural break, where the lines below + // it are short enough to wrap readably. Cut at the head, + // because a path is identified by its tail and these all + // share a long prefix. By the row's real width rather than a + // character count, which was one guess for every font size + // and screen. + session.cwd + .takeIf { it.isNotEmpty() } + ?.let { cwd -> Text( - warning, + cwd, style = MaterialTheme.typography.bodySmall, - color = warningColor, + maxLines = 1, + overflow = TextOverflow.StartEllipsis, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - } - // Beside the row it acts on, not collected at the bottom of - // the screen where its scope would have to be guessed. - TextButton(onClick = { onDelete(session) }) { + Text( + statsOf(session), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + // Its own line and its own colour, because it differs in kind + // from the stats above rather than in degree: those describe + // the session, this says whether taking it is safe at all. + warningOf(session)?.let { warning -> Text( - "Delete", + warning, + style = MaterialTheme.typography.bodySmall, + color = warningColor, + ) + } + // Reported where it happened, in the server's own words, the + // way every other failure in this app is shown. + errors[session.id]?.let { message -> + Spacer(Modifier.height(4.dp)) + Text( + message, style = MaterialTheme.typography.bodySmall, - // Coloured by consequence: this takes something - // away, and does so wherever it appears. color = MaterialTheme.colorScheme.error, ) } @@ -346,9 +560,8 @@ private fun humanSize(bytes: Long): String? = } /** What this session is: the measurements, in the order they are worth knowing. */ -private fun statsOf(session: Importable, importing: String?): String = +private fun statsOf(session: Importable): String = listOfNotNull( - if (importing == session.id) "importing…" else null, // Said, because a name and a last message are different claims: one describes the // session, the other is only what happened last in it. if (session.named) "named" else null, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt index 4a230f3..775e1ad 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -29,7 +30,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.unit.dp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -141,6 +141,9 @@ fun SessionListScreen( } confirmingDelete?.let { session -> + // Reset per session, so a toggle turned on for one conversation is not still on for the + // next one somebody opens this dialog for. Off to begin with: see [deleteSession]. + var alsoDeleteForeign by remember(session.id) { mutableStateOf(false) } AlertDialog( onDismissRequest = { confirmingDelete = null }, title = { Text("Delete \"${session.title}\"?") }, @@ -163,16 +166,50 @@ fun SessionListScreen( // which nothing here checked; and it names what goes either way, because this // app's transcript holds images, peer messages and commands that the CLI's own // record never had. - Text( - if (session.keepsOwnTranscript) - "Stops the process and deletes this app's copy of the conversation, " + - "including any images, peer messages and commands recorded only " + - "here. Claude Code keeps its own transcript on the machine, so the " + - "conversation itself should still be there to import again." - else - "Kills the process and deletes the conversation. Nothing else keeps a " + - "copy, so this can't be undone." - ) + Column { + Text( + when { + !session.keepsOwnTranscript -> + "Kills the process and deletes the conversation. Nothing else " + + "keeps a copy, so this can't be undone." + // The sentence below is the one the toggle makes false, which is why + // it is written twice rather than appended to: leaving "should still + // be there to import again" on screen beside a switch that removes it + // is the reassurance being read at the moment it stops being true. + alsoDeleteForeign -> + "Kills the process and deletes both copies of the conversation: " + + "this app's, and Claude Code's own transcript on the " + + "machine. Nothing keeps another, so this can't be undone." + else -> + "Stops the process and deletes this app's copy of the " + + "conversation, including any images, peer messages and " + + "commands recorded only here. Claude Code keeps its own " + + "transcript on the machine, so the conversation itself " + + "should still be there to import again." + } + ) + // Only where there is a second copy to decide about. Absent rather than + // disabled, because this is not a capability being withheld: for echo and + // llama.cpp there is no other transcript, and a switch offering to delete + // one would be asking about something that does not exist. + if (session.keepsOwnTranscript) { + Spacer(Modifier.height(16.dp)) + // Its own row rather than beside the paragraph: a switch is taller than + // a line of text and re-centres whatever shares a row with it. + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "Delete Claude Code's transcript too", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(12.dp)) + Switch( + checked = alsoDeleteForeign, + onCheckedChange = { alsoDeleteForeign = it }, + ) + } + } + } }, confirmButton = { TextButton( @@ -185,7 +222,9 @@ fun SessionListScreen( deleteErrors = deleteErrors - session.id scope.launch { try { - withContext(Dispatchers.IO) { deleteSession(settings, session.id) } + withContext(Dispatchers.IO) { + deleteSession(settings, session.id, alsoDeleteForeign) + } // Only this row, and only what changed. Refetching the list // instead put every other session back through loading and // handed the reader an empty screen -- to report on something @@ -208,7 +247,9 @@ fun SessionListScreen( } } ) { - Text("Delete") + // Coloured by consequence: this takes something away, and does so wherever + // it appears -- the same rule the import screen's Delete follows. + Text("Delete", color = MaterialTheme.colorScheme.error) } }, dismissButton = { @@ -227,92 +268,68 @@ private fun SessionCard( /** * Whether this session is being deleted right now. * - * Faded and inert while it is, which says the row is on its way out without claiming it has - * gone: a row removed the moment Delete is pressed is a promise about a request that has not - * been answered yet, and putting it back when the server refuses is worse than never having - * taken it away. + * Suspended rather than removed while it is -- see [BusyItem] -- which says the row is on its + * way out without claiming it has gone: a row removed the moment Delete is pressed is a promise + * about a request that has not been answered yet, and putting it back when the server refuses + * is worse than never having taken it away. */ deleting: Boolean, onOpen: () -> Unit, onLongPress: () -> Unit, ) { - Card( - Modifier.fillMaxWidth() - .alpha(if (deleting) 0.45f else 1f) - // Not just faded: a card that still opens a session it is deleting is a race the - // reader can start by tapping. - .combinedClickable(enabled = !deleting, onClick = onOpen, onLongClick = onLongPress) - ) { - Column(Modifier.padding(16.dp)) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - session.title, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.weight(1f), - ) - // In the status's own place, because that is what it is: what this session is - // doing now, which is being deleted. - if (deleting) DeletingMark() else StatusText(session.status) - } - Spacer(Modifier.height(4.dp)) - Row(modifier = Modifier.fillMaxWidth()) { - Text( - // Machine, then what runs on it, then what it is set to: the same order - // and 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.provider, - session.model?.let { modelLabel(it) }, - ) - .joinToString(" · "), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f), - ) - Text( - relativeTime(session.lastActivity), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - error?.let { - Spacer(Modifier.height(8.dp)) - // The server's own words, unprefixed, the way every other - // failure in this app is shown. - Text( - it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) + BusyItem(label = if (deleting) "deleting" else null) { + Card( + Modifier.fillMaxWidth().combinedClickable(onClick = onOpen, onLongClick = onLongPress) + ) { + Column(Modifier.padding(16.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + session.title, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + StatusText(session.status) + } + Spacer(Modifier.height(4.dp)) + Row(modifier = Modifier.fillMaxWidth()) { + Text( + // Machine, then what runs on it, then what it is set to: the same order + // and 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.provider, + session.model?.let { modelLabel(it) }, + ) + .joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + Text( + relativeTime(session.lastActivity), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + error?.let { + Spacer(Modifier.height(8.dp)) + // The server's own words, unprefixed, the way every other + // failure in this app is shown. + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } } } } } -/** A session on its way out: the same shape as a status, because that is the slot it fills. */ -@Composable -private fun DeletingMark() { - Row(verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator( - modifier = Modifier.width(14.dp).height(14.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.width(6.dp)) - // Not in the error colour, though it is destructive: red here means something went - // wrong on its own, and this is going exactly as asked. - Text( - "deleting", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } -} - @Composable fun StatusText(status: String) { val (label, color) = diff --git a/app/ui-sandbox.sh b/app/ui-sandbox.sh new file mode 100755 index 0000000..c963aae --- /dev/null +++ b/app/ui-sandbox.sh @@ -0,0 +1,175 @@ +#!/bin/sh +# An ai-server with invented sessions in it, for driving the phone UI. +# +# The import screen lists whatever Claude Code has on the machine, and in +# this VM that is real agent transcripts -- so exercising *delete* against +# the ordinary server means deleting somebody's conversation, and exercising +# *import* means starting a real `claude --resume` on Bryan's account. Both +# are the wrong price for looking at a list. +# +# So this starts a second server that can see neither. `$HOME` is pointed at +# a sandbox directory, which is the only thing the importer's own script +# consults (`$HOME/.claude/projects/*/*.jsonl`), and the config and session +# data live there too. What it lists is invented here, and deleting all of +# it costs nothing. +# +# Three things are deliberately shared with the real server, because the +# installed APK is built against them: the TLS certificates (the app pins +# that CA and would refuse a fresh one) and the port. Run it while the real +# server is down. +# +# Usage: +# ./ui-sandbox.sh start it, print the enrolment command +# ./ui-sandbox.sh stop stop it +# +# Environment: AI_SANDBOX_ROOT, AI_SANDBOX_TOKEN, AI_SANDBOX_PORT, and +# AI_SANDBOX_DELAY -- the last being the server's own `--delay`, which is +# what makes a spinner visible at all. On loopback every request is back in +# under a millisecond, so a busy state that is correct is still a busy state +# nobody can see. +set -eu + +ROOT=${AI_SANDBOX_ROOT:-${XDG_RUNTIME_DIR:-/tmp}/ai-app-sandbox} +TOKEN=${AI_SANDBOX_TOKEN:-sandbox} +PORT=${AI_SANDBOX_PORT:-8443} +DELAY=${AI_SANDBOX_DELAY:-1200} +CERTS=${AI_SANDBOX_CERTS:-${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/certs} + +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +SERVER_DIR=$SCRIPT_DIR/../server +PIDFILE=$ROOT/server.pid +LOG=$ROOT/server.log + +# By pid rather than by pattern: a `pkill -f` for something as generic as +# "ai-server" also matches the shell running this script, which kills the +# script mid-flight and leaves the restart never having happened. +stop_server() { + [ -f "$PIDFILE" ] || return 0 + pid=$(cat "$PIDFILE") + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + kill "$pid" 2>/dev/null || true + echo "sandbox: stopped server $pid" + fi + rm -f "$PIDFILE" +} + +if [ "${1:-start}" = stop ]; then + stop_server + exit 0 +fi + +stop_server +rm -rf "$ROOT/home" "$ROOT/sessions" "$ROOT/config.ron" +PROJECTS=$ROOT/home/.claude/projects/-home-bob-repos-sandbox +mkdir -p "$PROJECTS" "$ROOT/sessions" + +# Eight of them, because the point of the screen is a list long enough that +# picking rows one at a time is the annoyance being fixed. Ids are the same +# shape the CLI writes (a uuid, and the file name *is* the session id), and +# each carries a `cwd` and a few user turns so the row has a title, a path +# and a line count to show. +i=1 +while [ "$i" -le 8 ]; do + id="0000000${i}-5eed-4a11-9c0d-000000000${i}00" + file=$PROJECTS/$id.jsonl + cwd="/home/bob/repos/sandbox/project-$i" + : >"$file" + turn=1 + while [ "$turn" -le $((i + 2)) ]; do + printf '{"type":"user","cwd":"%s","message":{"role":"user","content":[{"type":"text","text":"sandbox session %s, turn %s"}]}}\n' \ + "$cwd" "$i" "$turn" >>"$file" + turn=$((turn + 1)) + done + # A usage record on the last line, which is where the importer reads the + # context figure from. Left off two of them on purpose: "no turn has + # recorded any" is a state the row has to be able to show, and a list + # where every row has a number never exercises it. + if [ "$i" -ne 3 ] && [ "$i" -ne 6 ]; then + printf '{"type":"assistant","message":{"role":"assistant","usage":{"input_tokens":%s,"output_tokens":128}}}\n' \ + "$((i * 9000))" >>"$file" + fi + i=$((i + 1)) +done + +# A CLI that does nothing, so importing one of these is free and safe. +# Everything the spawn path cares about is here: it holds the fifo open, +# records a real pid, writes nothing, and dies on a signal. A real +# `claude --resume` against an invented session id would either fail in a +# way that tests nothing or start a turn on somebody's account. +cat >"$ROOT/fake-claude" <<'FAKE' +#!/bin/sh +cat > /dev/null +FAKE +chmod +x "$ROOT/fake-claude" + +hash=$(printf '%s' "$TOKEN" | sha256sum | cut -d' ' -f1) +cat >"$ROOT/config.ron" <"$LOG" 2>&1 & +pid=$! +disown -h "$pid" 2>/dev/null || true +echo "$pid" >"$PIDFILE" + +# Waited for rather than assumed: the enrolment below fails silently against +# a server that has not bound yet, and the app then shows a network error +# that has nothing to do with what is being tested. +tries=0 +while [ "$tries" -lt 50 ]; do + if grep -q "listening\|Listening" "$LOG" 2>/dev/null; then break; fi + kill -0 "$pid" 2>/dev/null || { echo "sandbox: server exited; see $LOG" >&2; tail -5 "$LOG" >&2; exit 1; } + tries=$((tries + 1)) + sleep 0.2 +done + +cat < {id}, referenced by /message //! GET /sessions/{id}/files/{name} images the session produced or was sent //! DELETE /sessions/{id} kill process, delete transcript + files +//! (?deleteForeign=true removes the machine's own copy too) //! POST /sessions/{id}/notify {notify} -- announce this one or not //! GET /notifications SSE: every session's attention-wanting //! moments, live only (see `notifications`) @@ -617,10 +618,43 @@ async fn spawn_session( Ok(axum::Json(info)) } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct DeleteSessionQuery { + /// Also remove the machine's own transcript of this conversation -- + /// the file Claude Code keeps under `~/.claude/projects`, which this + /// server's delete does not otherwise touch. + /// + /// Off by default, because the two deletes differ in what they cost: + /// leaving the machine's copy behind is recoverable and removing it is + /// not, and a default is the one choice nobody is shown. + #[serde(default)] + delete_foreign: bool, +} + async fn delete_session( State(manager): State>, UrlPath(id): UrlPath, + Query(query): Query, ) -> Result { + // Before the session goes, because only the session record says which + // file on which machine this conversation is. + let foreign = query + .delete_foreign + .then(|| manager.foreign_transcript(&id)) + .flatten(); + // And *deleted* before it too, so a machine that cannot be reached + // leaves everything as it was rather than a deleted session and a + // transcript the phone has already promised is gone. The phone can + // then retry, or turn the toggle off. + if let Some((setup, session)) = &foreign { + let setup = setup_by_id(&manager, setup)?; + let transport = crate::session::transport::Transport::for_setup(&setup); + crate::session::import::delete(&transport, session) + .await + .map_err(bad_request)?; + tracing::info!("deleted Claude Code session {session} with ai-app session {id}"); + } manager.delete_session(&id).map_err(bad_request)?; tracing::info!("deleted session {id}"); Ok(StatusCode::NO_CONTENT) diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs index 09dc960..8445756 100644 --- a/server/src/session/mod.rs +++ b/server/src/session/mod.rs @@ -902,21 +902,32 @@ impl SessionManager { pub fn session_driving(&self, source: &str) -> Option { let inner = self.inner.read().unwrap(); inner.config.sessions.iter().find_map(|meta| { - let dir = self.data_dir.join(&meta.id); - let followed = import::read_cursor(&dir).and_then(|cursor| { - cursor - .path - .rsplit('/') - .next() - .and_then(|name| name.strip_suffix(".jsonl")) - .map(str::to_string) - }); - let resuming = claude::read_resume_token(&dir); + let (followed, resuming) = foreign_ids(&self.data_dir.join(&meta.id)); (followed.as_deref() == Some(source) || resuming.as_deref() == Some(source)) .then(|| meta.id.clone()) }) } + /// The Claude Code session this one is the app's copy of, as the setup + /// it lives on and the id the importer knows it by -- or `None` where + /// the driver keeps no record of its own. + /// + /// This is [`session_driving`](Self::session_driving) read in the other + /// direction, and it exists for the same delete the phone offers a + /// toggle for: removing a session here can also remove the machine's + /// own transcript of it, and only the server knows which file that is. + pub fn foreign_transcript(&self, id: &str) -> Option<(String, String)> { + let inner = self.inner.read().unwrap(); + let meta = inner.config.sessions.iter().find(|meta| meta.id == id)?; + let (followed, resuming) = foreign_ids(&self.data_dir.join(&meta.id)); + // The cursor first: an imported session follows a file that exists + // whether or not a CLI has resumed it yet, so it is the answer that + // is true earliest. + followed + .or(resuming) + .map(|foreign| (meta.setup.clone(), foreign)) + } + /// Every session, in config order, with live status joined in. A /// session that failed to relaunch reports as exited. pub fn sessions(&self) -> Vec { @@ -1584,6 +1595,26 @@ fn label_of<'a>(config: &'a Config, id: &'a str) -> &'a str { config.setup(id).map_or(id, |setup| setup.name.as_str()) } +/// The two ways a session directory can name a Claude Code conversation: +/// the file an **imported** session follows, and the conversation a session +/// this app **spawned** resumes. +/// +/// Both, rather than the first that answers, because the callers ask +/// different questions of them -- "is either of these the session you +/// mean?" and "which file would deleting this one also remove?" -- and a +/// helper that picked one would answer the first wrongly. +fn foreign_ids(dir: &Path) -> (Option, Option) { + let followed = import::read_cursor(dir).and_then(|cursor| { + cursor + .path + .rsplit('/') + .next() + .and_then(|name| name.strip_suffix(".jsonl")) + .map(str::to_string) + }); + (followed, claude::read_resume_token(dir)) +} + /// Whether this session's provider keeps the conversation somewhere this /// app's delete cannot reach. /// @@ -2452,6 +2483,40 @@ mod tests { assert_eq!(manager.session_driving("some-other-session"), None); } + /// The other direction of the same lookup: which transcript on the + /// machine a delete would also remove. + /// + /// Worth its own test because the two halves answer at different times + /// -- a spawned session has no foreign transcript at all until the CLI + /// names itself -- and "nothing yet" must not read as "nothing ever". + #[tokio::test] + async fn a_sessions_foreign_transcript_is_the_conversation_it_resumes() { + let dir = tempfile::tempdir().expect("tempdir"); + let config_path = dir.path().join("config.ron"); + let data_dir = dir.path().join("sessions"); + seed_echo_only(&config_path); + let manager = SessionManager::new( + config_path.clone(), + data_dir.clone(), + data_dir.join("models"), + ) + .expect("manager"); + let info = manager.spawn_session(echo_spec()).expect("spawn"); + + // Nothing recorded yet, so there is nothing a delete would reach. + assert_eq!(manager.foreign_transcript(&info.id), None); + + claude::write_resume_token(&data_dir.join(&info.id), "5ecf21da-d53f"); + assert_eq!( + manager.foreign_transcript(&info.id), + Some((info.setup.clone(), "5ecf21da-d53f".to_string())) + ); + + // A session that is not there has no transcript to name, rather + // than a panic or somebody else's. + assert_eq!(manager.foreign_transcript("no-such-session"), None); + } + #[test] fn a_session_we_are_not_driving_says_exited_only_when_it_is_gone() { let dir = tempfile::tempdir().expect("tempdir");