Say which kind of delete this is, and stop offering what is already open

**Deleting was one word for two different acts.** An imported session's
real transcript belongs to Claude Code and outlives anything this app
does, so removing it here undoes a view. A session started here has no
copy anywhere, and removing it ends the conversation. The dialog warned
"this can't be undone" of both, which makes the warning worthless on the
one where it is true -- and frightening on the one where it is not, since
what it actually deletes is a cache of a conversation still sitting on
the machine.

Sessions now report whether they were imported, and the dialog says which
act this is. No new mechanism: the soft delete already existed, it was
just indistinguishable from the hard one.

**And a session already open here is no longer offered for import.**
Importing one twice would leave two `--resume` processes appending to the
same transcript, each seeing the other's writes as work done elsewhere and
replaying them -- both sessions then showing a conversation neither is
having. The route refuses it as well, so the rule holds for anything not
going through the app.

Left out of the list rather than shown and disabled. The usual argument
says absence is ambiguous, and it is wrong here: an imported session has
not disappeared, it has moved to the session list, which is where it now
belongs. Absence means "already somewhere you can reach it". Deleting the
app's copy puts it straight back -- verified: 68 offered, 67 after
importing one, 68 again after the soft delete, which is also the clearest
demonstration that a soft delete keeps the conversation.
This commit is contained in:
iris committed 2026-08-28 22:53:42 -04:00
1 parent d2915c12fa
commit 3f8805a610
4 files changed
+78 -5

No files matched your search

