Name a session in the import list, and let one be deleted

Three things about finding a session in a list of seventy, and one about
getting rid of it.

**A name beats anything inferred.** `/rename` appends a `custom-title`
record, so if somebody has said what a session is, that is the row. Eleven
of the seventy here turned out to be named already and none of it showed.

**Otherwise the last thing said, not the first.** The question this list
answers is "which one was I just in", and a session's opening line is the
least distinctive thing about it -- several of these begin with the same
slash command.

Finding that last message took three tries, and the two wrong ones are
worth recording because they failed in opposite directions. Grepping the
user record type caught tool results, which are *also* user records -- so
a session that ended mid-tool showed a tail of empty records and a row
saying nothing was said, when plenty had been. Narrowing to a string
`content` fixed those two and broke twenty others, because a message
carrying an attachment stores its text in a list. Excluding `tool_use_id`
keeps both shapes of a real message and drops the one that is not: seven
rows still have nothing to show, and those are sessions that really are
empty.

**Sorted by when it was last used**, and the time is on the row. Naming
was tried as the first sort key and is a worse list -- it buries what
somebody was just doing under everything they ever named. A name is for
recognising a row, not for ordering it, so it stays as the title and as a
word beside it.

**And a session can be deleted**, which is asked before it is done. The
transcript *is* the session, so this ends any chance of resuming that
conversation, and the dialog says exactly that rather than "are you
sure?". Deletion resolves the id against what the machine reported, like
importing, so no path crosses the wire in either direction.

Looked at on the emulator, including the dialog -- which is where the
delete button turned out to be missing entirely after a patch that
compiled fine, and where the row layout got its second look.
This commit is contained in:
iris committed 2026-08-28 22:08:43 -04:00
1 parent 6bbc829a3e
commit 233689ced6
4 files changed
+223 -39

No files matched your search

@@ -188,6 +188,8 @@ data class Importable(
val title: String,
val modified: Double,
val lines: Int,
/** Whether [title] is a name somebody chose rather than the last thing said in the session. */
val named: Boolean,
)
fun fetchImportable(settings: ServerSettings, setup: String): List<Importable> =
@@ -199,6 +201,7 @@ fun fetchImportable(settings: ServerSettings, setup: String): List<Importable> =
title = session.optString("title"),
modified = session.optDouble("modified", 0.0),
lines = session.optInt("lines", 0),
named = session.optBoolean("named", false),
)
}
}
@@ -418,6 +421,16 @@ fun interruptSession(settings: ServerSettings, sessionId: String) {
requestFromServer(settings, "/sessions/$sessionId/interrupt", method = "POST") {}
}
/**
* Removes a Claude Code session from the machine.
*
* The transcript *is* the session, so this ends any chance of resuming that conversation. The
* caller confirms first; see ImportScreen.
*/
fun deleteImportable(settings: ServerSettings, setup: String, sessionId: String) {
requestFromServer(settings, "/setups/$setup/importable/$sessionId", method = "DELETE") {}
}
fun deleteSession(settings: ServerSettings, sessionId: String) {
requestFromServer(settings, "/sessions/$sessionId", method = "DELETE") {}
}
@@ -8,8 +8,10 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
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.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
@@ -51,6 +53,9 @@ fun ImportScreen(
// 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) }
fun loadSessions(setup: Setup) {
sessions = LoadState.Loading
@@ -136,6 +141,7 @@ fun ImportScreen(
ImportableList(
state = sessions,
importing = importing,
onDelete = { confirming = it },
onPick = { session ->
val setup = chosen ?: return@ImportableList
val useProvider = provider ?: return@ImportableList
@@ -166,12 +172,50 @@ fun ImportScreen(
}
}
}
confirming?.let { session ->
AlertDialog(
onDismissRequest = { confirming = null },
title = { Text("Delete this session?") },
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."
)
},
confirmButton = {
TextButton(
onClick = {
val setup = chosen ?: return@TextButton
confirming = null
scope.launch {
try {
withContext(Dispatchers.IO) {
deleteImportable(settings, setup.id, session.id)
}
loadSessions(setup)
} catch (err: Exception) {
failure = err.message ?: "Couldn't delete that session"
}
}
}
) {
// Coloured by consequence: this takes something away, wherever it appears.
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = { TextButton(onClick = { confirming = null }) { Text("Cancel") } },
)
}
}
@Composable
private fun ImportableList(
state: LoadState<List<Importable>>,
importing: String?,
onDelete: (Importable) -> Unit,
onPick: (Importable) -> Unit,
) {
when (state) {
@@ -194,30 +238,45 @@ private fun ImportableList(
}
) {
Column(Modifier.padding(12.dp)) {
Text(
session.title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Row(verticalAlignment = Alignment.Top) {
Text(
session.title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
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))
Text(
listOfNotNull(
if (importing == session.id) "importing…" else null,
"${session.lines} lines",
// The tail, not the head: a path is identified by
// where it ends, and these all share a long prefix.
session.cwd
.takeIf { it.isNotEmpty() }
?.let { cwd ->
if (cwd.length > 34) "" + cwd.takeLast(34)
else cwd
},
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
detailOf(session, importing),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
// 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(
"Delete",
style = MaterialTheme.typography.bodySmall,
// Coloured by consequence: this takes something
// away, and does so wherever it appears.
color = MaterialTheme.colorScheme.error,
)
.joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@@ -225,3 +284,19 @@ private fun ImportableList(
}
}
}
/** The second line of a row: what this session is, in the order it is worth knowing. */
private fun detailOf(session: Importable, importing: String?): 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,
"${session.lines} lines",
// The tail, not the head: a path is identified by where it ends, and these all share
// a long prefix.
session.cwd
.takeIf { it.isNotEmpty() }
?.let { cwd -> if (cwd.length > 28) "" + cwd.takeLast(28) else cwd },
)
.joinToString(" · ")