Delete and import sessions in batches, and say which rows are busy

Clearing out imported sessions was one confirmation dialog per row, which
is why it was not worth doing. Holding a row on the import screen now
selects it and plain taps add more; Delete and Import act on the whole
selection from a bar along the bottom.

Submitting hands the work over and puts the screen back as it was: the
selection clears, the bar goes, and what says the work is happening is the
rows it is happening to -- the one in flight marked with its operation, the
rest marked "waiting". Both are inert, so a queued row cannot be tapped
into starting a second CLI behind the batch already coming for it. Rows
leave as each one lands rather than all at the end, because a finished row
still sitting there looks exactly like one that was never imported; the
rows below it therefore move, so a row that has just moved ignores taps for
half a second.

That busy appearance is one composable shared with the session list, which
had its own dimmed row and its own word for it. It is a word rather than a
bare spinner because deleting and importing differ in kind.

Deleting a session can now take the machine's own transcript with it, as a
switch in the confirmation and only where the driver keeps a record this
app's delete cannot otherwise reach. Off by default, since leaving that
copy is what makes an ordinary delete recoverable -- and the paragraph is
rewritten rather than appended to when it is on, because the sentence
promising the conversation is still there to import again is exactly the
one the switch makes false. The server removes the machine's copy first, so
a machine it cannot reach leaves the session where it was.

`app/ui-sandbox.sh` is how all of this was driven: a second server with its
own $HOME, invented transcripts and a two-line `claude`. Against the
ordinary server, testing delete deletes somebody's conversation and testing
import spends a turn on a real account.
This commit is contained in:
iris committed 2026-08-31 19:51:26 -04:00
1 parent 21d22f89c2
commit fd2e1d0798
8 files changed
+960 -296

No files matched your search

