session_list.rs (9922B)
1 use std::path::{Path, PathBuf}; 2 3 use egui::{Align, Color32, Layout, Sense}; 4 use notedeck_ui::app_images; 5 6 use crate::agent_status::AgentStatus; 7 use crate::config::AiMode; 8 use crate::focus_queue::{FocusPriority, FocusQueue}; 9 use crate::session::{SessionId, SessionManager}; 10 use crate::ui::keybind_hint::paint_keybind_hint; 11 12 /// Actions that can be triggered from the session list UI 13 #[derive(Debug, Clone)] 14 pub enum SessionListAction { 15 NewSession, 16 SwitchTo(SessionId), 17 Delete(SessionId), 18 } 19 20 /// UI component for displaying the session list sidebar 21 pub struct SessionListUi<'a> { 22 session_manager: &'a SessionManager, 23 focus_queue: &'a FocusQueue, 24 ctrl_held: bool, 25 ai_mode: AiMode, 26 } 27 28 impl<'a> SessionListUi<'a> { 29 pub fn new( 30 session_manager: &'a SessionManager, 31 focus_queue: &'a FocusQueue, 32 ctrl_held: bool, 33 ai_mode: AiMode, 34 ) -> Self { 35 SessionListUi { 36 session_manager, 37 focus_queue, 38 ctrl_held, 39 ai_mode, 40 } 41 } 42 43 pub fn ui(&self, ui: &mut egui::Ui) -> Option<SessionListAction> { 44 let mut action: Option<SessionListAction> = None; 45 46 ui.vertical(|ui| { 47 // Header with New Agent button 48 action = self.header_ui(ui); 49 50 ui.add_space(8.0); 51 52 // Scrollable list of sessions 53 egui::ScrollArea::vertical() 54 .id_salt("session_list_scroll") 55 .auto_shrink([false; 2]) 56 .show(ui, |ui| { 57 if let Some(session_action) = self.sessions_list_ui(ui) { 58 action = Some(session_action); 59 } 60 }); 61 }); 62 63 action 64 } 65 66 fn header_ui(&self, ui: &mut egui::Ui) -> Option<SessionListAction> { 67 let mut action = None; 68 69 // Header text and tooltip depend on mode 70 let (header_text, new_tooltip) = match self.ai_mode { 71 AiMode::Chat => ("Chats", "New Chat"), 72 AiMode::Agentic => ("Agents", "New Agent"), 73 }; 74 75 ui.horizontal(|ui| { 76 ui.add_space(4.0); 77 ui.label(egui::RichText::new(header_text).size(18.0).strong()); 78 79 ui.with_layout(Layout::right_to_left(Align::Center), |ui| { 80 let icon = app_images::new_message_image() 81 .max_height(20.0) 82 .sense(Sense::click()); 83 84 if ui 85 .add(icon) 86 .on_hover_cursor(egui::CursorIcon::PointingHand) 87 .on_hover_text(new_tooltip) 88 .clicked() 89 { 90 action = Some(SessionListAction::NewSession); 91 } 92 }); 93 }); 94 95 action 96 } 97 98 fn sessions_list_ui(&self, ui: &mut egui::Ui) -> Option<SessionListAction> { 99 let mut action = None; 100 let active_id = self.session_manager.active_id(); 101 102 for (index, session) in self.session_manager.sessions_ordered().iter().enumerate() { 103 let is_active = Some(session.id) == active_id; 104 // Show keyboard shortcut hint for first 9 sessions (1-9 keys), only when Ctrl held 105 let shortcut_hint = if self.ctrl_held && index < 9 { 106 Some(index + 1) 107 } else { 108 None 109 }; 110 111 // Check if this session is in the focus queue 112 let queue_priority = self.focus_queue.get_session_priority(session.id); 113 114 // Get cwd from agentic data, fallback to empty path for Chat mode 115 let empty_path = PathBuf::new(); 116 let cwd = session.cwd().unwrap_or(&empty_path); 117 118 let response = self.session_item_ui( 119 ui, 120 &session.title, 121 cwd, 122 is_active, 123 shortcut_hint, 124 session.status(), 125 queue_priority, 126 ); 127 128 if response.clicked() { 129 action = Some(SessionListAction::SwitchTo(session.id)); 130 } 131 132 // Right-click context menu for delete 133 response.context_menu(|ui| { 134 if ui.button("Delete").clicked() { 135 action = Some(SessionListAction::Delete(session.id)); 136 ui.close_menu(); 137 } 138 }); 139 } 140 141 action 142 } 143 144 #[allow(clippy::too_many_arguments)] 145 fn session_item_ui( 146 &self, 147 ui: &mut egui::Ui, 148 title: &str, 149 cwd: &Path, 150 is_active: bool, 151 shortcut_hint: Option<usize>, 152 status: AgentStatus, 153 queue_priority: Option<FocusPriority>, 154 ) -> egui::Response { 155 // In Chat mode: shorter height (no CWD), no status bar 156 // In Agentic mode: taller height with CWD and status bar 157 let show_cwd = self.ai_mode == AiMode::Agentic; 158 let show_status_bar = self.ai_mode == AiMode::Agentic; 159 160 let item_height = if show_cwd { 48.0 } else { 32.0 }; 161 let desired_size = egui::vec2(ui.available_width(), item_height); 162 let (rect, response) = ui.allocate_exact_size(desired_size, Sense::click()); 163 let hover_text = format!("Ctrl+{} to switch", shortcut_hint.unwrap_or(0)); 164 let response = response 165 .on_hover_cursor(egui::CursorIcon::PointingHand) 166 .on_hover_text_at_pointer(hover_text); 167 168 // Paint background: active > hovered > transparent 169 let fill = if is_active { 170 ui.visuals().widgets.active.bg_fill 171 } else if response.hovered() { 172 ui.visuals().widgets.hovered.weak_bg_fill 173 } else { 174 Color32::TRANSPARENT 175 }; 176 177 let corner_radius = 8.0; 178 ui.painter().rect_filled(rect, corner_radius, fill); 179 180 // Status color indicator (left edge vertical bar) - only in Agentic mode 181 let text_start_x = if show_status_bar { 182 let status_color = status.color(); 183 let status_bar_rect = egui::Rect::from_min_size( 184 rect.left_top() + egui::vec2(2.0, 4.0), 185 egui::vec2(3.0, rect.height() - 8.0), 186 ); 187 ui.painter().rect_filled(status_bar_rect, 1.5, status_color); 188 12.0 // Left padding (room for status bar) 189 } else { 190 8.0 // Smaller padding in Chat mode (no status bar) 191 }; 192 193 // Draw shortcut hint at the far right 194 let mut right_offset = 8.0; // Start with normal right padding 195 196 if let Some(num) = shortcut_hint { 197 let hint_text = format!("{}", num); 198 let hint_size = 18.0; 199 let hint_center = rect.right_center() - egui::vec2(8.0 + hint_size / 2.0, 0.0); 200 paint_keybind_hint(ui, hint_center, &hint_text, hint_size); 201 right_offset = 8.0 + hint_size + 6.0; // padding + hint width + spacing 202 } 203 204 // Draw focus queue indicator dot to the left of the shortcut hint 205 let text_end_x = if let Some(priority) = queue_priority { 206 let dot_radius = 5.0; 207 let dot_center = rect.right_center() - egui::vec2(right_offset + dot_radius + 4.0, 0.0); 208 ui.painter() 209 .circle_filled(dot_center, dot_radius, priority.color()); 210 right_offset + dot_radius * 2.0 + 8.0 // Space reserved for the dot 211 } else { 212 right_offset 213 }; 214 215 // Calculate text position - offset title upward only if showing CWD 216 let title_y_offset = if show_cwd { -7.0 } else { 0.0 }; 217 let text_pos = rect.left_center() + egui::vec2(text_start_x, title_y_offset); 218 let max_text_width = rect.width() - text_start_x - text_end_x; 219 220 // Draw title text (with clipping to avoid overlapping the dot) 221 let font_id = egui::FontId::proportional(14.0); 222 let text_color = ui.visuals().text_color(); 223 let galley = ui 224 .painter() 225 .layout_no_wrap(title.to_string(), font_id.clone(), text_color); 226 227 if galley.size().x > max_text_width { 228 // Text is too long, use ellipsis 229 let clip_rect = egui::Rect::from_min_size( 230 text_pos - egui::vec2(0.0, galley.size().y / 2.0), 231 egui::vec2(max_text_width, galley.size().y), 232 ); 233 ui.painter().with_clip_rect(clip_rect).galley( 234 text_pos - egui::vec2(0.0, galley.size().y / 2.0), 235 galley, 236 text_color, 237 ); 238 } else { 239 ui.painter().text( 240 text_pos, 241 egui::Align2::LEFT_CENTER, 242 title, 243 font_id, 244 text_color, 245 ); 246 } 247 248 // Draw cwd below title - only in Agentic mode 249 if show_cwd { 250 let cwd_pos = rect.left_center() + egui::vec2(text_start_x, 7.0); 251 cwd_ui(ui, cwd, cwd_pos, max_text_width); 252 } 253 254 response 255 } 256 } 257 258 /// Draw cwd text (monospace, weak+small) with clipping 259 fn cwd_ui(ui: &mut egui::Ui, cwd_path: &Path, pos: egui::Pos2, max_width: f32) { 260 let cwd_text = cwd_path.to_string_lossy(); 261 let cwd_font = egui::FontId::monospace(10.0); 262 let cwd_color = ui.visuals().weak_text_color(); 263 264 let cwd_galley = ui 265 .painter() 266 .layout_no_wrap(cwd_text.to_string(), cwd_font.clone(), cwd_color); 267 268 if cwd_galley.size().x > max_width { 269 let clip_rect = egui::Rect::from_min_size( 270 pos - egui::vec2(0.0, cwd_galley.size().y / 2.0), 271 egui::vec2(max_width, cwd_galley.size().y), 272 ); 273 ui.painter().with_clip_rect(clip_rect).galley( 274 pos - egui::vec2(0.0, cwd_galley.size().y / 2.0), 275 cwd_galley, 276 cwd_color, 277 ); 278 } else { 279 ui.painter().text( 280 pos, 281 egui::Align2::LEFT_CENTER, 282 &cwd_text, 283 cwd_font, 284 cwd_color, 285 ); 286 } 287 }