list working and idle agents most recent first within a status, the menu, panel, search, and `dingger status` listed done and blocked agents oldest first (the queue), and the rest by place. now working and idle agents are most recent first: the first idle row is the agent that finished, or that the user looked at, last. @dingger_since now means when the pane entered its current status, for every status (it was set only while the pane needed the user), and survives a daemon restart. the queue selects done and blocked panes by status, no longer by having a stamp. one ordering rule each in Rust (watcher::list_order) and Swift (listedBefore), with tests that fail on the old order; e2e step 11 makes an agent finish while watched and checks it moves to the top of the idle rows. agents idle since before this change share one stamp and keep their place order until they next change.
@@ -253,8 +253,7 @@ return Ok(());
} panes.sort_by_key(|pane| { let status = watcher::Status::from_label(&pane.written_state); - let since = pane.written_since.parse::<u64>().unwrap_or(u64::MAX); - (std::cmp::Reverse(status.priority()), since, pane.display_target.clone()) + (watcher::list_order(status, pane.written_since.parse().ok()), pane.display_target.clone()) }); let place = |pane: &tmux::Pane| pane.display_target.replacen(':', " · ", 1); let width = panes.iter().map(|pane| place(pane).chars().count()).max().unwrap_or(5).max(5);
@@ -20,7 +20,7 @@ //! All per-pane state lives in tmux pane options, so a daemon restart picks
//! up exactly where it left off: //! //! - `@agent_state` display status (what every surface renders) -//! - `@dingger_since` unix ms the pane started needing the user (FIFO) +//! - `@dingger_since` unix ms the pane entered its current status //! - `@dingger_hook` last state an agent extension reported //! - `@dingger_ack` one-shot "the user jumped here" event from `dingger focus` //! - `@agent_session` the agent's session reference, for restore@@ -100,8 +100,9 @@ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PaneTrack { pub state: AgentState, pub seen: bool, - /// Unix millisecond the pane started needing the user (done or blocked); - /// orders the FIFO queue within a status. + /// Unix millisecond the pane entered its current status: the queue + /// serves done and blocked oldest first; working and idle are listed + /// most recent first. pub since: Option<u64>, }@@ -133,12 +134,7 @@ "working" => (AgentState::Working, true),
"blocked" => (AgentState::Blocked, true), _ => return PaneTrack::NEW, }; - let track = PaneTrack { state, seen, since }; - let needs_user = matches!(track.status(), Status::Done | Status::Blocked); - PaneTrack { - since: if needs_user { since } else { None }, - ..track - } + PaneTrack { state, seen, since } } }@@ -165,18 +161,26 @@ next.seen = true;
} else if matches!(prev.state, AgentState::Working | AgentState::Blocked) { next.seen = attended; } - next.since = None; // a new episode } if attended || acked { next.seen = true; } - let needs_user = matches!(next.status(), Status::Done | Status::Blocked); - next.since = match (needs_user, next.since) { - (true, Some(since)) => Some(since), - (true, None) => Some(now), - (false, _) => None, - }; + if next.status() != prev.status() || next.since.is_none() { + next.since = Some(now); + } next +} + +/// The order agents are listed in: herdr's status priority; within a +/// status, done and blocked oldest first (the queue: nothing waits +/// forever), working and idle most recent first; then by place. +pub fn list_order(status: Status, since: Option<u64>) -> (std::cmp::Reverse<u8>, i128) { + let since = since.map(i128::from); + let key = match status { + Status::Done | Status::Blocked => since.unwrap_or(i128::MAX), + _ => since.map(|since| -since).unwrap_or(i128::MAX), + }; + (std::cmp::Reverse(status.priority()), key) } pub struct Watcher {@@ -363,7 +367,9 @@ Status::Done => done += 1,
Status::Working => working += 1, Status::Idle | Status::Unknown => {} } - if let Some(since) = track.since { + // The queue: what needs the user, oldest first. + if matches!(track.status(), Status::Blocked | Status::Done) { + let since = track.since.unwrap_or(u64::MAX); queue.push((std::cmp::Reverse(track.status().priority()), since, pane.pane_id.as_str())); } }@@ -498,7 +504,7 @@ #[test]
fn first_observation_of_idle_is_idle() { let track = step(PaneTrack::NEW, Idle, false); assert_eq!(track.status(), Status::Idle); - assert_eq!(track.since, None); + assert_eq!(track.since, Some(100)); } // herdr: state_changed_idle_in_background_marks_unseen@@ -515,9 +521,9 @@ // herdr: active_tab_completion_marks_pane_seen
#[test] fn completion_while_looking_is_idle() { let working = step(PaneTrack::NEW, Working, true); - let idle = step(working, Idle, true); + let idle = advance(working, Idle, true, false, 150); assert_eq!(idle.status(), Status::Idle); - assert_eq!(idle.since, None); + assert_eq!(idle.since, Some(150), "idle since the turn ended"); } // herdr: switch_workspace_marks_panes_seen (mark_active_tab_seen)@@ -527,9 +533,9 @@ let done = step(step(PaneTrack::NEW, Working, false), Idle, false);
assert_eq!(done.status(), Status::Done); let still_done = step(done, Idle, false); assert_eq!(still_done.status(), Status::Done); - let seen = step(still_done, Idle, true); + let seen = advance(still_done, Idle, true, false, 170); assert_eq!(seen.status(), Status::Idle); - assert_eq!(seen.since, None); + assert_eq!(seen.since, Some(170), "idle since the user looked"); } // herdr: agent_focus_marks_already_focused_done_agent_seen@@ -543,9 +549,9 @@
#[test] fn working_again_clears_done_and_the_next_completion_is_done_again() { let done = step(step(PaneTrack::NEW, Working, false), Idle, false); - let working = step(done, Working, false); + let working = advance(done, Working, false, false, 250); assert_eq!(working.status(), Status::Working); - assert_eq!(working.since, None); + assert_eq!(working.since, Some(250)); let done_again = advance(working, Idle, false, false, 300); assert_eq!(done_again.status(), Status::Done); assert_eq!(done_again.since, Some(300));@@ -562,10 +568,31 @@ assert_eq!(done.status(), Status::Done);
} #[test] - fn fifo_stamp_survives_unchanged_ticks() { + fn the_stamp_survives_unchanged_ticks() { let done = advance(step(PaneTrack::NEW, Working, false), Idle, false, false, 50); - let later = advance(done, Idle, false, false, 999); - assert_eq!(later.since, Some(50)); + assert_eq!(advance(done, Idle, false, false, 999).since, Some(50)); + let idle = advance(done, Idle, true, false, 60); + assert_eq!(advance(idle, Idle, true, false, 999).since, Some(60)); + } + + #[test] + fn waiting_oldest_first_the_rest_most_recent_first() { + let mut rows = [ + ("idle old", Status::Idle, Some(10)), + ("done new", Status::Done, Some(90)), + ("idle unknown", Status::Idle, None), + ("working old", Status::Working, Some(20)), + ("idle new", Status::Idle, Some(80)), + ("done old", Status::Done, Some(30)), + ("blocked", Status::Blocked, Some(99)), + ("working new", Status::Working, Some(70)), + ]; + rows.sort_by_key(|(_, status, since)| list_order(*status, *since)); + let names: Vec<_> = rows.iter().map(|(name, ..)| *name).collect(); + assert_eq!( + names, + ["blocked", "done old", "done new", "working new", "working old", "idle new", "idle old", "idle unknown"] + ); } #[test]@@ -583,7 +610,7 @@ // the same observation after a restart keeps it done
assert_eq!(step(done, Idle, false).status(), Status::Done); assert_eq!(PaneTrack::restore("idle", "").status(), Status::Idle); assert_eq!(PaneTrack::restore("", "").status(), Status::Unknown); - assert_eq!(PaneTrack::restore("idle", "42").since, None); + assert_eq!(PaneTrack::restore("idle", "42").since, Some(42), "recency survives a restart"); } #[test]
@@ -248,7 +248,11 @@ expect "client back on wb1" "client_window" wb1
hook "$A1" working; hook "$A1" idle expect "A1 done" "opt $A1 @agent_state" done expect "menu title" "mb --print | head -1" "title: ×1 ✓1" - expect "menu rows: herdr priority, names as titles, harness when unnamed" \ + expect "menu rows: herdr priority; idle most recent first (C1 since its jump in step 4, B1 since the start)" \ + "mb --print | tail -n +2 | cut -f1,2 | tr '\t\n' '|;'" "× blocked|pi;✓ done|Deploy the widget;○ idle|pi;○ idle|Review PR 42;" + hook "$B1" working; hook "$B1" idle + expect "B1 finishes a turn while watched: idle" "opt $B1 @agent_state" idle + expect "and is now the most recent idle agent: listed first" \ "mb --print | tail -n +2 | cut -f1,2 | tr '\t\n' '|;'" "× blocked|pi;✓ done|Deploy the widget;○ idle|Review PR 42;○ idle|pi;" expect "subtitle: location, harness, waiting time" "mb --print | grep 'Deploy the widget' | cut -f3" "a · wa1 · pi · done now" expect "idle subtitle has no waiting time" "mb --print | grep 'Review PR 42' | cut -f3" "b · wb1 · pi"@@ -302,9 +306,11 @@ expect "panel summarizes the rest" "mb --print-panel | tail -1" "○ 3 idle"
DINGGER_TMUX_SOCKET="$SOCK" "$DINGGER" focus --client "$CLIENT" --pane "$A2" expect "A2 idle after the jump" "opt $A2 @agent_state" idle expect "panel with nothing waiting" "mb --print-panel" "Nothing waiting · ○ 4 idle" + # Most recent first: A2 (just jumped to), A1 (clicked in step 11), B1 (finished + # in step 11, before that click), C1. expect "full list: every agent, then the summary with the toggle" \ "mb --print-panel --full | cut -f1,2 | tr '\t\n' '|;'" \ - "○ idle|Deploy the widget;○ idle|pi;○ idle|Review PR 42;○ idle|pi;Nothing waiting · ○ 4 idle|[Show less ▴];" + "○ idle|pi;○ idle|Deploy the widget;○ idle|Review PR 42;○ idle|pi;Nothing waiting · ○ 4 idle|[Show less ▴];" expect "compact in Always mode offers 'Show all'" "mb --print-panel --toggle" "Nothing waiting · ○ 4 idle [Show all ▾]" hook "$A1" working; hook "$A1" idle expect "A1 done" "opt $A1 @agent_state" done