Delete and import sessions in batches, and say which rows are busy

Clearing out imported sessions was one confirmation dialog per row, which
is why it was not worth doing. Holding a row on the import screen now
selects it and plain taps add more; Delete and Import act on the whole
selection from a bar along the bottom.

Submitting hands the work over and puts the screen back as it was: the
selection clears, the bar goes, and what says the work is happening is the
rows it is happening to -- the one in flight marked with its operation, the
rest marked "waiting". Both are inert, so a queued row cannot be tapped
into starting a second CLI behind the batch already coming for it. Rows
leave as each one lands rather than all at the end, because a finished row
still sitting there looks exactly like one that was never imported; the
rows below it therefore move, so a row that has just moved ignores taps for
half a second.

That busy appearance is one composable shared with the session list, which
had its own dimmed row and its own word for it. It is a word rather than a
bare spinner because deleting and importing differ in kind.

Deleting a session can now take the machine's own transcript with it, as a
switch in the confirmation and only where the driver keeps a record this
app's delete cannot otherwise reach. Off by default, since leaving that
copy is what makes an ordinary delete recoverable -- and the paragraph is
rewritten rather than appended to when it is on, because the sentence
promising the conversation is still there to import again is exactly the
one the switch makes false. The server removes the machine's copy first, so
a machine it cannot reach leaves the session where it was.

`app/ui-sandbox.sh` is how all of this was driven: a second server with its
own $HOME, invented transcripts and a two-line `claude`. Against the
ordinary server, testing delete deletes somebody's conversation and testing
import spends a turn on a real account.
This commit is contained in:
iris committed 2026-08-31 19:51:26 -04:00
1 parent 21d22f89c2
commit fd2e1d0798
8 files changed
+960 -296

No files matched your search

+38
View File
@@ -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
@@ -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
@@ -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()
}
}
@@ -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<List<Setup>>>(LoadState.Loading) }
var chosen by remember { mutableStateOf<Setup?>(null) }
var sessions by remember { mutableStateOf<LoadState<List<Importable>>>(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<String?>(null) }
var failure by remember { mutableStateOf<String?>(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<Importable?>(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<Map<String, String>>(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<Set<String>>(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<Map<String, String>>(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<List<Importable>?>(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<String, Long>() }
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<Importable>, 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<Importable>, 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<List<Importable>>,
importing: String?,
onDelete: (Importable) -> Unit,
onPick: (Importable) -> Unit,
/** Rows an operation is running on, as the word each one shows. */
running: Map<String, String>,
/** Whether this row has just moved and should ignore being touched -- see [SETTLE_MS]. */
settling: (String) -> Boolean,
selected: Set<String>,
errors: Map<String, String>,
/** 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,
@@ -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) =
+175
View File
@@ -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" <<RON
tokens: [
(
name: "sandbox",
sha256: "$hash",
),
],
setups: [
(
id: "local",
name: "sandbox",
providers: [
(
name: "echo",
kind: echo,
),
(
name: "claude-cli",
kind: claude_cli,
command: "$ROOT/fake-claude",
models: [
"haiku",
],
),
],
),
],
sessions: [],
RON
echo "sandbox: building"
(cd "$SERVER_DIR" && cargo build --quiet)
# Fully detached, so it outlives the shell that started it. HOME is the
# whole isolation: the importer's script reads it, and nothing else here
# looks outside the paths passed explicitly below.
HOME=$ROOT/home setsid nohup "$SERVER_DIR/target/debug/ai-server" \
--bind 127.0.0.1 \
--port "$PORT" \
--config "$ROOT/config.ron" \
--data-dir "$ROOT/sessions" \
--models-dir "$ROOT/models" \
--certs "$CERTS" \
--delay "$DELAY" \
>"$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 <<INFO
sandbox: server $pid on 127.0.0.1:$PORT, log $LOG
sandbox: 8 invented Claude Code sessions under $PROJECTS
enrol the emulator:
adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=$PORT&token=$TOKEN'"
stop it:
./ui-sandbox.sh stop
INFO
+34
View File
@@ -29,6 +29,7 @@
//! POST /sessions/{id}/attachments multipart image upload -> {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<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
Query(query): Query<DeleteSessionQuery>,
) -> Result<StatusCode, ApiError> {
// 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)
+75 -10
View File
@@ -902,21 +902,32 @@ impl SessionManager {
pub fn session_driving(&self, source: &str) -> Option<String> {
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<SessionInfo> {
@@ -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<String>, Option<String>) {
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");