notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit 170b7c43df570b8ffd52de96b77abe4705b19146
parent abe852a6d5092566f91c68e0ef4cd9875420c272
Author: William Casarin <jb55@jb55.com>
Date:   Fri, 20 Feb 2026 13:30:27 -0800

dave: group agent sessions by hostname in session list

Replace the flat "Agents" section with hostname-based groups so
sessions from different machines are visually separated. Hostname
groups are cached in SessionManager and rebuilt only when sessions
change, avoiding per-frame allocations. CWD display no longer
includes the hostname prefix since it's now a section header.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Diffstat:
Mcrates/notedeck_dave/src/lib.rs | 6++++++
Mcrates/notedeck_dave/src/session.rs | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/ui/session_list.rs | 70+++++++++++++++++++++++++++-------------------------------------------
Mcrates/notedeck_dave/src/update.rs | 2++
4 files changed, 89 insertions(+), 43 deletions(-)

diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -372,6 +372,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr if let Some(session) = manager.get_mut(sid) { session.details.hostname = hostname.clone(); } + manager.rebuild_host_groups(); (manager, DaveOverlay::None) } AiMode::Agentic => (SessionManager::new(), DaveOverlay::DirectoryPicker), @@ -1133,6 +1134,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } } } + self.session_manager.rebuild_host_groups(); // Close directory picker if open if self.active_overlay == DaveOverlay::DirectoryPicker { @@ -1497,6 +1499,8 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } } + self.session_manager.rebuild_host_groups(); + // Skip the directory picker since we restored sessions self.active_overlay = DaveOverlay::None; } @@ -1684,6 +1688,8 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } } + self.session_manager.rebuild_host_groups(); + // If we were showing the directory picker, switch to showing sessions if matches!(self.active_overlay, DaveOverlay::DirectoryPicker) { self.active_overlay = DaveOverlay::None; diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs @@ -548,6 +548,12 @@ pub struct SessionManager { next_id: SessionId, /// Pending external editor job (only one at a time) pub pending_editor: Option<EditorJob>, + /// Cached agent grouping by hostname. Each entry is (hostname, session IDs + /// in recency order). Rebuilt via `rebuild_host_groups()` when sessions or + /// hostnames change. + host_groups: Vec<(String, Vec<SessionId>)>, + /// Cached chat session IDs in recency order. Rebuilt alongside host_groups. + chat_ids: Vec<SessionId>, } impl Default for SessionManager { @@ -564,6 +570,8 @@ impl SessionManager { active: None, next_id: 1, pending_editor: None, + host_groups: Vec::new(), + chat_ids: Vec::new(), } } @@ -576,6 +584,7 @@ impl SessionManager { self.sessions.insert(id, session); self.order.insert(0, id); // Most recent first self.active = Some(id); + self.rebuild_host_groups(); id } @@ -595,6 +604,7 @@ impl SessionManager { self.sessions.insert(id, session); self.order.insert(0, id); // Most recent first self.active = Some(id); + self.rebuild_host_groups(); id } @@ -636,6 +646,7 @@ impl SessionManager { if self.active == Some(id) { self.active = self.order.first().copied(); } + self.rebuild_host_groups(); true } else { false @@ -709,6 +720,49 @@ impl SessionManager { pub fn session_ids(&self) -> Vec<SessionId> { self.order.clone() } + + /// Get cached agent session groups by hostname. + /// Each entry is (hostname, session IDs in recency order). + pub fn host_groups(&self) -> &[(String, Vec<SessionId>)] { + &self.host_groups + } + + /// Get cached chat session IDs in recency order. + pub fn chat_ids(&self) -> &[SessionId] { + &self.chat_ids + } + + /// Get a session's index in the recency-ordered list (for keyboard shortcuts). + pub fn session_index(&self, id: SessionId) -> Option<usize> { + self.order.iter().position(|&oid| oid == id) + } + + /// Rebuild the cached hostname groups from current sessions and order. + /// Call after adding/removing sessions or changing a session's hostname. + pub fn rebuild_host_groups(&mut self) { + self.host_groups.clear(); + self.chat_ids.clear(); + + for &id in &self.order { + if let Some(session) = self.sessions.get(&id) { + if session.ai_mode != AiMode::Agentic { + if session.ai_mode == AiMode::Chat { + self.chat_ids.push(id); + } + continue; + } + let hostname = &session.details.hostname; + if let Some(group) = self.host_groups.iter_mut().find(|(h, _)| h == hostname) { + group.1.push(id); + } else { + self.host_groups.push((hostname.clone(), vec![id])); + } + } + } + + // Sort groups by hostname for stable ordering + self.host_groups.sort_by(|a, b| a.0.cmp(&b.0)); + } } impl ChatSession { diff --git a/crates/notedeck_dave/src/ui/session_list.rs b/crates/notedeck_dave/src/ui/session_list.rs @@ -89,47 +89,46 @@ impl<'a> SessionListUi<'a> { fn sessions_list_ui(&self, ui: &mut egui::Ui) -> Option<SessionListAction> { let mut action = None; let active_id = self.session_manager.active_id(); - let sessions = self.session_manager.sessions_ordered(); - - // Split into agents and chats - let agents: Vec<_> = sessions - .iter() - .enumerate() - .filter(|(_, s)| s.ai_mode == AiMode::Agentic) - .collect(); - let chats: Vec<_> = sessions - .iter() - .enumerate() - .filter(|(_, s)| s.ai_mode == AiMode::Chat) - .collect(); - - // Agents section - if !agents.is_empty() { + + // Agents grouped by hostname (pre-computed, no per-frame allocation) + for (hostname, ids) in self.session_manager.host_groups() { + let label = if hostname.is_empty() { + "Local" + } else { + hostname + }; ui.label( - egui::RichText::new("Agents") + egui::RichText::new(label) .size(12.0) .color(ui.visuals().weak_text_color()), ); ui.add_space(4.0); - for (index, session) in &agents { - if let Some(a) = self.render_session_item(ui, session, *index, active_id) { - action = Some(a); + for &id in ids { + if let Some(session) = self.session_manager.get(id) { + let index = self.session_manager.session_index(id).unwrap_or(0); + if let Some(a) = self.render_session_item(ui, session, index, active_id) { + action = Some(a); + } } } ui.add_space(8.0); } - // Chats section - if !chats.is_empty() { + // Chats section (pre-computed IDs) + let chat_ids = self.session_manager.chat_ids(); + if !chat_ids.is_empty() { ui.label( egui::RichText::new("Chats") .size(12.0) .color(ui.visuals().weak_text_color()), ); ui.add_space(4.0); - for (index, session) in &chats { - if let Some(a) = self.render_session_item(ui, session, *index, active_id) { - action = Some(a); + for &id in chat_ids { + if let Some(session) = self.session_manager.get(id) { + let index = self.session_manager.session_index(id).unwrap_or(0); + if let Some(a) = self.render_session_item(ui, session, index, active_id) { + action = Some(a); + } } } } @@ -158,7 +157,6 @@ impl<'a> SessionListUi<'a> { ui, &session.details.title, cwd, - &session.details.hostname, &session.details.home_dir, is_active, shortcut_hint, @@ -186,7 +184,6 @@ impl<'a> SessionListUi<'a> { ui: &mut egui::Ui, title: &str, cwd: &Path, - hostname: &str, home_dir: &str, is_active: bool, shortcut_hint: Option<usize>, @@ -290,7 +287,7 @@ impl<'a> SessionListUi<'a> { // Draw cwd below title - only in Agentic mode if show_cwd { let cwd_pos = rect.left_center() + egui::vec2(text_start_x, 7.0); - cwd_ui(ui, cwd, hostname, home_dir, cwd_pos, max_text_width); + cwd_ui(ui, cwd, home_dir, cwd_pos, max_text_width); } response @@ -298,25 +295,12 @@ impl<'a> SessionListUi<'a> { } /// Draw cwd text (monospace, weak+small) with clipping. -/// Shows "hostname:cwd" when hostname is non-empty. -fn cwd_ui( - ui: &mut egui::Ui, - cwd_path: &Path, - hostname: &str, - home_dir: &str, - pos: egui::Pos2, - max_width: f32, -) { - let cwd_str = if home_dir.is_empty() { +fn cwd_ui(ui: &mut egui::Ui, cwd_path: &Path, home_dir: &str, pos: egui::Pos2, max_width: f32) { + let display_text = if home_dir.is_empty() { crate::path_utils::abbreviate_path(cwd_path) } else { crate::path_utils::abbreviate_with_home(cwd_path, home_dir) }; - let display_text = if hostname.is_empty() { - cwd_str - } else { - format!("{}:{}", hostname, cwd_str) - }; let cwd_font = egui::FontId::monospace(10.0); let cwd_color = ui.visuals().weak_text_color(); diff --git a/crates/notedeck_dave/src/update.rs b/crates/notedeck_dave/src/update.rs @@ -908,6 +908,7 @@ pub fn create_session_with_cwd( } } } + session_manager.rebuild_host_groups(); id } @@ -937,6 +938,7 @@ pub fn create_resumed_session_with_cwd( } } } + session_manager.rebuild_host_groups(); id }