Order the session list by when each agent was turned on

A running session no longer moves: the ones with a process come first,
oldest start first, so starting one appends it to the bottom of that
group and nothing it goes on to do -- beginning a turn, finishing one,
asking a question -- can shift it. Sorting by activity with the
awaiting-answer ones floated to the top is what this replaces; the
status word and its colour already say which session wants something
without the row having to move to say it. Stopped sessions are a group
below, most recently active first.

The order is the server's: `SessionConfig::started` is written each time
a process is started for a session and reported as `started`, so it is
the same on every device and survives a backend restart -- which adopts
processes rather than starting them, and so could not work the times out
for itself. Applied on the phone, because presentation order is a
display decision.

`LiveSession::info` takes the session's config entry rather than a
parameter per field read from it, which is what `AutoResumeView` existed
to bundle; that goes.

Verified on the emulator against the sandbox: three echo sessions kept
their order while the newest-active one was messaged; a stopped and
restarted session moved below one started after it; a stopped session
dropped below every running one; and after a backend restart the
recorded times came back unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Opus 5 committed 2026-09-15 23:54:36 -04:00
1 parent a9cfea89e5
commit cbae7ee8c0
6 files changed
+255 -64

No files matched your search

+16 -1
View File
@@ -1121,7 +1121,22 @@ Kotlin + Compose Multiplatform, single `:androidApp` module, same versions as
dev-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21). dev-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21).
1. **Session list** — kind icon, title, machine, model, status, last activity. 1. **Session list** — kind icon, title, machine, model, status, last activity.
Sessions awaiting an answer sort to the top: the "your turn" inbox. **A session with a process stays where it is, and the order is when each
agent was turned on** (2026-09-15, replacing the awaiting-answer inbox
sort): the running sessions come first, oldest start first, so one that is
started joins the bottom of that group and nothing it goes on to do —
beginning a turn, finishing one, asking a question — can move it. A list
that reorders itself is one nobody can keep their place in, and the status
word and its colour already say which session wants an answer without the
row having to move to say it. Stopped sessions are a group below, most
recently active first; "turned on" is what the order above is made of and a
session with no process has no place in it.
The order is the server's (`SessionConfig::started`, reported as
`started`), written each time a process is started for the session, so it
is the same on every device and survives a backend restart — which adopts
processes rather than starting them, and so could not work the times out
for itself. Applied on the phone (`sessionsInListOrder`), because
presentation order is a display decision.
2. **Import** — Claude Code sessions the machine already has, selected in 2. **Import** — Claude Code sessions the machine already has, selected in
batches (hold to enter, tap to add), with Delete and Import along the batches (hold to enter, tap to add), with Delete and Import along the
bottom. Submitting clears the selection immediately and marks every chosen bottom. Submitting clears the selection immediately and marks every chosen
@@ -225,6 +225,15 @@ data class SessionSummary(
val usageProvider: String?, val usageProvider: String?,
val status: String, val status: String,
val lastActivity: Double, val lastActivity: Double,
/**
* Epoch seconds a process was last started for this session -- when this agent was last turned
* on. What the list orders the running sessions by; see [sessionsInListOrder].
*
* The server's own, so the order is the same on every device and across a backend restart. An
* older server does not send it, and those sessions fall back to when the session was last
* active, which is the best this app can do without inventing a time.
*/
val started: Double,
/** Latest measured number of live background tasks; zero also covers older servers. */ /** Latest measured number of live background tasks; zero also covers older servers. */
val backgroundTasks: Int, val backgroundTasks: Int,
/** /**
@@ -265,6 +274,7 @@ private fun parseSession(session: JSONObject) =
usageProvider = session.optString("usageProvider").ifEmpty { null }, usageProvider = session.optString("usageProvider").ifEmpty { null },
status = session.getString("status"), status = session.getString("status"),
lastActivity = session.getDouble("lastActivity"), lastActivity = session.getDouble("lastActivity"),
started = session.optDouble("started", session.getDouble("lastActivity")),
backgroundTasks = session.optInt("backgroundTasks", 0), backgroundTasks = session.optInt("backgroundTasks", 0),
subagents = session.optInt("subagents", 0), subagents = session.optInt("subagents", 0),
) )
@@ -257,12 +257,7 @@ fun SessionListScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
// Awaiting-answer first (the point of the screen), then most recently active. val ordered = sessionsInListOrder(state.value)
val ordered =
state.value.sortedWith(
compareByDescending<SessionSummary> { it.status == "awaitingInput" }
.thenByDescending { it.lastActivity }
)
LazyColumn { LazyColumn {
uniqueItems(ordered, key = { it.id }) { session -> uniqueItems(ordered, key = { it.id }) { session ->
SessionCard( SessionCard(
@@ -472,6 +467,27 @@ fun SessionListScreen(
} }
} }
/**
* The order sessions are drawn in: the ones with a process, oldest agent first, then the stopped
* ones, most recently active first. A display decision, made here rather than on the server, which
* answers in config order -- see UI_RULES.
*
* The running group is ordered by when each agent was *turned on* rather than by what it is doing,
* so a session cannot move by beginning a turn, finishing one or asking a question, and a list a
* reader has their place in stays still. That replaces sorting by activity and floating the
* awaiting-answer ones to the top: the status word and its colour already say which session wants
* something, without the row having to move to say it. [SessionSummary.started] is the server's, so
* the order is the same on every device and survives a backend restart.
*
* Stopped sessions are a group of their own because "turned on" is what the order above is made of.
* A session nobody could ask about is not one of them -- it has a process, and dropping it out of
* the running group the moment a machine goes quiet is the move this exists to prevent.
*/
fun sessionsInListOrder(sessions: List<SessionSummary>): List<SessionSummary> {
val (running, stopped) = sessions.partition { it.status != "exited" }
return running.sortedBy { it.started } + stopped.sortedByDescending { it.lastActivity }
}
/** /**
* The subagents picked out inside one session's card, and which card that is. * The subagents picked out inside one session's card, and which card that is.
* *
@@ -0,0 +1,86 @@
package com.example.aiapp
import kotlin.test.Test
import kotlin.test.assertEquals
class SessionOrderTest {
@Test
fun `running sessions keep the order their agents were turned on in`() {
val first = session("first", status = "running", started = 10.0, lastActivity = 900.0)
val second =
session("second", status = "awaitingInput", started = 20.0, lastActivity = 20.0)
val third = session("third", status = "waiting", started = 30.0, lastActivity = 500.0)
assertEquals(
listOf("first", "second", "third"),
sessionsInListOrder(listOf(third, second, first)).map { it.id },
)
}
@Test
fun `a session started again joins the bottom of the running group`() {
val old = session("old", status = "idle", started = 10.0, lastActivity = 10.0)
val restarted = session("restarted", status = "idle", started = 99.0, lastActivity = 99.0)
assertEquals(
listOf("old", "restarted"),
sessionsInListOrder(listOf(restarted, old)).map { it.id },
)
}
@Test
fun `stopped sessions come after the running ones, most recent first`() {
val running = session("running", status = "idle", started = 100.0, lastActivity = 100.0)
val stale = session("stale", status = "exited", started = 1.0, lastActivity = 5.0)
val recent = session("recent", status = "exited", started = 2.0, lastActivity = 50.0)
assertEquals(
listOf("running", "recent", "stale"),
sessionsInListOrder(listOf(stale, recent, running)).map { it.id },
)
}
/**
* A session whose process nobody could ask about still has one, so it stays where it was rather
* than dropping into the stopped group the moment a machine goes quiet.
*/
@Test
fun `a session of unknown state is one of the running ones`() {
val unknown = session("unknown", status = "unknown", started = 10.0, lastActivity = 10.0)
val stopped = session("stopped", status = "exited", started = 5.0, lastActivity = 999.0)
assertEquals(
listOf("unknown", "stopped"),
sessionsInListOrder(listOf(stopped, unknown)).map { it.id },
)
}
private fun session(id: String, status: String, started: Double, lastActivity: Double) =
SessionSummary(
id = id,
machine = "machine",
machineName = "machine",
provider = "echo",
title = id,
model = null,
keepsOwnTranscript = false,
ownTranscriptName = null,
permissionMode = null,
effort = null,
takesEffort = false,
imported = false,
notify = true,
autoResume = false,
autoResumeMessage = DEFAULT_RESUME_MESSAGE,
resumeAt = null,
cwd = null,
contextTokens = null,
maxImageEdge = null,
usageProvider = null,
status = status,
lastActivity = lastActivity,
started = started,
backgroundTasks = 0,
subagents = 0,
)
}
+22
View File
@@ -392,6 +392,25 @@ pub struct SessionConfig {
#[serde(default, skip_serializing_if = "not_set")] #[serde(default, skip_serializing_if = "not_set")]
pub throwaway: bool, pub throwaway: bool,
pub created: f64, pub created: f64,
/// Epoch seconds a process was last started for this session, and `None`
/// for a session written before this was recorded.
///
/// The session list orders running sessions by it, so one that is turned
/// on joins the bottom of that group and stays where it is however busy
/// it gets. Persisted rather than held in memory because that order has
/// to survive a backend restart -- sessions are adopted, not restarted,
/// so nothing in the run that adopts them knows when they began.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub started: Option<f64>,
}
impl SessionConfig {
/// When this session's agent was turned on. A session written before that
/// was recorded answers with its creation, which is when its first
/// process started.
pub fn started_at(&self) -> f64 {
self.started.unwrap_or(self.created)
}
} }
/// A message owed to a session whose account ran out, and when to try sending /// A message owed to a session whose account ran out, and when to try sending
@@ -598,6 +617,7 @@ mod tests {
resume: None, resume: None,
throwaway: false, throwaway: false,
created: 1234.5, created: 1234.5,
started: Some(2345.5),
}], }],
}; };
config.save(&path).expect("save"); config.save(&path).expect("save");
@@ -608,6 +628,8 @@ mod tests {
// The label and the id are separate, and the session holds the id. // The label and the id are separate, and the session holds the id.
assert_eq!(loaded.machine("vm").expect("machine").name, "the vm"); assert_eq!(loaded.machine("vm").expect("machine").name, "the vm");
assert_eq!(loaded.sessions[0].provider, "claude-cli"); assert_eq!(loaded.sessions[0].provider, "claude-cli");
// The list's order is made of this, so it has to survive the file.
assert_eq!(loaded.sessions[0].started, Some(2345.5));
assert_eq!( assert_eq!(
loaded loaded
.machine("vm") .machine("vm")
+99 -57
View File
@@ -139,29 +139,6 @@ pub enum NotificationKind {
Finished, Finished,
} }
/// What a session's auto-resume setting looks like from outside: on or off,
/// what it would say, and when it next intends to check.
///
/// One struct rather than three parameters on [`LiveSession::info`], and read
/// from the config rather than from the launch snapshot beside it, for the
/// reason `cwd` is: all three change under a running session.
#[derive(Debug, Clone)]
pub struct AutoResumeView {
pub on: bool,
pub message: String,
pub at: Option<f64>,
}
impl AutoResumeView {
fn of(meta: &SessionConfig) -> Self {
Self {
on: meta.auto_resume,
message: resume_message(meta),
at: meta.resume.map(|scheduled| scheduled.at),
}
}
}
/// A session with a message owed to it once its account has quota again -- /// A session with a message owed to it once its account has quota again --
/// see [`SessionManager::owed_resumes`]. /// see [`SessionManager::owed_resumes`].
/// ///
@@ -272,6 +249,10 @@ pub struct SessionInfo {
pub status: SessionStatus, pub status: SessionStatus,
pub last_activity: f64, pub last_activity: f64,
pub created: f64, pub created: f64,
/// Epoch seconds this session's agent was last turned on, which the
/// session list orders the running ones by -- see
/// [`SessionConfig::started`], whose fallback this resolves.
pub started: f64,
/// The provider's latest measured number of live background tasks. /// The provider's latest measured number of live background tasks.
/// Absent until it reports one; absence is not a measured zero. /// Absent until it reports one; absence is not a measured zero.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -566,11 +547,12 @@ impl LiveSession {
Ok((name, path)) Ok((name, path))
} }
/// `machine_name` and `cwd` are passed in rather than read from the /// `machine_name` and the session's own config entry are passed in
/// snapshot this session launched with: only the manager holds the /// rather than read from the snapshot this session launched with: only
/// config, and both can change under a running session. Passed rather /// the manager holds the config, and a setting, a working directory or
/// than mirrored into `Shared`, so there is one answer, read where the /// the time of the latest start can all change under a running session.
/// row is built. /// Passed rather than mirrored into `Shared`, so there is one answer,
/// read where the row is built.
/// ///
/// `kind` rather than the facts derived from it, or every caller /// `kind` rather than the facts derived from it, or every caller
/// derives each one separately. `None` where the provider has been /// derives each one separately. `None` where the provider has been
@@ -579,11 +561,9 @@ impl LiveSession {
fn info( fn info(
&self, &self,
machine_name: &str, machine_name: &str,
cwd: Option<&Path>, current: &SessionConfig,
effort: Option<&str>,
imported: bool, imported: bool,
kind: Option<DriverKind>, kind: Option<DriverKind>,
resume: AutoResumeView,
) -> SessionInfo { ) -> SessionInfo {
SessionInfo { SessionInfo {
id: self.meta.id.clone(), id: self.meta.id.clone(),
@@ -596,22 +576,23 @@ impl LiveSession {
// From the config rather than from `shared`, like the cwd beside // From the config rather than from `shared`, like the cwd beside
// it: neither can change under a running process, so there is no // it: neither can change under a running process, so there is no
// live value for one to disagree with. // live value for one to disagree with.
effort: effort.map(str::to_string), effort: current.effort.clone(),
takes_effort: kind.is_some_and(DriverKind::takes_effort), takes_effort: kind.is_some_and(DriverKind::takes_effort),
context_tokens: *self.shared.context_tokens.lock().unwrap(), context_tokens: *self.shared.context_tokens.lock().unwrap(),
notify: *self.shared.notify.lock().unwrap(), notify: *self.shared.notify.lock().unwrap(),
auto_resume: resume.on, auto_resume: current.auto_resume,
auto_resume_message: resume.message, auto_resume_message: resume_message(current),
resume_at: resume.at, resume_at: current.resume.map(|scheduled| scheduled.at),
max_image_edge: kind.and_then(DriverKind::max_image_edge), max_image_edge: kind.and_then(DriverKind::max_image_edge),
usage_provider: kind.and_then(DriverKind::usage_provider), usage_provider: kind.and_then(DriverKind::usage_provider),
imported, imported,
keeps_own_transcript: kind.is_some_and(DriverKind::keeps_own_transcript), keeps_own_transcript: kind.is_some_and(DriverKind::keeps_own_transcript),
own_transcript_name: kind.and_then(DriverKind::own_transcript_name), own_transcript_name: kind.and_then(DriverKind::own_transcript_name),
cwd: cwd.map(Path::to_path_buf), cwd: current.cwd.clone(),
status: *self.shared.status.lock().unwrap(), status: *self.shared.status.lock().unwrap(),
last_activity: *self.shared.last_activity.lock().unwrap(), last_activity: *self.shared.last_activity.lock().unwrap(),
created: self.meta.created, created: self.meta.created,
started: current.started_at(),
background_tasks: self.driver().and_then(|driver| driver.background_tasks()), background_tasks: self.driver().and_then(|driver| driver.background_tasks()),
subagents: subagent::count(self.dir()), subagents: subagent::count(self.dir()),
} }
@@ -1107,11 +1088,9 @@ impl SessionManager {
.map(|meta| match inner.live.get(&meta.id) { .map(|meta| match inner.live.get(&meta.id) {
Some(session) => session.info( Some(session) => session.info(
label_of(&inner.config, &meta.machine), label_of(&inner.config, &meta.machine),
meta.cwd.as_deref(), meta,
meta.effort.as_deref(),
import::read_cursor(&self.data_dir.join(&meta.id)).is_some(), import::read_cursor(&self.data_dir.join(&meta.id)).is_some(),
kind_of(&inner.config, &meta.machine, &meta.provider), kind_of(&inner.config, &meta.machine, &meta.provider),
AutoResumeView::of(meta),
), ),
None => SessionInfo { None => SessionInfo {
id: meta.id.clone(), id: meta.id.clone(),
@@ -1145,6 +1124,7 @@ impl SessionManager {
status: status_of_unlaunched(&self.data_dir.join(&meta.id)), status: status_of_unlaunched(&self.data_dir.join(&meta.id)),
last_activity: meta.created, last_activity: meta.created,
created: meta.created, created: meta.created,
started: meta.started_at(),
background_tasks: None, background_tasks: None,
subagents: subagent::count(&self.data_dir.join(&meta.id)), subagents: subagent::count(&self.data_dir.join(&meta.id)),
}, },
@@ -1223,6 +1203,7 @@ impl SessionManager {
})? })?
.clone(); .clone();
let id = unique_id(&inner.config); let id = unique_id(&inner.config);
let created = now();
let title = spec let title = spec
.title .title
.filter(|title| !title.trim().is_empty()) .filter(|title| !title.trim().is_empty())
@@ -1268,7 +1249,10 @@ impl SessionManager {
// whichever server is running when the time comes knows what to // whichever server is running when the time comes knows what to
// do with it -- see `SessionConfig::throwaway`. // do with it -- see `SessionConfig::throwaway`.
throwaway: self.spawn_throwaway, throwaway: self.spawn_throwaway,
created: now(), created,
// The spawn starts a process, so this session is turned on from
// the moment it exists.
started: Some(created),
}; };
let session = launch( let session = launch(
@@ -1293,11 +1277,9 @@ impl SessionManager {
// of the directory a moment later. // of the directory a moment later.
let info = session.info( let info = session.info(
&machine.name, &machine.name,
session.meta.cwd.as_deref(), &session.meta,
session.meta.effort.as_deref(),
import::read_cursor(&self.data_dir.join(&id)).is_some(), import::read_cursor(&self.data_dir.join(&id)).is_some(),
Some(provider.kind), Some(provider.kind),
AutoResumeView::of(&session.meta),
); );
inner.live.insert(id, session); inner.live.insert(id, session);
Ok(info) Ok(info)
@@ -1931,6 +1913,18 @@ impl SessionManager {
inner.live.insert(id.to_string(), session); inner.live.insert(id.to_string(), session);
} }
} }
// Turned on now, so it takes its place at the bottom of the running
// group -- see `SessionConfig::started`. Best effort: a config that
// cannot be written is not a reason to fail a start that has already
// happened, and the cost is one row in the wrong place.
let mut candidate = inner.config.clone();
if let Some(meta) = candidate.sessions.iter_mut().find(|meta| meta.id == id) {
meta.started = Some(now());
match candidate.save(&self.config_path) {
Ok(()) => inner.config = candidate,
Err(err) => tracing::warn!("couldn't record that session {id} started: {err:#}"),
}
}
// Nothing is announced from here. A driver that starts a process // Nothing is announced from here. A driver that starts a process
// reports the session idle itself, in order with everything else it // reports the session idle itself, in order with everything else it
// says about that process. Saying it here too would be a second // says about that process. Saying it here too would be a second
@@ -3219,20 +3213,7 @@ mod tests {
// open to look one up on. // open to look one up on.
assert_eq!( assert_eq!(
first.title, first.title,
session session.info("m", &session.meta, false, None).title
.info(
"m",
None,
None,
false,
None,
AutoResumeView {
on: false,
message: DEFAULT_RESUME_MESSAGE.to_string(),
at: None,
},
)
.title
); );
manager.set_session_notify(&info.id, false).expect("off"); manager.set_session_notify(&info.id, false).expect("off");
@@ -4278,6 +4259,67 @@ mod tests {
.await; .await;
} }
/// The session list keeps the running sessions in the order their agents
/// were turned on, so what that order is made of has to outlive both the
/// process and the backend: a session started again belongs at the bottom
/// of the group, and a restart adopts processes rather than starting them,
/// so nothing in the new run could work the time out for itself.
#[tokio::test]
async fn starting_a_session_again_records_when_it_was_turned_on() {
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");
let spawned = info.started;
assert!(
(spawned - info.created).abs() < 1.0,
"a spawned session was turned on at {spawned} rather than when it was created",
);
let session = manager.session(&info.id).expect("live session");
let mut rx = session.subscribe();
let _ = session.sink.send(Event::Status {
state: SessionStatus::Exited,
});
collect_until(&mut rx, |event| {
matches!(
event,
Event::Status {
state: SessionStatus::Exited
}
)
})
.await;
// Otherwise the two starts can land in the same instant and the test
// cannot tell a recorded time from a kept one.
tokio::time::sleep(Duration::from_millis(20)).await;
manager.start_session(&info.id).expect("start again");
collect_until(&mut rx, is_idle).await;
let restarted = manager.sessions()[0].started;
assert!(
restarted > spawned,
"starting the session again left it reporting {restarted}, the {spawned} of the \
process that had already ended",
);
drop(manager);
let manager = SessionManager::new(config_path, data_dir.clone(), data_dir.join("models"))
.expect("manager restart");
assert_eq!(
manager.sessions()[0].started,
restarted,
"the backend restart lost when this session's agent was turned on",
);
}
/// Stopping and starting a session is about its *process*, and the two /// Stopping and starting a session is about its *process*, and the two
/// refusals are what keeps starting one from becoming a second one on the /// refusals are what keeps starting one from becoming a second one on the
/// same conversation. Echo has no process, which makes it the right /// same conversation. Echo has no process, which makes it the right