merge origin/blitz/command-bar into command-bar/147-rust-matcher

This commit is contained in:
Miguel Palhas
2026-09-03 16:23:45 +01:00
25 changed files with 2292 additions and 194 deletions
+1
View File
@@ -251,6 +251,7 @@ mod tests {
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
last_focused_at: None,
}
}
+59 -25
View File
@@ -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<u8>, broadcast::Receiver<Vec<u8>>)> {
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(())
}
}
+35
View File
@@ -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<Session> = 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;
@@ -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);
}
+1
View File
@@ -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,
}
}
+1
View File
@@ -248,6 +248,7 @@ mod tests {
activity_since: Utc::now(),
current_cwd: None,
blocked_work: None,
last_focused_at: None,
}
}
+31
View File
@@ -108,6 +108,14 @@ pub struct Session {
/// from daemons that predate the field stay forward-compatible.
#[serde(default)]
pub blocked_work: Option<InTheWay>,
/// 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<DateTime<Utc>>,
}
/// 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
+1
View File
@@ -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,
}
}
+5
View File
@@ -1670,6 +1670,7 @@ mod tests {
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
last_focused_at: None,
})
.collect(),
});
@@ -1850,6 +1851,7 @@ mod tests {
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
last_focused_at: None,
}],
});
app.dispatch(Action::SidebarNext);
@@ -1876,6 +1878,7 @@ mod tests {
activity_since: Utc::now(),
current_cwd: None,
blocked_work: None,
last_focused_at: None,
}
}
@@ -2737,6 +2740,7 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
}],
});
app.dispatch(Action::KillHighlighted);
@@ -2771,6 +2775,7 @@ mod tests {
activity_since: Utc::now(),
current_cwd: None,
blocked_work: None,
last_focused_at: None,
}],
});
}
+1
View File
@@ -222,6 +222,7 @@ mod tests {
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
last_focused_at: None,
}
}
+2
View File
@@ -499,6 +499,7 @@ mod tests {
activity_since: Utc::now(),
current_cwd: None,
blocked_work: None,
last_focused_at: None,
}
}
@@ -1264,6 +1265,7 @@ mod tests {
activity_since: Utc::now(),
current_cwd: None,
blocked_work: None,
last_focused_at: None,
};
let sb = Sidebar {
sessions: vec![s],