+34
View File
@@ -29,6 +29,7 @@
//! POST /sessions/{id}/attachments multipart image upload -> {id}, referenced by /message
//! GET /sessions/{id}/files/{name} images the session produced or was sent
//! DELETE /sessions/{id} kill process, delete transcript + files
//! (?deleteForeign=true removes the machine's own copy too)
//! POST /sessions/{id}/notify {notify} -- announce this one or not
//! GET /notifications SSE: every session's attention-wanting
//! moments, live only (see `notifications`)
@@ -617,10 +618,43 @@ async fn spawn_session(
Ok(axum::Json(info))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct DeleteSessionQuery {
/// Also remove the machine's own transcript of this conversation --
/// the file Claude Code keeps under `~/.claude/projects`, which this
/// server's delete does not otherwise touch.
///
/// Off by default, because the two deletes differ in what they cost:
/// leaving the machine's copy behind is recoverable and removing it is
/// not, and a default is the one choice nobody is shown.
#[serde(default)]
delete_foreign: bool,
}
async fn delete_session(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
Query(query): Query<DeleteSessionQuery>,
) -> Result<StatusCode, ApiError> {
// Before the session goes, because only the session record says which
// file on which machine this conversation is.
let foreign = query
.delete_foreign
.then(|| manager.foreign_transcript(&id))
.flatten();
// And *deleted* before it too, so a machine that cannot be reached
// leaves everything as it was rather than a deleted session and a
// transcript the phone has already promised is gone. The phone can
// then retry, or turn the toggle off.
if let Some((setup, session)) = &foreign {
let setup = setup_by_id(&manager, setup)?;
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} with ai-app session {id}");
}
manager.delete_session(&id).map_err(bad_request)?;
tracing::info!("deleted session {id}");
Ok(StatusCode::NO_CONTENT)
+75 -10
View File
@@ -902,21 +902,32 @@ impl SessionManager {
pub fn session_driving(&self, source: &str) -> Option<String> {
let inner = self.inner.read().unwrap();
inner.config.sessions.iter().find_map(|meta| {
let dir = self.data_dir.join(&meta.id);
let followed = import::read_cursor(&dir).and_then(|cursor| {
cursor
.path
.rsplit('/')
.next()
.and_then(|name| name.strip_suffix(".jsonl"))
.map(str::to_string)
});
let resuming = claude::read_resume_token(&dir);
let (followed, resuming) = foreign_ids(&self.data_dir.join(&meta.id));
(followed.as_deref() == Some(source) || resuming.as_deref() == Some(source))
.then(|| meta.id.clone())
})
}
/// The Claude Code session this one is the app's copy of, as the setup
/// it lives on and the id the importer knows it by -- or `None` where
/// the driver keeps no record of its own.
///
/// This is [`session_driving`](Self::session_driving) read in the other
/// direction, and it exists for the same delete the phone offers a
/// toggle for: removing a session here can also remove the machine's
/// own transcript of it, and only the server knows which file that is.
pub fn foreign_transcript(&self, id: &str) -> Option<(String, String)> {
let inner = self.inner.read().unwrap();
let meta = inner.config.sessions.iter().find(|meta| meta.id == id)?;
let (followed, resuming) = foreign_ids(&self.data_dir.join(&meta.id));
// The cursor first: an imported session follows a file that exists
// whether or not a CLI has resumed it yet, so it is the answer that
// is true earliest.
followed
.or(resuming)
.map(|foreign| (meta.setup.clone(), foreign))
}
/// Every session, in config order, with live status joined in. A
/// session that failed to relaunch reports as exited.
pub fn sessions(&self) -> Vec<SessionInfo> {
@@ -1584,6 +1595,26 @@ fn label_of<'a>(config: &'a Config, id: &'a str) -> &'a str {
config.setup(id).map_or(id, |setup| setup.name.as_str())
}
/// The two ways a session directory can name a Claude Code conversation:
/// the file an **imported** session follows, and the conversation a session
/// this app **spawned** resumes.
///
/// Both, rather than the first that answers, because the callers ask
/// different questions of them -- "is either of these the session you
/// mean?" and "which file would deleting this one also remove?" -- and a
/// helper that picked one would answer the first wrongly.
fn foreign_ids(dir: &Path) -> (Option<String>, Option<String>) {
let followed = import::read_cursor(dir).and_then(|cursor| {
cursor
.path
.rsplit('/')
.next()
.and_then(|name| name.strip_suffix(".jsonl"))
.map(str::to_string)
});
(followed, claude::read_resume_token(dir))
}
/// Whether this session's provider keeps the conversation somewhere this
/// app's delete cannot reach.
///
@@ -2452,6 +2483,40 @@ mod tests {
assert_eq!(manager.session_driving("some-other-session"), None);
}
/// The other direction of the same lookup: which transcript on the
/// machine a delete would also remove.
///
/// Worth its own test because the two halves answer at different times
/// -- a spawned session has no foreign transcript at all until the CLI
/// names itself -- and "nothing yet" must not read as "nothing ever".
#[tokio::test]
async fn a_sessions_foreign_transcript_is_the_conversation_it_resumes() {
let dir = tempfile::tempdir().expect("tempdir");
let config_path = dir.path().join("config.ron");
let data_dir = dir.path().join("sessions");
seed_echo_only(&config_path);
let manager = SessionManager::new(
config_path.clone(),
data_dir.clone(),
data_dir.join("models"),
)
.expect("manager");
let info = manager.spawn_session(echo_spec()).expect("spawn");
// Nothing recorded yet, so there is nothing a delete would reach.
assert_eq!(manager.foreign_transcript(&info.id), None);
claude::write_resume_token(&data_dir.join(&info.id), "5ecf21da-d53f");
assert_eq!(
manager.foreign_transcript(&info.id),
Some((info.setup.clone(), "5ecf21da-d53f".to_string()))
);
// A session that is not there has no transcript to name, rather
// than a panic or somebody else's.
assert_eq!(manager.foreign_transcript("no-such-session"), None);
}
#[test]
fn a_session_we_are_not_driving_says_exited_only_when_it_is_gone() {
let dir = tempfile::tempdir().expect("tempdir");