diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt index d4316c9..00e78fd 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -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 = @@ -199,6 +201,7 @@ fun fetchImportable(settings: ServerSettings, setup: String): List = 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") {} } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt index 0e44781..a557db2 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt @@ -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(null) } var failure by remember { mutableStateOf(null) } + // Deleting a transcript cannot be undone, so it is asked rather than done. Held as the row + // itself, not a flag, so the dialog can say which session it is about. + var confirming by remember { mutableStateOf(null) } 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>, 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(" · ") diff --git a/server/src/routes.rs b/server/src/routes.rs index 35a7c71..c1cc136 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -53,6 +53,10 @@ pub fn router(manager: Arc) -> 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>, + UrlPath((id, session)): UrlPath<(String, String)>, +) -> Result { + 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>, axum::Json(body): axum::Json, diff --git a/server/src/session/import.rs b/server/src/session/import.rs index 0e7d58c..d834559 100644 --- a/server/src/session/import.rs +++ b/server/src/session/import.rs @@ -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> { - // 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 = 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 { 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::(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); - } - (first_line_of(&record), 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); + } + } 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, 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(()) +}