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 bbd04be..8ff74a0 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -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"), ) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt index f219781..ea691dd 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt @@ -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 = { diff --git a/server/src/routes.rs b/server/src/routes.rs index 905d234..8b4e7b4 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -397,9 +397,18 @@ async fn list_importable( ) -> Result>, 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)?; diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs index 09b8e18..e247d1a 100644 --- a/server/src/session/mod.rs +++ b/server/src/session/mod.rs @@ -82,6 +82,15 @@ pub struct SessionInfo { /// thought you were confirming. #[serde(skip_serializing_if = "Option::is_none")] pub permission_mode: Option, + /// 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, 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 { + 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 { 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) }