session_list.rs (16661B)
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::backend::BackendType; 8 use crate::config::AiMode; 9 use crate::focus_queue::{FocusPriority, FocusQueue}; 10 use crate::session::{SessionId, SessionManager}; 11 use crate::ui::keybind_hint::paint_keybind_hint; 12 13 /// Actions that can be triggered from the session list UI 14 #[derive(Debug, Clone)] 15 pub enum SessionListAction { 16 NewSession, 17 SwitchTo(SessionId), 18 Delete(SessionId), 19 Rename(SessionId, String), 20 DismissDone(SessionId), 21 } 22 23 /// UI component for displaying the session list sidebar 24 pub struct SessionListUi<'a> { 25 session_manager: &'a SessionManager, 26 focus_queue: &'a FocusQueue, 27 ctrl_held: bool, 28 } 29 30 impl<'a> SessionListUi<'a> { 31 pub fn new( 32 session_manager: &'a SessionManager, 33 focus_queue: &'a FocusQueue, 34 ctrl_held: bool, 35 ) -> Self { 36 SessionListUi { 37 session_manager, 38 focus_queue, 39 ctrl_held, 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 ui.horizontal(|ui| { 70 ui.add_space(4.0); 71 ui.label(egui::RichText::new("Sessions").size(18.0).strong()); 72 73 ui.with_layout(Layout::right_to_left(Align::Center), |ui| { 74 let icon = app_images::new_message_image() 75 .max_height(20.0) 76 .sense(Sense::click()); 77 78 if ui 79 .add(icon) 80 .on_hover_cursor(egui::CursorIcon::PointingHand) 81 .on_hover_text("New Chat") 82 .clicked() 83 { 84 action = Some(SessionListAction::NewSession); 85 } 86 }); 87 }); 88 89 action 90 } 91 92 fn sessions_list_ui(&self, ui: &mut egui::Ui) -> Option<SessionListAction> { 93 let mut action = None; 94 let active_id = self.session_manager.active_id(); 95 let mut visual_index: usize = 0; 96 97 // Agents grouped by hostname (pre-computed, no per-frame allocation) 98 for (hostname, ids) in self.session_manager.host_groups() { 99 let label = if hostname.is_empty() { 100 "Local" 101 } else { 102 hostname 103 }; 104 ui.label( 105 egui::RichText::new(label) 106 .size(12.0) 107 .color(ui.visuals().weak_text_color()), 108 ); 109 ui.add_space(4.0); 110 for &id in ids { 111 if let Some(session) = self.session_manager.get(id) { 112 if let Some(a) = self.render_session_item(ui, session, visual_index, active_id) 113 { 114 action = Some(a); 115 } 116 visual_index += 1; 117 } 118 } 119 ui.add_space(8.0); 120 } 121 122 // Chats section (pre-computed IDs) 123 let chat_ids = self.session_manager.chat_ids(); 124 if !chat_ids.is_empty() { 125 ui.label( 126 egui::RichText::new("Chats") 127 .size(12.0) 128 .color(ui.visuals().weak_text_color()), 129 ); 130 ui.add_space(4.0); 131 for &id in chat_ids { 132 if let Some(session) = self.session_manager.get(id) { 133 if let Some(a) = self.render_session_item(ui, session, visual_index, active_id) 134 { 135 action = Some(a); 136 } 137 visual_index += 1; 138 } 139 } 140 } 141 142 action 143 } 144 145 fn render_session_item( 146 &self, 147 ui: &mut egui::Ui, 148 session: &crate::session::ChatSession, 149 index: usize, 150 active_id: Option<SessionId>, 151 ) -> Option<SessionListAction> { 152 let is_active = Some(session.id) == active_id; 153 let shortcut_hint = if self.ctrl_held && index < 9 { 154 Some(index + 1) 155 } else { 156 None 157 }; 158 let queue_priority = self.focus_queue.get_session_priority(session.id); 159 let empty_path = PathBuf::new(); 160 let cwd = session.cwd().unwrap_or(&empty_path); 161 162 let rename_id = egui::Id::new("session_rename_state"); 163 let mut renaming: Option<(SessionId, String)> = 164 ui.data(|d| d.get_temp::<(SessionId, String)>(rename_id)); 165 let is_renaming = renaming 166 .as_ref() 167 .map(|(id, _)| *id == session.id) 168 .unwrap_or(false); 169 170 let display_title = if is_renaming { 171 "" 172 } else { 173 session.details.display_title() 174 }; 175 let (response, dot_action) = self.session_item_ui( 176 ui, 177 session.id, 178 display_title, 179 cwd, 180 &session.details.home_dir, 181 is_active, 182 shortcut_hint, 183 session.status(), 184 queue_priority, 185 session.ai_mode, 186 session.backend_type, 187 ); 188 189 let mut action = dot_action; 190 191 if is_renaming { 192 let outcome = renaming 193 .as_mut() 194 .and_then(|(_, buf)| inline_rename_ui(ui, &response, buf)); 195 match outcome { 196 Some(RenameOutcome::Confirmed(title)) => { 197 action = Some(SessionListAction::Rename(session.id, title)); 198 ui.data_mut(|d| d.remove_by_type::<(SessionId, String)>()); 199 } 200 Some(RenameOutcome::Cancelled) => { 201 ui.data_mut(|d| d.remove_by_type::<(SessionId, String)>()); 202 } 203 None => { 204 if let Some(r) = renaming { 205 ui.data_mut(|d| d.insert_temp(rename_id, r)); 206 } 207 } 208 } 209 } else if response.clicked() { 210 action = Some(SessionListAction::SwitchTo(session.id)); 211 } 212 213 // Long-press to rename (mobile) 214 if !is_renaming { 215 let press_id = egui::Id::new("session_long_press"); 216 if response.is_pointer_button_down_on() { 217 let now = ui.input(|i| i.time); 218 let start: Option<PressStart> = ui.data(|d| d.get_temp(press_id)); 219 if start.is_none() { 220 ui.data_mut(|d| d.insert_temp(press_id, PressStart(now))); 221 } else if let Some(s) = start { 222 if now - s.0 > 0.5 { 223 let rename_state = 224 (session.id, session.details.display_title().to_string()); 225 ui.data_mut(|d| d.insert_temp(rename_id, rename_state)); 226 ui.data_mut(|d| d.remove_by_type::<PressStart>()); 227 } 228 } 229 } else { 230 ui.data_mut(|d| d.remove_by_type::<PressStart>()); 231 } 232 } 233 234 response.context_menu(|ui| { 235 if ui.button("Rename").clicked() { 236 let rename_state = (session.id, session.details.display_title().to_string()); 237 ui.ctx() 238 .data_mut(|d| d.insert_temp(rename_id, rename_state)); 239 ui.close_menu(); 240 } 241 if ui.button("Delete").clicked() { 242 action = Some(SessionListAction::Delete(session.id)); 243 ui.close_menu(); 244 } 245 }); 246 action 247 } 248 249 #[allow(clippy::too_many_arguments)] 250 fn session_item_ui( 251 &self, 252 ui: &mut egui::Ui, 253 session_id: SessionId, 254 title: &str, 255 cwd: &Path, 256 home_dir: &str, 257 is_active: bool, 258 shortcut_hint: Option<usize>, 259 status: AgentStatus, 260 queue_priority: Option<FocusPriority>, 261 session_ai_mode: AiMode, 262 backend_type: BackendType, 263 ) -> (egui::Response, Option<SessionListAction>) { 264 let mut dot_action = None; 265 // Per-session: Chat sessions get shorter height (no CWD), no status bar 266 // Agentic sessions get taller height with CWD and status bar 267 let show_cwd = session_ai_mode == AiMode::Agentic; 268 let show_status_bar = session_ai_mode == AiMode::Agentic; 269 270 let item_height = if show_cwd { 48.0 } else { 32.0 }; 271 let desired_size = egui::vec2(ui.available_width(), item_height); 272 let (rect, response) = ui.allocate_exact_size(desired_size, Sense::click()); 273 let hover_text = format!("Ctrl+{} to switch", shortcut_hint.unwrap_or(0)); 274 let response = response 275 .on_hover_cursor(egui::CursorIcon::PointingHand) 276 .on_hover_text_at_pointer(hover_text); 277 278 // Paint background: active > hovered > transparent 279 let fill = if is_active { 280 ui.visuals().widgets.active.bg_fill 281 } else if response.hovered() { 282 ui.visuals().widgets.hovered.weak_bg_fill 283 } else { 284 Color32::TRANSPARENT 285 }; 286 287 let corner_radius = 8.0; 288 ui.painter().rect_filled(rect, corner_radius, fill); 289 290 // Status color indicator (left edge vertical bar) - only in Agentic mode 291 let mut text_start_x = if show_status_bar { 292 let status_color = status.color(); 293 let status_bar_rect = egui::Rect::from_min_size( 294 rect.left_top() + egui::vec2(2.0, 4.0), 295 egui::vec2(3.0, rect.height() - 8.0), 296 ); 297 ui.painter().rect_filled(status_bar_rect, 1.5, status_color); 298 12.0 // Left padding (room for status bar) 299 } else { 300 8.0 // Smaller padding in Chat mode (no status bar) 301 }; 302 303 // Backend icon (only for agentic backends) 304 if backend_type.is_agentic() { 305 let icon_size = 14.0; 306 let icon_rect = egui::Rect::from_center_size( 307 rect.left_center() + egui::vec2(text_start_x + icon_size / 2.0, 0.0), 308 egui::vec2(icon_size, icon_size), 309 ); 310 let icon = crate::ui::backend_icon(backend_type); 311 icon.paint_at(ui, icon_rect); 312 text_start_x += icon_size + 4.0; 313 } 314 315 // Draw shortcut hint at the far right 316 let mut right_offset = 8.0; // Start with normal right padding 317 318 if let Some(num) = shortcut_hint { 319 let hint_text = format!("{}", num); 320 let hint_size = 18.0; 321 let hint_center = rect.right_center() - egui::vec2(8.0 + hint_size / 2.0, 0.0); 322 paint_keybind_hint(ui, hint_center, &hint_text, hint_size); 323 right_offset = 8.0 + hint_size + 6.0; // padding + hint width + spacing 324 } 325 326 // Draw focus queue indicator dot to the left of the shortcut hint 327 let text_end_x = if let Some(priority) = queue_priority { 328 let dot_radius = 5.0; 329 let dot_center = rect.right_center() - egui::vec2(right_offset + dot_radius + 4.0, 0.0); 330 ui.painter() 331 .circle_filled(dot_center, dot_radius, priority.color()); 332 333 // Make the dot clickable to dismiss Done indicators 334 if priority == FocusPriority::Done { 335 let dot_rect = egui::Rect::from_center_size( 336 dot_center, 337 egui::vec2(dot_radius * 4.0, dot_radius * 4.0), 338 ); 339 let dot_response = ui.interact( 340 dot_rect, 341 ui.id().with(("dismiss_dot", session_id)), 342 egui::Sense::click(), 343 ); 344 if dot_response.clicked() { 345 dot_action = Some(SessionListAction::DismissDone(session_id)); 346 } 347 if dot_response.hovered() { 348 ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); 349 } 350 } 351 352 right_offset + dot_radius * 2.0 + 8.0 // Space reserved for the dot 353 } else { 354 right_offset 355 }; 356 357 // Calculate text position - offset title upward only if showing CWD 358 let title_y_offset = if show_cwd { -7.0 } else { 0.0 }; 359 let text_pos = rect.left_center() + egui::vec2(text_start_x, title_y_offset); 360 let max_text_width = rect.width() - text_start_x - text_end_x; 361 362 // Draw title text (with clipping to avoid overlapping the dot) 363 let font_id = egui::FontId::proportional(14.0); 364 let text_color = ui.visuals().text_color(); 365 let galley = ui 366 .painter() 367 .layout_no_wrap(title.to_string(), font_id.clone(), text_color); 368 369 if galley.size().x > max_text_width { 370 // Text is too long, use ellipsis 371 let clip_rect = egui::Rect::from_min_size( 372 text_pos - egui::vec2(0.0, galley.size().y / 2.0), 373 egui::vec2(max_text_width, galley.size().y), 374 ); 375 ui.painter().with_clip_rect(clip_rect).galley( 376 text_pos - egui::vec2(0.0, galley.size().y / 2.0), 377 galley, 378 text_color, 379 ); 380 } else { 381 ui.painter().text( 382 text_pos, 383 egui::Align2::LEFT_CENTER, 384 title, 385 font_id, 386 text_color, 387 ); 388 } 389 390 // Draw cwd below title - only in Agentic mode 391 if show_cwd { 392 let cwd_pos = rect.left_center() + egui::vec2(text_start_x, 7.0); 393 cwd_ui(ui, cwd, home_dir, cwd_pos, max_text_width); 394 } 395 396 (response, dot_action) 397 } 398 } 399 400 #[derive(Clone, Copy)] 401 struct PressStart(f64); 402 403 enum RenameOutcome { 404 Confirmed(String), 405 Cancelled, 406 } 407 408 fn inline_rename_ui( 409 ui: &mut egui::Ui, 410 response: &egui::Response, 411 buf: &mut String, 412 ) -> Option<RenameOutcome> { 413 let edit_rect = response.rect.shrink2(egui::vec2(8.0, 4.0)); 414 let edit = egui::Area::new(egui::Id::new("rename_textedit")) 415 .fixed_pos(edit_rect.min) 416 .order(egui::Order::Foreground) 417 .show(ui.ctx(), |ui| { 418 ui.set_width(edit_rect.width()); 419 ui.add( 420 egui::TextEdit::singleline(buf) 421 .font(egui::FontId::proportional(14.0)) 422 .frame(false), 423 ) 424 }) 425 .inner; 426 427 if !edit.has_focus() && !edit.lost_focus() { 428 edit.request_focus(); 429 } 430 431 if edit.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { 432 Some(RenameOutcome::Confirmed(buf.clone())) 433 } else if edit.lost_focus() { 434 if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { 435 Some(RenameOutcome::Cancelled) 436 } else { 437 Some(RenameOutcome::Confirmed(buf.clone())) 438 } 439 } else { 440 None 441 } 442 } 443 444 /// Draw cwd text (monospace, weak+small) with clipping. 445 fn cwd_ui(ui: &mut egui::Ui, cwd_path: &Path, home_dir: &str, pos: egui::Pos2, max_width: f32) { 446 let display_text = if home_dir.is_empty() { 447 crate::path_utils::abbreviate_path(cwd_path) 448 } else { 449 crate::path_utils::abbreviate_with_home(cwd_path, home_dir) 450 }; 451 let cwd_font = egui::FontId::monospace(10.0); 452 let cwd_color = ui.visuals().weak_text_color(); 453 454 let cwd_galley = ui 455 .painter() 456 .layout_no_wrap(display_text.clone(), cwd_font.clone(), cwd_color); 457 458 if cwd_galley.size().x > max_width { 459 let clip_rect = egui::Rect::from_min_size( 460 pos - egui::vec2(0.0, cwd_galley.size().y / 2.0), 461 egui::vec2(max_width, cwd_galley.size().y), 462 ); 463 ui.painter().with_clip_rect(clip_rect).galley( 464 pos - egui::vec2(0.0, cwd_galley.size().y / 2.0), 465 cwd_galley, 466 cwd_color, 467 ); 468 } else { 469 ui.painter().text( 470 pos, 471 egui::Align2::LEFT_CENTER, 472 &display_text, 473 cwd_font, 474 cwd_color, 475 ); 476 } 477 }