Let the reader put the session list in its own order

Nothing sorts the sessions tab any more. The order is the server's
`sessions` list, which is the reader's arrangement: holding a row puts the
screen in selection mode -- the same gesture and the same bottom bar as the
import tab -- and each card grows a burger handle at its right edge that
drags the row to a new place, with a tick of haptic feedback for each one it
passes.

The two attempts this replaces, sorting by activity and then by when each
agent was turned on, were both looking for an order a session could not move
itself out of; no rule computed from what a session is doing can be one.
`POST /sessions/order` rewrites the config's order, so it is the same on
every device and survives a backend restart, and `SessionConfig::started`
goes with the sort that needed it.

Rearranging is independent of the selection: the handle moves the row it is
on, picked out or not. The click moved off the card and onto its contents so
that a press landing on the handle cannot also select the row it is about to
move. Selection's one action is Delete, which now takes the whole set.

Two traps in `Reorder.kt`, both measured on the emulator and written down in
`this-machine-android`: a crossing is decided from how far the finger has
travelled, because a lazy list animates an item into its new place and its
`offset` reports the old one for several frames; and the viewport is pinned
with `requestScrollToItem` around each move, because a lazy list keeps its
place by the key of the top item and would otherwise follow the row being
dragged.

Verified on the emulator against the sandbox: the order survives an app
restart and a backend read-back, a two-row drag moves exactly two rows, a
drag to the bottom edge scrolls the list and lands the row last, pressing the
handle without moving changes nothing, and deleting two selected sessions
leaves the rest in place.
This commit is contained in:
iris-ai committed 2026-09-20 01:06:15 -04:00
1 parent 942edd6b31
commit b7fd18b195
12 files changed
+801 -339

No files matched your search

