diff --git a/crates/colibri-glasspane-tui/src/main.rs b/crates/colibri-glasspane-tui/src/main.rs index bc5b8ec..73b4c9f 100644 --- a/crates/colibri-glasspane-tui/src/main.rs +++ b/crates/colibri-glasspane-tui/src/main.rs @@ -178,6 +178,23 @@ impl App { } } + /// Total items in the session cycle: the synthetic "All sessions" entry + /// plus one per real session id. Always >= 1 — "All" is always present. + fn session_count(&self) -> usize { + self.sessions.len() + 1 + } + + /// Map the current `session_idx` to a filter. Index 0 is the synthetic + /// "All sessions" aggregated view (filter = None); any other index scopes + /// to the session id at `index - 1`. Call after every `session_idx` change. + fn apply_session_filter(&mut self) { + self.session_filter = if self.session_idx == 0 { + None + } else { + self.sessions.get(self.session_idx - 1).cloned() + }; + } + fn rebuild_session_list(&mut self) { let snap = match &self.snapshot { Some(s) => s, @@ -195,17 +212,15 @@ impl App { .collect(); ids.sort(); ids.dedup(); - if ids.is_empty() { - self.sessions.clear(); + self.sessions = ids; + // The cycle is [All, s1, s2, ...] = sessions.len() + 1. Keep the + // previous selection when it still maps; otherwise fall back to "All". + // Default on first connect is "All sessions" (the aggregated view). + let count = self.session_count(); + if self.session_idx >= count { self.session_idx = 0; - self.session_filter = None; - } else { - self.sessions = ids; - if self.session_idx >= self.sessions.len() { - self.session_idx = self.sessions.len().saturating_sub(1); - } - self.session_filter = self.sessions.get(self.session_idx).cloned(); } + self.apply_session_filter(); } async fn refresh(&mut self) { @@ -312,12 +327,12 @@ impl App { Some(sid) => format!("Session: {sid}"), None => "All sessions".to_string(), }; - let session_span = if self.sessions.len() > 1 { + let session_span = if self.session_count() > 1 { Span::styled( format!( "{session_label} ({} of {})", self.session_idx + 1, - self.sessions.len() + self.session_count() ), Style::default().add_modifier(Modifier::BOLD), ) @@ -638,30 +653,24 @@ async fn run(socket_path: PathBuf) -> io::Result<()> { } } KeyCode::Tab | KeyCode::Char('\t') if !app.sessions.is_empty() => { - app.session_idx = (app.session_idx + 1) % app.sessions.len(); - app.session_filter = app.sessions.get(app.session_idx).cloned(); + let count = app.session_count(); + app.session_idx = (app.session_idx + 1) % count; + app.apply_session_filter(); app.table_state.select(Some(0)); app.detail_pane = None; - app.set_status(format!( - "session {}/{}", - app.session_idx + 1, - app.sessions.len() - )); + app.set_status(format!("session {}/{}", app.session_idx + 1, count)); } KeyCode::BackTab if !app.sessions.is_empty() => { + let count = app.session_count(); app.session_idx = if app.session_idx == 0 { - app.sessions.len() - 1 + count - 1 } else { app.session_idx - 1 }; - app.session_filter = app.sessions.get(app.session_idx).cloned(); + app.apply_session_filter(); app.table_state.select(Some(0)); app.detail_pane = None; - app.set_status(format!( - "session {}/{}", - app.session_idx + 1, - app.sessions.len() - )); + app.set_status(format!("session {}/{}", app.session_idx + 1, count)); } KeyCode::Down | KeyCode::Char('j') => { let count = app.filtered_panes().len(); @@ -846,8 +855,71 @@ mod tests { let mut app = App::new(PathBuf::from("/tmp/nonexistent.sock")); app.snapshot = Some(snap); app.rebuild_session_list(); + // Real session ids are sorted + deduped (s1 appeared twice). assert_eq!(app.sessions, vec!["s1", "s2"]); + // Default selection is "All sessions" (index 0) — the aggregated view + // stays reachable. Regression for the "All sessions unreachable" bug. + assert_eq!(app.session_idx, 0); + assert!(app.session_filter.is_none()); + assert_eq!(app.session_count(), 3); // [All, s1, s2] + // Index 1 -> s1, index 2 -> s2. + app.session_idx = 1; + app.apply_session_filter(); assert_eq!(app.session_filter.as_deref(), Some("s1")); + app.session_idx = 2; + app.apply_session_filter(); + assert_eq!(app.session_filter.as_deref(), Some("s2")); + } + + #[test] + fn all_sessions_view_is_reachable_with_sessions_present() { + // Regression for the pre-existing bug documented in + // GLASSPANE-TUI-ENHANCEMENTS.md: once any pane had a session_id, + // rebuild_session_list() forced session_filter = Some(first), making + // the aggregated "All sessions" view unreachable. It must now default + // to All and stay selectable via Tab. + let snap = GlasspaneSnapshot::new( + "osa", + "2026-06-25T12:00:00Z", + vec![ + colibri_glasspane::Pane { + id: "a".into(), + agent: "zot".into(), + state: AgentState::Working, + session_id: Some("s1".into()), + last_event_at: None, + cwd: None, + stalled: false, + }, + colibri_glasspane::Pane { + id: "b".into(), + agent: "zot".into(), + state: AgentState::Working, + session_id: Some("s2".into()), + last_event_at: None, + cwd: None, + stalled: false, + }, + ], + ); + let mut app = App::new(PathBuf::from("/tmp/nonexistent.sock")); + app.snapshot = Some(snap); + app.rebuild_session_list(); + // After connect, the operator lands on the aggregated view. + assert_eq!(app.session_idx, 0); + assert!(app.session_filter.is_none()); + assert_eq!(app.filtered_panes().len(), 2); + // Tab cycles All -> s1 -> s2 -> All. + let count = app.session_count(); + app.session_idx = (app.session_idx + 1) % count; + app.apply_session_filter(); + assert_eq!(app.session_filter.as_deref(), Some("s1")); + app.session_idx = (app.session_idx + 1) % count; + app.apply_session_filter(); + assert_eq!(app.session_filter.as_deref(), Some("s2")); + app.session_idx = (app.session_idx + 1) % count; + app.apply_session_filter(); + assert!(app.session_filter.is_none(), "wrap back to All sessions"); } // ── render tests (TestBackend) ── @@ -1341,7 +1413,7 @@ mod tests { let mut app = App::new(PathBuf::from("/tmp/nonexistent.sock")); app.snapshot = Some(snap); app.rebuild_session_list(); - // Navigate to session s1 (rebuild selects first alphabetically = s1). + // rebuild now defaults to "All sessions"; force the view to session s1. app.session_filter = Some("s1".into()); let text = render_text(&mut app, 80, 24);