From 12ce9f3f8edc84064458b649e8615247641dad36 Mon Sep 17 00:00:00 2001 From: Igor Soarez Date: Fri, 23 Oct 2026 17:58:24 +0300 Subject: 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. --- crates/dinggerd/src/main.rs | 3 +- crates/dinggerd/src/watcher.rs | 83 ++++++++++++++++++++++++++++-------------- menubar/main.swift | 9 +++++ menubar/model.swift | 19 +++++++--- tests/e2e.sh | 10 ++++- 5 files changed, 87 insertions(+), 37 deletions(-) diff --git a/crates/dinggerd/src/main.rs b/crates/dinggerd/src/main.rs index 9606d5b..2783899 100644 --- a/crates/dinggerd/src/main.rs +++ b/crates/dinggerd/src/main.rs @@ -253,8 +253,7 @@ fn status() -> Result<(), String> { } panes.sort_by_key(|pane| { let status = watcher::Status::from_label(&pane.written_state); - let since = pane.written_since.parse::().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); diff --git a/crates/dinggerd/src/watcher.rs b/crates/dinggerd/src/watcher.rs index 346bfa8..8951623 100644 --- a/crates/dinggerd/src/watcher.rs +++ b/crates/dinggerd/src/watcher.rs @@ -20,7 +20,7 @@ //! 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 @@ pub fn notification_kind_for_transition( 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, } @@ -133,12 +134,7 @@ impl PaneTrack { "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,20 +161,28 @@ pub fn advance( } 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) -> (std::cmp::Reverse, 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 { tracks: HashMap, snapshots: crate::snapshot::Recorder, @@ -363,7 +367,9 @@ impl Watcher { 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 @@ mod tests { 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 @@ mod tests { #[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 @@ mod tests { 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 @@ mod tests { #[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 @@ mod tests { } #[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 @@ mod tests { 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] diff --git a/menubar/main.swift b/menubar/main.swift index 0d367b8..17b9fcc 100644 --- a/menubar/main.swift +++ b/menubar/main.swift @@ -388,6 +388,15 @@ if arguments.contains("--selftest") { if Hotkey.parse("cmd+shift+f12")?.display != "⇧⌘F12" { failures.append("hotkey: parse cmd+shift+f12") } if Hotkey.parse(Settings().jumpHotkey)?.display != "⌃⌥⌘U" { failures.append("hotkey: default jump is ⌃⌥⌘U") } if Hotkey.parse(Settings().searchHotkey)?.display != "⌃⌥⇧⌘U" { failures.append("hotkey: default search is ⌃⌥⇧⌘U") } + let order: [(String, Status, Int)] = [ + ("idle old", .idle, 10), ("done new", .done, 90), ("idle unknown", .idle, Int.max), + ("working old", .working, 20), ("idle new", .idle, 80), ("done old", .done, 30), + ("blocked", .blocked, 99), ("working new", .working, 70), + ] + let listed = order.map { Row(paneId: $0.0, agent: "pi", name: $0.0, status: $0.1, session: "s", window: "w", + since: $0.2, path: "~") }.sorted(by: listedBefore).map(\.name) + let expected = ["blocked", "done old", "done new", "working new", "working old", "idle new", "idle old", "idle unknown"] + if listed != expected { failures.append("order: waiting oldest first, the rest most recent first: \(listed)") } failures.forEach { print("FAIL \($0)") } print(failures.isEmpty ? "selftest ok" : "selftest: \(failures.count) failed") exit(failures.isEmpty ? 0 : 1) diff --git a/menubar/model.swift b/menubar/model.swift index da5ad15..a17b009 100644 --- a/menubar/model.swift +++ b/menubar/model.swift @@ -219,7 +219,9 @@ func runDinggerInLoginShell(_ args: [String]) -> (ok: Bool, output: String) { private let home = NSHomeDirectory() -/// Every agent pane, in herdr's priority order, oldest waiting first. +/// Every agent pane: herdr's status priority; within a status, done and +/// blocked oldest first (the queue), working and idle most recent first +/// (`@dingger_since`: when the pane entered its status); then by place. func fetchRows() -> [Row] { let format = [ "#{pane_id}", "#{@agent}", "#{@agent_state}", "#{@dingger_since}", "#{@agent_name}", @@ -236,11 +238,18 @@ func fetchRows() -> [Row] { session: f[5], window: window, since: Int(f[3]) ?? Int.max, path: f[9].hasPrefix(home) ? "~" + f[9].dropFirst(home.count) : f[9]) } - return rows.sorted { - if $0.status.priority != $1.status.priority { return $0.status.priority > $1.status.priority } - if $0.since != $1.since { return $0.since < $1.since } - return $0.location < $1.location + return rows.sorted(by: listedBefore) +} + +/// The list order (see `fetchRows`). +func listedBefore(_ a: Row, _ b: Row) -> Bool { + if a.status.priority != b.status.priority { return a.status.priority > b.status.priority } + if a.since != b.since { + // Int.max: never stamped; last either way. + if a.since == Int.max || b.since == Int.max { return a.since < b.since } + return a.waiting ? a.since < b.since : a.since > b.since } + return a.location < b.location } /// Show the pane and mark it attended; the same code path as the tmux diff --git a/tests/e2e.sh b/tests/e2e.sh index c4bcabb..670f84a 100755 --- a/tests/e2e.sh +++ b/tests/e2e.sh @@ -248,7 +248,11 @@ if [ -x "$MENUBAR" ]; then 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 @@ if [ -x "$MENUBAR" ]; then 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 -- cgit v1.2.3