@@ -120,6 +120,10 @@ data class SessionSummary(
val model: String?,
/** How much the session asks before acting; null when it was never set. */
val permissionMode: String?,
/**
* Whether this continues a session the machine already had, which changes what deleting means.
*/
val imported: Boolean,
val status: String,
val lastActivity: Double,
)
@@ -132,6 +136,7 @@ private fun parseSession(session: JSONObject) =
title = session.getString("title"),
model = session.optString("model").ifEmpty { null },
permissionMode = session.optString("permissionMode").ifEmpty { null },
imported = session.optBoolean("imported", false),
status = session.getString("status"),
lastActivity = session.getDouble("lastActivity"),
)
@@ -163,7 +163,19 @@ fun SessionListScreen(
AlertDialog(
onDismissRequest = { confirmingDelete = null },
title = { Text("Delete \"${session.title}\"?") },
text = { Text("Kills the process and deletes its transcript. This can't be undone.") },
text = {
// Two different acts behind one button, so it says which one this is. An
// imported session's real transcript belongs to Claude Code and outlives
// this: removing it here undoes a view. A session started here has no copy
// anywhere, and removing it ends the conversation. Warning "this cannot be
// undone" of both would make the warning worthless where it is true.
Text(
if (session.imported)
"Stops the process and removes this app's copy. The conversation " +
"itself stays on the machine and can be imported again."
else "Kills the process and deletes its transcript. This can't be undone."
)
},
confirmButton = {
TextButton(
onClick = {
+17 -1
View File
@@ -397,9 +397,18 @@ async fn list_importable(
) -> Result<axum::Json<Vec<crate::session::import::Importable>>, ApiError> {
let setup = setup_by_id(&manager, &id)?;
let transport = crate::session::transport::Transport::for_setup(&setup);
let found = crate::session::import::list(&transport)
let mut found = crate::session::import::list(&transport)
.await
.map_err(bad_request)?;
// Anything this app is already continuing is not offered again. Left
// out rather than shown-and-disabled, because it has not disappeared:
// it is in the session list, which is where it now belongs. Absence
// here means "already somewhere you can reach it", not "gone".
//
// Joined here because the importer knows about files and the manager
// knows about sessions, and putting the two together is the route's
// job rather than either one's.
found.retain(|candidate| manager.session_importing(&candidate.id).is_none());
Ok(axum::Json(found))
}
@@ -445,6 +454,13 @@ async fn spawn_session(
body.setup
))
})?;
if let Some(existing) = manager.session_importing(want) {
return Err(ApiError::BadRequest(format!(
"session {existing} is already continuing that one -- delete it first if you \
want a fresh copy. Deleting it here does not touch the conversation itself, \
only this app's view of it."
)));
}
let events = crate::session::import::replay(&transport, &chosen.path)
.await
.map_err(bad_request)?;
+43 -3
View File
@@ -82,6 +82,15 @@ pub struct SessionInfo {
/// thought you were confirming.
#[serde(skip_serializing_if = "Option::is_none")]
pub permission_mode: Option<String>,
/// Whether this session continues one the machine already had.
///
/// Reported because it changes what deleting *means*: an imported
/// session's real transcript belongs to the CLI and survives, so
/// removing it here is undoing a view. A session started here has no
/// copy anywhere else, and removing it ends the conversation. Saying
/// "this cannot be undone" of both makes the warning worthless on the
/// one where it is true.
pub imported: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<PathBuf>,
pub status: SessionStatus,
@@ -196,7 +205,7 @@ impl LiveSession {
/// `setup_name` is passed in rather than stored: only the manager
/// holds the config, and the label can change under a running session.
fn info(&self, setup_name: &str) -> SessionInfo {
fn info(&self, setup_name: &str, imported: bool) -> SessionInfo {
SessionInfo {
id: self.meta.id.clone(),
provider: self.meta.provider.clone(),
@@ -205,6 +214,7 @@ impl LiveSession {
title: self.meta.title.clone(),
model: self.shared.model.lock().unwrap().clone(),
permission_mode: self.shared.permission_mode.lock().unwrap().clone(),
imported,
cwd: self.meta.cwd.clone(),
status: *self.shared.status.lock().unwrap(),
last_activity: *self.shared.last_activity.lock().unwrap(),
@@ -468,6 +478,27 @@ impl SessionManager {
tracing::info!("stopped {} session process(es)", inner.live.len());
}
/// The session already continuing `source`, if there is one.
///
/// Importing the same Claude Code session twice would leave two
/// `--resume` processes appending to one transcript, each seeing the
/// other's writes as work done elsewhere and replaying them. Nothing
/// is corrupted, but both sessions show a conversation neither of them
/// is having, which is worse than a refusal.
pub fn session_importing(&self, source: &str) -> Option<String> {
let inner = self.inner.read().unwrap();
inner.config.sessions.iter().find_map(|meta| {
let cursor = import::read_cursor(&self.data_dir.join(&meta.id))?;
cursor
.path
.rsplit('/')
.next()?
.strip_suffix(".jsonl")
.filter(|found| *found == source)
.map(|_| meta.id.clone())
})
}
pub fn sessions(&self) -> Vec<SessionInfo> {
let inner = self.inner.read().unwrap();
inner
@@ -475,7 +506,10 @@ impl SessionManager {
.sessions
.iter()
.map(|meta| match inner.live.get(&meta.id) {
Some(session) => session.info(label_of(&inner.config, &meta.setup)),
Some(session) => session.info(
label_of(&inner.config, &meta.setup),
import::read_cursor(&self.data_dir.join(&meta.id)).is_some(),
),
None => SessionInfo {
id: meta.id.clone(),
setup: meta.setup.clone(),
@@ -484,6 +518,7 @@ impl SessionManager {
title: meta.title.clone(),
model: meta.model.clone(),
permission_mode: meta.permission_mode.clone(),
imported: import::read_cursor(&self.data_dir.join(&meta.id)).is_some(),
cwd: meta.cwd.clone(),
status: SessionStatus::Exited,
last_activity: meta.created,
@@ -592,7 +627,12 @@ impl SessionManager {
return Err(err);
}
inner.config = candidate;
let info = session.info(&setup.name);
// Whether this one was seeded, which is the same question the
// listing asks of the directory a moment later.
let info = session.info(
&setup.name,
import::read_cursor(&self.data_dir.join(&id)).is_some(),
);
inner.live.insert(id, session);
Ok(info)
}