diff --git a/crates/maestro-cli/src/sessions.rs b/crates/maestro-cli/src/sessions.rs index 2b83457..cac1729 100644 --- a/crates/maestro-cli/src/sessions.rs +++ b/crates/maestro-cli/src/sessions.rs @@ -251,6 +251,7 @@ mod tests { activity_since: chrono::Utc::now(), current_cwd: None, blocked_work: None, + last_focused_at: None, } } diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index ecfea91..d59f92e 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -285,33 +285,50 @@ impl SessionManager { /// Subscribe to a session's PTY broadcast and return the replay buffer /// (NAP-229). Callers send the buffer before pumping the live receiver /// so freshly-attached clients see the last screenful immediately. + /// + /// Opening an attach is focusing (#145): it stamps + /// [`Session::last_focused_at`], the same field `set_focused` stamps, so + /// a client that only ever attaches sorts identically to one that + /// reports focus explicitly. pub async fn subscribe_pty_with_replay( &self, id: Uuid, ) -> Option<(Vec, broadcast::Receiver>)> { let tmux = self.inner.tmux.clone(); - let sessions = self.inner.sessions.lock().await; - let rec = sessions.get(&id)?; - // For a live session, tmux's own screen beats the byte tail: the tail - // starts mid-stream, so a full-screen program (an agent TUI) replayed - // from it renders as garbage until something forces a repaint. A dead - // session has no pane left to capture, so it keeps the byte tail. - let alive = rec.session.status == SessionStatus::Running; - // The capture shells out to tmux while the replay lock is held, which - // parks the reader for the length of one tmux round-trip. That only - // happens on attach, and the alternative is a seam in the stream. - Some(rec.bridge.subscribe_with_snapshot(|| { - if !alive { - return None; - } - match tmux.capture_pane(id) { - Ok(snapshot) => snapshot, - Err(e) => { - tracing::warn!(error = ?e, %id, "capture-pane failed; replaying raw bytes"); - None + let (result, updated) = { + let mut sessions = self.inner.sessions.lock().await; + let rec = sessions.get_mut(&id)?; + rec.session.last_focused_at = Some(Utc::now()); + let updated = rec.session.clone(); + // For a live session, tmux's own screen beats the byte tail: the + // tail starts mid-stream, so a full-screen program (an agent TUI) + // replayed from it renders as garbage until something forces a + // repaint. A dead session has no pane left to capture, so it + // keeps the byte tail. + let alive = rec.session.status == SessionStatus::Running; + // The capture shells out to tmux while the replay lock is held, + // which parks the reader for the length of one tmux round-trip. + // That only happens on attach, and the alternative is a seam in + // the stream. + let result = rec.bridge.subscribe_with_snapshot(|| { + if !alive { + return None; } - } - })) + match tmux.capture_pane(id) { + Ok(snapshot) => snapshot, + Err(e) => { + tracing::warn!(error = ?e, %id, "capture-pane failed; replaying raw bytes"); + None + } + } + }); + (result, updated) + }; + let _ = self + .inner + .events + .send(CatalogEvent::SessionUpdated { session: updated }); + Some(result) } pub async fn has(&self, id: Uuid) -> bool { @@ -448,6 +465,7 @@ impl SessionManager { // The watcher fills this in on its first blocked-work pass; a // fresh tree has nothing in the way anyway. blocked_work: None, + last_focused_at: None, }; if let Err(e) = self.inner.sidecar.write(&session) { @@ -652,6 +670,7 @@ impl SessionManager { current_cwd: None, blocked_work: None, + last_focused_at: None, }, }; let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo")); @@ -838,11 +857,26 @@ impl SessionManager { /// Record whether a client's pane is currently showing `id`. Read by the /// activity pass's next tick to decide whether an `AwaitingInput` glow /// has been looked at long enough to clear (`apply_focus_suppression`). - /// An unknown session is [`SessionError::NotFound`]. + /// Gaining focus also stamps [`Session::last_focused_at`] (#145) — losing + /// it does not, since the command bar's ordering cares about the last + /// time the operator arrived, not when they left. An unknown session is + /// [`SessionError::NotFound`]. pub async fn set_focused(&self, id: Uuid, focused: bool) -> Result<(), SessionError> { - let mut sessions = self.inner.sessions.lock().await; - let rec = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?; - rec.focused_since = focused.then(std::time::Instant::now); + let updated = { + let mut sessions = self.inner.sessions.lock().await; + let rec = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?; + rec.focused_since = focused.then(std::time::Instant::now); + if focused { + rec.session.last_focused_at = Some(Utc::now()); + } + focused.then(|| rec.session.clone()) + }; + if let Some(session) = updated { + let _ = self + .inner + .events + .send(CatalogEvent::SessionUpdated { session }); + } Ok(()) } } diff --git a/crates/maestro-daemon/tests/local_api.rs b/crates/maestro-daemon/tests/local_api.rs index d46622c..9861796 100644 --- a/crates/maestro-daemon/tests/local_api.rs +++ b/crates/maestro-daemon/tests/local_api.rs @@ -383,6 +383,41 @@ async fn patch_unknown_session_returns_404() { assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND); } +/// #145: `POST /.../focus` with `focused: true` over HTTP stamps +/// `last_focused_at`, observable on the next `GET /api/sessions`. +#[tokio::test] +async fn post_focus_stamps_last_focused_at() { + let h = spawn("naps-laptop").await; + let session = h + .manager + .create(CreateSessionRequest { + cwd: "/tmp".into(), + command: "sleep 60".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: None, + }) + .await + .unwrap(); + assert_eq!(session.last_focused_at, None); + + let resp = reqwest::Client::new() + .post(url(h.addr, &format!("/api/sessions/{}/focus", session.id))) + .json(&serde_json::json!({ "focused": true })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT); + + let sessions: Vec = reqwest::get(url(h.addr, "/api/sessions")) + .await + .unwrap() + .json() + .await + .unwrap(); + assert!(sessions[0].last_focused_at.is_some()); +} + #[tokio::test] async fn delete_owned_session_kills_it() { let h = spawn("naps-laptop").await; diff --git a/crates/maestro-daemon/tests/session_manager.rs b/crates/maestro-daemon/tests/session_manager.rs index 49c6fd3..650dc34 100644 --- a/crates/maestro-daemon/tests/session_manager.rs +++ b/crates/maestro-daemon/tests/session_manager.rs @@ -1091,3 +1091,109 @@ async fn spawned_sessions_export_maestro_session_id_and_url() { fx.manager.kill(s.id, false).await.expect("kill"); } + +/// docs/COMMAND-BAR.md, empty-query ordering: a session that has never been +/// focused carries no `last_focused_at` at all, which is what lets a client +/// sort it after every session that has one (#145). +#[tokio::test] +async fn a_freshly_created_session_has_never_been_focused() { + let fx = DaemonFixture::new(); + let cwd = TempDir::new().unwrap(); + + let s = fx + .manager + .create(CreateSessionRequest { + cwd: cwd.path().to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: None, + }) + .await + .expect("create"); + + assert_eq!(s.last_focused_at, None); + assert_eq!(fx.manager.snapshot().await[0].last_focused_at, None); +} + +/// #145: `POST /.../focus` reporting `focused: true` stamps +/// `last_focused_at` and announces it on the event stream; reporting +/// `focused: false` (losing focus) leaves the stamp alone, since the bar +/// orders by when the operator last arrived, not when they left. +#[tokio::test] +async fn set_focused_true_stamps_last_focused_at() { + let fx = DaemonFixture::new(); + let cwd = TempDir::new().unwrap(); + + let s = fx + .manager + .create(CreateSessionRequest { + cwd: cwd.path().to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: None, + }) + .await + .expect("create"); + + let mut events = fx.manager.events(); + let before = chrono::Utc::now(); + fx.manager.set_focused(s.id, true).await.expect("focus"); + + let updated = fx.manager.snapshot().await[0].clone(); + let stamp = updated.last_focused_at.expect("stamped on focus"); + assert!(stamp >= before); + + let mut saw_update = false; + while let Ok(event) = tokio::time::timeout(Duration::from_millis(200), events.recv()).await { + if let Ok(CatalogEvent::SessionUpdated { session }) = event { + if session.id == s.id && session.last_focused_at == Some(stamp) { + saw_update = true; + break; + } + } + } + assert!(saw_update, "expected a SessionUpdated carrying the stamp"); + + fx.manager.set_focused(s.id, false).await.expect("unfocus"); + let after_unfocus = fx.manager.snapshot().await[0].clone(); + assert_eq!( + after_unfocus.last_focused_at, + Some(stamp), + "losing focus must not clear the last-focused stamp" + ); +} + +/// #145: opening `/api/attach/:id` is focusing too — it must stamp the same +/// field `set_focused` does, so a client that only ever attaches sorts +/// identically to one that reports focus explicitly. +#[tokio::test] +async fn attaching_stamps_last_focused_at() { + let fx = DaemonFixture::new(); + let cwd = TempDir::new().unwrap(); + + let s = fx + .manager + .create(CreateSessionRequest { + cwd: cwd.path().to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: None, + }) + .await + .expect("create"); + assert_eq!(s.last_focused_at, None); + + let before = chrono::Utc::now(); + let (_replay, _rx) = fx + .manager + .subscribe_pty_with_replay(s.id) + .await + .expect("known session"); + + let updated = fx.manager.snapshot().await[0].clone(); + let stamp = updated.last_focused_at.expect("attach stamps focus"); + assert!(stamp >= before); +} diff --git a/crates/maestro-daemon/tests/sidecar.rs b/crates/maestro-daemon/tests/sidecar.rs index 0274844..2934d3a 100644 --- a/crates/maestro-daemon/tests/sidecar.rs +++ b/crates/maestro-daemon/tests/sidecar.rs @@ -31,6 +31,7 @@ fn sample(id: Uuid) -> Session { activity_since: chrono::Utc::now(), current_cwd: Some(PathBuf::from("/tmp/work/crates")), blocked_work: None, + last_focused_at: None, } } diff --git a/crates/maestro-protocol/src/display.rs b/crates/maestro-protocol/src/display.rs index fe2deda..474949d 100644 --- a/crates/maestro-protocol/src/display.rs +++ b/crates/maestro-protocol/src/display.rs @@ -248,6 +248,7 @@ mod tests { activity_since: Utc::now(), current_cwd: None, blocked_work: None, + last_focused_at: None, } } diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index 696a28f..68fbeab 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -108,6 +108,14 @@ pub struct Session { /// from daemons that predate the field stay forward-compatible. #[serde(default)] pub blocked_work: Option, + /// Wall-clock time this session last became the pane a client was + /// showing (#145) — stamped by both `POST /.../focus` and opening + /// `/api/attach/:id`, since either one is the operator moving there. + /// `None` until the session has been focused at least once, which is + /// also how the command bar's empty-query ordering (`docs/COMMAND-BAR.md`) + /// sorts a never-focused session after every session that has one. + #[serde(default)] + pub last_focused_at: Option>, } /// Default for [`Session::last_activity_at`] when an older payload omits it. @@ -847,6 +855,7 @@ mod tests { .with_timezone(&Utc), current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")), blocked_work: None, + last_focused_at: None, } } @@ -896,6 +905,28 @@ mod tests { ); } + #[test] + fn last_focused_at_defaults_to_none_on_legacy_payload() { + // Pre-#145 payload omits the field; `None` means never-focused. + let json = serde_json::json!({ + "id": Uuid::from_u128(1), + "cwd": "/tmp", + "command": "bash", + "status": "Running", + "created_at": "2026-05-24T12:00:00Z" + }); + let s: Session = serde_json::from_value(json).unwrap(); + assert_eq!(s.last_focused_at, None); + + let mut focused = sample_session(); + focused.last_focused_at = Some( + DateTime::parse_from_rfc3339("2026-05-24T12:06:00Z") + .unwrap() + .with_timezone(&Utc), + ); + assert_eq!(focused, round_trip(&focused)); + } + #[test] fn activity_since_defaults_to_epoch_on_legacy_payload() { // A daemon predating #102 omits `activity_since`; it must deserialize diff --git a/crates/maestro-testing/src/lib.rs b/crates/maestro-testing/src/lib.rs index 0a1c535..5a192fa 100644 --- a/crates/maestro-testing/src/lib.rs +++ b/crates/maestro-testing/src/lib.rs @@ -46,6 +46,7 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session { activity_since: chrono::Utc::now(), current_cwd: None, blocked_work: None, + last_focused_at: None, } } diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 0870265..d8b2092 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -1665,6 +1665,7 @@ mod tests { activity_since: chrono::Utc::now(), current_cwd: None, blocked_work: None, + last_focused_at: None, }) .collect(), }); @@ -1845,6 +1846,7 @@ mod tests { activity_since: chrono::Utc::now(), current_cwd: None, blocked_work: None, + last_focused_at: None, }], }); app.dispatch(Action::SidebarNext); @@ -1871,6 +1873,7 @@ mod tests { activity_since: Utc::now(), current_cwd: None, blocked_work: None, + last_focused_at: None, } } @@ -2732,6 +2735,7 @@ mod tests { current_cwd: None, blocked_work: None, + last_focused_at: None, }], }); app.dispatch(Action::KillHighlighted); @@ -2766,6 +2770,7 @@ mod tests { activity_since: Utc::now(), current_cwd: None, blocked_work: None, + last_focused_at: None, }], }); } diff --git a/crates/maestro-tui/src/catalog.rs b/crates/maestro-tui/src/catalog.rs index ec589b2..36f39d5 100644 --- a/crates/maestro-tui/src/catalog.rs +++ b/crates/maestro-tui/src/catalog.rs @@ -222,6 +222,7 @@ mod tests { activity_since: chrono::Utc::now(), current_cwd: None, blocked_work: None, + last_focused_at: None, } } diff --git a/crates/maestro-tui/src/sidebar.rs b/crates/maestro-tui/src/sidebar.rs index 6689ae2..b217dd1 100644 --- a/crates/maestro-tui/src/sidebar.rs +++ b/crates/maestro-tui/src/sidebar.rs @@ -461,6 +461,7 @@ mod tests { activity_since: Utc::now(), current_cwd: None, blocked_work: None, + last_focused_at: None, } } @@ -1123,6 +1124,7 @@ mod tests { activity_since: Utc::now(), current_cwd: None, blocked_work: None, + last_focused_at: None, }; let sb = Sidebar { sessions: vec![s],