From 766d2569707149be6bdf03d7736b3e89d1541163 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 19:57:58 +0100 Subject: [PATCH] fix(maestro): suppress epoch durations on TUI cards --- crates/maestro-tui/src/sidebar.rs | 70 ++++++++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 6 deletions(-) diff --git a/crates/maestro-tui/src/sidebar.rs b/crates/maestro-tui/src/sidebar.rs index 9e2bda0..4a02ca2 100644 --- a/crates/maestro-tui/src/sidebar.rs +++ b/crates/maestro-tui/src/sidebar.rs @@ -19,6 +19,12 @@ use uuid::Uuid; /// Time-in-state alone earns a meta line only once the session has sat in /// its current activity state this long (docs/SIDEBAR.md). const META_LINE_AFTER_SECS: i64 = 30; +/// A payload predating #102 defaults `activity_since` to the Unix epoch. Per +/// docs/SIDEBAR.md such a session contributes no duration — the dot still +/// renders — so anything implausibly old counts as "never observed" rather +/// than a card announcing that the session has been idle for half a century. +/// Mirrors `PLAUSIBLE_EPOCH_MS` in `web/src/activity.ts`. +const PLAUSIBLE_SINCE_YEAR: i32 = 2010; const INDENT: &str = " "; const META_INDENT: &str = " "; /// Spawn-similar and kill, shown on the highlighted card's top-right corner. @@ -129,25 +135,31 @@ impl Sidebar { /// The adaptive second line: ` [ | exited]`. fn meta_row(&self, s: &Session, width: usize) -> Option>> { let now = Utc::now(); - let held_secs = (now - s.activity_since).num_seconds(); + let held = held_for(s, now); + let held_secs = held.map(|d| d.num_seconds()).unwrap_or(0); // #83: a worktree that blocks a kill is summarised here rather than // on line 1; the kill confirm names the specifics. let blocked = s.blocked_work.filter(|b| !b.is_clear()); let dead = s.status == SessionStatus::Dead; let errored = s.activity == ActivityState::Error; - if held_secs < META_LINE_AFTER_SECS && blocked.is_none() && !dead && !errored { + if (held.is_none() || held_secs < META_LINE_AFTER_SECS) + && blocked.is_none() + && !dead + && !errored + { return None; } let base = card_base(self.highlight == Some(s.id)); let dot_style = base.patch(Style::default().fg(activity_color(s.activity))); let muted = Style::default().bg(base.bg.unwrap_or(Color::Reset)); - let duration = format_state_duration(now - s.activity_since); let mut spans = vec![ Span::styled(META_INDENT, base), Span::styled("●", dot_style), - Span::styled(" ", base), - Span::styled(duration, dot_style), ]; + if let Some(d) = held { + spans.push(Span::styled(" ", base)); + spans.push(Span::styled(format_state_duration(d), dot_style)); + } if let Some(b) = blocked { spans.push(Span::styled(format!(" ⚠ {b}"), muted)); } @@ -171,6 +183,16 @@ fn activity_color(state: ActivityState) -> Color { /// docs/SIDEBAR.md duration format — floored whole seconds / minutes / hours, /// measured in the current activity state. No days, no rounding up. +/// How long the session has held its current activity state, or `None` when +/// the daemon never reported one (see [`PLAUSIBLE_SINCE_YEAR`]). +fn held_for(s: &Session, now: chrono::DateTime) -> Option { + use chrono::Datelike; + if s.activity_since.year() < PLAUSIBLE_SINCE_YEAR { + return None; + } + Some(now - s.activity_since) +} + fn format_state_duration(elapsed: chrono::Duration) -> String { let secs = elapsed.num_seconds().max(0); match secs { @@ -228,7 +250,7 @@ fn name_for(s: &Session) -> String { #[cfg(test)] mod tests { use super::*; - use chrono::Duration; + use chrono::{DateTime, Duration}; use maestro_protocol::InTheWay; use ratatui::backend::TestBackend; use ratatui::buffer::Buffer; @@ -508,6 +530,42 @@ mod tests { ); } + /// docs/SIDEBAR.md: a payload predating #102 carries an epoch + /// `activity_since` and so contributes no duration — a card must not claim + /// the session has been idle since 1970. The dot still renders when some + /// other trigger puts line 2 on screen. + #[test] + fn epoch_activity_since_renders_no_duration() { + let mut legacy = session_named(1, "claude"); + legacy.activity_since = DateTime::from_timestamp(0, 0).unwrap(); + legacy.status = SessionStatus::Dead; + let sb = Sidebar { + sessions: vec![legacy], + highlight: None, + attached: Default::default(), + }; + let rendered = render_to_string(&draw(&sb, 40, 8)); + assert!( + rendered.contains("exited"), + "dead session must still show line 2:\n{rendered}" + ); + assert!( + !rendered.contains('h') || !rendered.contains("● "), + "no duration may follow the dot for an epoch stamp:\n{rendered}" + ); + for line in rendered.lines().filter(|l| l.contains('●')) { + assert!( + !line + .split('●') + .nth(1) + .unwrap_or("") + .trim_start() + .starts_with(char::is_numeric), + "duration rendered from an epoch stamp: {line:?}" + ); + } + } + #[test] fn falls_back_to_command_when_name_metadata_is_absent() { let s = Session {