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
+214
-30
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,34 +238,65 @@ private fun ImportableList(
|
||||
}
|
||||
) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
Text(
|
||||
session.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
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(
|
||||
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
|
||||
},
|
||||
)
|
||||
.joinToString(" · "),
|
||||
relativeTime(session.modified),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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(" · ")
|
||||
@@ -53,6 +53,10 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
|
||||
.route("/setups", get(list_setups).post(add_setup))
|
||||
.route("/setups/probe", post(probe_setup))
|
||||
.route("/setups/{id}/importable", get(list_importable))
|
||||
.route(
|
||||
"/setups/{id}/importable/{session}",
|
||||
delete(delete_importable),
|
||||
)
|
||||
.route(
|
||||
"/setups/{id}",
|
||||
get(read_setup).put(update_setup).delete(delete_setup),
|
||||
@@ -398,6 +402,25 @@ async fn list_importable(
|
||||
Ok(axum::Json(found))
|
||||
}
|
||||
|
||||
/// Removes a Claude Code session from a machine.
|
||||
///
|
||||
/// The transcript *is* the session, so this ends any chance of resuming
|
||||
/// that conversation -- including from an ai-app session already importing
|
||||
/// it. The phone confirms before calling this; the server does not
|
||||
/// second-guess a decision somebody was shown the cost of.
|
||||
async fn delete_importable(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath((id, session)): UrlPath<(String, String)>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let setup = setup_by_id(&manager, &id)?;
|
||||
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} from setup {id}");
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn spawn_session(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
axum::Json(body): axum::Json<SpawnRequest>,
|
||||
|
||||
@@ -49,6 +49,11 @@ pub struct Importable {
|
||||
/// Epoch seconds, for ordering by "what I was last doing".
|
||||
pub modified: f64,
|
||||
pub lines: usize,
|
||||
/// Whether [`title`](Self::title) is a name somebody chose rather than
|
||||
/// something read out of the conversation. Sorted on, and worth the
|
||||
/// reader knowing: a name is a claim about what a session *is*, and a
|
||||
/// last message is only the last thing that happened in it.
|
||||
pub named: bool,
|
||||
/// Where it lives. Not serialized: the phone chooses by id and the
|
||||
/// server resolves the path, so a path never crosses the wire in
|
||||
/// either direction.
|
||||
@@ -64,15 +69,35 @@ pub struct Importable {
|
||||
/// `stat -c` is GNU-specific, which is fine for the machines here and is
|
||||
/// the thing to change first if this ever meets a BSD.
|
||||
pub async fn list(transport: &Transport) -> Result<Vec<Importable>> {
|
||||
// The first few user records rather than only the first: a session
|
||||
// usually opens with meta records the CLI injected -- caveats about
|
||||
// local commands, and so on -- and titling a session with those would
|
||||
// give a list where every row reads the same.
|
||||
// Two questions per file, both answered from the end of it.
|
||||
//
|
||||
// A rename, if there was one: `/rename` appends a `custom-title`
|
||||
// record, and a name somebody chose beats anything inferred from the
|
||||
// conversation. Grepped over the whole file rather than its tail,
|
||||
// because a session can be named early and talked in for hours after.
|
||||
//
|
||||
// Then the last several things a person said. The *last*, not the
|
||||
// first: the question a list like this answers is "which one was I
|
||||
// just in", and every session's opening line is the least distinctive
|
||||
// thing about it. Several, because the final ones are often the CLI's
|
||||
// own -- a slash command, the caveat wrapped around its output -- and
|
||||
// one of those identifies nothing.
|
||||
//
|
||||
// Tool results are excluded rather than typed messages included, and
|
||||
// the difference matters: a tool result is *also* a user record --
|
||||
// it is how the API models one -- so grepping the type alone gave a
|
||||
// session that ended mid-tool a tail of empty records and a row
|
||||
// saying nothing was said, when plenty was. But matching only a
|
||||
// string `content` was worse: a message carrying an attachment stores
|
||||
// its text in a list, so that reading lost twenty rows rather than
|
||||
// two. Excluding `tool_use_id` keeps both shapes of a real message
|
||||
// and drops the one that is not.
|
||||
let script = r#"
|
||||
for f in "$HOME"/.claude/projects/*/*.jsonl; do
|
||||
[ -f "$f" ] || continue
|
||||
printf '%s\t%s\t%s\t' "$(stat -c %Y "$f" 2>/dev/null || echo 0)" "$(wc -l < "$f")" "$f"
|
||||
grep -m8 '"type":"user"' "$f" 2>/dev/null | tr '\n' '\037'
|
||||
grep '"type":"custom-title"' "$f" 2>/dev/null | tail -1 | tr '\n' '\037'
|
||||
grep '"type":"user"' "$f" 2>/dev/null | grep -v '"tool_use_id"' | tail -12 | tr '\n' '\037'
|
||||
printf '\n'
|
||||
done
|
||||
"#;
|
||||
@@ -80,8 +105,12 @@ done
|
||||
let found = transport.capture(&launch).await?;
|
||||
|
||||
let mut sessions: Vec<Importable> = found.lines().filter_map(parse_row).collect();
|
||||
// Most recent first: the reason to open this list is almost always to
|
||||
// pick up what you were just doing.
|
||||
// Most recent first, and only that. Naming was tried as the first key
|
||||
// and is a worse list: it buries what somebody was just doing under
|
||||
// everything they ever named, and the reason to open this screen is
|
||||
// almost always to pick up where they left off. A name still shows,
|
||||
// as the row's title and as a word beside it -- being easier to
|
||||
// recognise is what a name is for, and it does not need the order too.
|
||||
sessions.sort_by(|a, b| b.modified.total_cmp(&a.modified));
|
||||
Ok(sessions)
|
||||
}
|
||||
@@ -94,22 +123,42 @@ fn parse_row(line: &str) -> Option<Importable> {
|
||||
let path = fields.next()?.to_string();
|
||||
let id = path.rsplit('/').next()?.strip_suffix(".jsonl")?.to_string();
|
||||
|
||||
let heads = fields.next().unwrap_or("");
|
||||
let (title, cwd) = heads
|
||||
let mut named = None;
|
||||
let mut said = None;
|
||||
let mut cwd = None;
|
||||
for record in fields
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.split('\u{1f}')
|
||||
.filter_map(|record| serde_json::from_str::<Value>(record).ok())
|
||||
.fold((None, None), |(title, cwd), record| {
|
||||
let cwd = cwd.or_else(|| record.get("cwd")?.as_str().map(String::from));
|
||||
if title.is_some() || is_hidden(&record) {
|
||||
return (title, cwd);
|
||||
{
|
||||
if cwd.is_none() {
|
||||
cwd = record.get("cwd").and_then(Value::as_str).map(String::from);
|
||||
}
|
||||
if let Some(custom) = record.get("customTitle").and_then(Value::as_str) {
|
||||
named = Some(custom.to_string());
|
||||
continue;
|
||||
}
|
||||
if !is_hidden(&record)
|
||||
&& let Some(text) = first_line_of(&record)
|
||||
{
|
||||
// Kept rather than broken out of: these arrive oldest first,
|
||||
// so the last one to survive the filter is the most recent
|
||||
// thing that was actually said.
|
||||
said = Some(text);
|
||||
}
|
||||
}
|
||||
(first_line_of(&record), cwd)
|
||||
});
|
||||
|
||||
Some(Importable {
|
||||
id,
|
||||
cwd: cwd.unwrap_or_default(),
|
||||
title: title.unwrap_or_else(|| "(no opening message)".to_string()),
|
||||
// A name somebody typed outranks anything read out of the
|
||||
// conversation, because they chose it to answer this exact
|
||||
// question.
|
||||
named: named.is_some(),
|
||||
title: named
|
||||
.or(said)
|
||||
.unwrap_or_else(|| "(no messages)".to_string()),
|
||||
modified,
|
||||
lines,
|
||||
path,
|
||||
@@ -275,3 +324,27 @@ fn push_assistant(events: &mut Vec<Event>, content: &Value) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes one of the sessions [`list`] reported.
|
||||
///
|
||||
/// By id, resolved here against what the machine actually has, so the
|
||||
/// caller never names a file -- the same rule importing follows, and it
|
||||
/// matters more here: this one removes something.
|
||||
///
|
||||
/// Irreversible, and the caller is expected to have said so. Claude Code
|
||||
/// keeps no copy: the JSONL *is* the session, so deleting it ends any
|
||||
/// chance of resuming that conversation, including from an ai-app session
|
||||
/// that was already importing it.
|
||||
pub async fn delete(transport: &Transport, id: &str) -> Result<()> {
|
||||
let found = list(transport).await?;
|
||||
let chosen = found
|
||||
.into_iter()
|
||||
.find(|candidate| candidate.id == id)
|
||||
.with_context(|| format!("no Claude Code session {id} on that machine"))?;
|
||||
let launch = Launch::new("rm", vec!["-f".to_string(), chosen.path.clone()], None);
|
||||
transport
|
||||
.capture(&launch)
|
||||
.await
|
||||
.with_context(|| format!("deleting {}", chosen.path))?;
|
||||
Ok(())
|
||||
}
|
||||
Reference in new issue
Block a user