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:
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(" · ")
|
||||
Reference in new issue
Block a user