+4 -22
View File
@@ -33,6 +33,10 @@ pub struct Config {
/// machines already configured; every subsequent write uses `machines`.
#[serde(alias = "setups")]
pub machines: Vec<MachineConfig>,
/// Every session, in the order the reader has put them in -- what `GET
/// /sessions` answers in and what every screen draws. A drag on the phone
/// rewrites this (`POST /sessions/order`), and a spawn appends, so a new
/// session arrives at the bottom rather than displacing anything.
pub sessions: Vec<SessionConfig>,
/// What a new session's thinking level is when nothing chose one.
///
@@ -647,25 +651,6 @@ pub struct SessionConfig {
#[serde(default, skip_serializing_if = "not_set")]
pub throwaway: bool,
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
@@ -881,7 +866,6 @@ mod tests {
resume: None,
throwaway: false,
created: 1234.5,
started: Some(2345.5),
}],
};
config.save(&path).expect("save");
@@ -892,8 +876,6 @@ mod tests {
// 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.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!(
loaded
.machine("vm")
+25
View File
@@ -39,6 +39,9 @@
//! GET /sessions list (id, provider, title, model, status, last activity)
//! GET /sessions/{id} one session, for refetching after a change
//! POST /sessions spawn {machine, provider, title?, model?, cwd?, params?}
//! POST /sessions/order {sessions} -- the list in the order it is drawn in,
//! as the reader dragged it; ids left out keep their
//! places at the end
//! GET /sessions/{id}/events?after=N SSE: backlog after N, then live
//! (a backlog past CATCH_UP_LIMIT arrives as a
//! `reset` frame plus the newest window)
@@ -188,6 +191,9 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
get(read_file).put(write_file).post(create_file),
)
.route("/sessions", get(list_sessions).post(spawn_session))
// Before the `{id}` route below only in reading order -- a static
// segment wins over a parameter whichever order they are added in.
.route("/sessions/order", post(reorder_sessions))
.route("/sessions/{id}", get(read_session).delete(delete_session))
.route("/sessions/{id}/events", get(events))
.route("/sessions/{id}/transcript", get(transcript))
@@ -1742,6 +1748,25 @@ async fn usage(
Ok(axum::Json(snapshots))
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct OrderRequest {
sessions: Vec<String>,
}
/// The order the list is drawn in, which is the reader's to choose -- see
/// [`SessionManager::reorder_sessions`] for what an id this server does not
/// know, or one the caller left out, does.
async fn reorder_sessions(
State(manager): State<Arc<SessionManager>>,
axum::Json(body): axum::Json<OrderRequest>,
) -> Result<StatusCode, ApiError> {
manager
.reorder_sessions(&body.sessions)
.map_err(ApiError::Internal)?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct TitleRequest {
+58 -61
View File
@@ -266,10 +266,6 @@ pub struct SessionInfo {
pub status: SessionStatus,
pub last_activity: 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.
/// Absent until it reports one; absence is not a measured zero.
#[serde(skip_serializing_if = "Option::is_none")]
@@ -622,7 +618,6 @@ impl LiveSession {
status: *self.shared.status.lock().unwrap(),
last_activity: *self.shared.last_activity.lock().unwrap(),
created: self.meta.created,
started: current.started_at(),
background_tasks: self
.driver()
.and_then(|driver| driver.background_tasks())
@@ -1179,7 +1174,6 @@ impl SessionManager {
status: status_of_unlaunched(&self.data_dir.join(&meta.id)),
last_activity: meta.created,
created: meta.created,
started: meta.started_at(),
background_tasks: None,
subagents: subagent::count(&self.data_dir.join(&meta.id)),
},
@@ -1371,9 +1365,6 @@ impl SessionManager {
// do with it -- see `SessionConfig::throwaway`.
throwaway: self.spawn_throwaway,
created,
// The spawn starts a process, so this session is turned on from
// the moment it exists.
started: Some(created),
};
let session = launch(
@@ -1722,6 +1713,35 @@ impl SessionManager {
})
}
/// Puts the session list in the order given, which is the order every
/// screen then draws: the reader's arrangement, held on the server so it
/// is the same on every device and survives a backend restart.
///
/// The request is the whole list as one phone last saw it, and it is
/// applied as a preference rather than as a replacement: an id naming a
/// session that is no longer here is ignored, and a session the caller
/// did not name keeps its relative place at the end -- which is where a
/// spawn puts one anyway. That is what makes a drag safe against a list
/// that changed under it, for which the alternative was refusing the drag
/// somebody has already made and watching the row spring back.
pub fn reorder_sessions(&self, order: &[String]) -> Result<()> {
let mut inner = self.inner.write().unwrap();
let rank: HashMap<&str, usize> = order
.iter()
.enumerate()
.map(|(at, id)| (id.as_str(), at))
.collect();
let mut candidate = inner.config.clone();
// Stable, so the sessions with no rank of their own stay in the order
// they are already in rather than in whatever order a sort leaves.
candidate
.sessions
.sort_by_key(|meta| rank.get(meta.id.as_str()).copied().unwrap_or(usize::MAX));
candidate.save(&self.config_path)?;
inner.config = candidate;
Ok(())
}
/// Changes a session's model: persisted, so a respawn keeps it and the
/// list shows it, and handed to the driver, which switches in place
/// where its dialect can. Through the manager rather than the session,
@@ -2071,18 +2091,6 @@ impl SessionManager {
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
// reports the session idle itself, in order with everything else it
// says about that process. Saying it here too would be a second
@@ -4429,13 +4437,14 @@ mod tests {
.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.
/// The order of the list is the reader's own, kept in the config, so it
/// has to survive a backend restart: a phone reopening the tab must see
/// the arrangement somebody dragged rather than the order things were
/// spawned in. The ids left out of the request are the other half -- a
/// session spawned on another device between the fetch and the drag is
/// not in what the phone sent, and must not be dropped by it.
#[tokio::test]
async fn starting_a_session_again_records_when_it_was_turned_on() {
async fn the_order_sessions_are_dragged_into_outlives_the_backend() {
let dir = tempfile::tempdir().expect("tempdir");
let config_path = dir.path().join("config.ron");
let data_dir = dir.path().join("sessions");
@@ -4446,47 +4455,35 @@ mod tests {
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 first = manager.spawn_session(echo_spec()).expect("spawn");
let second = manager.spawn_session(echo_spec()).expect("spawn");
let third = manager.spawn_session(echo_spec()).expect("spawn");
let ids = |manager: &SessionManager| -> Vec<String> {
manager
.sessions()
.into_iter()
.map(|session| session.id)
.collect()
};
assert_eq!(
ids(&manager),
vec![first.id.clone(), second.id.clone(), third.id.clone()],
"a spawn belongs at the bottom of the list",
);
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;
manager
.reorder_sessions(&[third.id.clone(), first.id.clone()])
.expect("reorder");
let expected = vec![third.id.clone(), first.id.clone(), second.id.clone()];
assert_eq!(ids(&manager), expected);
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",
ids(&manager),
expected,
"the restart lost the reader's order"
);
}