notedeck

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

commit 86887680298c80648aa5372d056be5625f6af287
parent 9fe94dc05ab63a466cd67e3456f8818f52c79fd7
Author: William Casarin <jb55@jb55.com>
Date:   Sun, 25 Jan 2026 12:34:19 -0800

Merge multi-conversations in dave

William Casarin (5):
      chrome: add --no-columns-app
      dave: add multiple session support with sidebar
      dave: restyle session list sidebar with ChatGPT-like design
      dave: replace new chat button with icon in sidebar

Diffstat:
MCargo.lock | 10+++++-----
Mcrates/notedeck/Cargo.toml | 4++--
Mcrates/notedeck_chrome/src/chrome.rs | 8+++++---
Mcrates/notedeck_dave/src/lib.rs | 204++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Acrates/notedeck_dave/src/session.rs | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/ui/dave.rs | 54+++++++++++++++++++-----------------------------------
Mcrates/notedeck_dave/src/ui/mod.rs | 2++
Acrates/notedeck_dave/src/ui/session_list.rs | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 474 insertions(+), 89 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -651,7 +651,7 @@ dependencies = [ "bitflags 2.9.1", "cexpr", "clang-sys", - "itertools 0.12.1", + "itertools 0.10.5", "lazy_static", "lazycell", "log", @@ -674,7 +674,7 @@ dependencies = [ "bitflags 2.9.1", "cexpr", "clang-sys", - "itertools 0.12.1", + "itertools 0.10.5", "log", "prettyplease", "proc-macro2", @@ -3315,7 +3315,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" dependencies = [ "cfg-if", - "windows-targets 0.53.2", + "windows-targets 0.48.5", ] [[package]] @@ -6710,7 +6710,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "319c70195101a93f56db4c74733e272d720768e13471f400c78406a326b172b0" dependencies = [ "cc", - "windows-targets 0.52.6", + "windows-targets 0.48.5", ] [[package]] @@ -7492,7 +7492,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.48.0", ] [[package]] diff --git a/crates/notedeck/Cargo.toml b/crates/notedeck/Cargo.toml @@ -81,9 +81,9 @@ ring = { workspace = true } # Override rustls to use ring crypto provider on Windows rustls = { version = "0.23.28", default-features = false, features = ["std", "tls12", "logging", "ring"] } # Override hyper-rustls to use ring on Windows -hyper-rustls = { version = "0.27.7", default-features = false, features = ["http1", "tls12", "logging", "native-tokio", "ring"] } +hyper-rustls = { version = "0.27.7", default-features = false, features = ["http1", "tls12", "logging", "native-tokio", "ring", "webpki-roots"] } # Use aws-lc-rs on non-Windows platforms for better performance [target.'cfg(not(windows))'.dependencies] rustls = { version = "0.23.28", default-features = false, features = ["std", "tls12", "logging", "aws_lc_rs"] } -hyper-rustls = { version = "0.27.7", default-features = false, features = ["http1", "tls12", "logging", "native-tokio", "aws-lc-rs"] } +hyper-rustls = { version = "0.27.7", default-features = false, features = ["http1", "tls12", "logging", "native-tokio", "aws-lc-rs", "webpki-roots"] } diff --git a/crates/notedeck_chrome/src/chrome.rs b/crates/notedeck_chrome/src/chrome.rs @@ -153,12 +153,14 @@ impl Chrome { let context = &mut notedeck.app_context(); let dave = Dave::new(cc.wgpu_render_state.as_ref()); - let columns = Damus::new(context, app_args); let mut chrome = Chrome::default(); - notedeck.check_args(columns.unrecognized_args())?; + if !app_args.iter().any(|arg| arg == "--no-columns-app") { + let columns = Damus::new(context, app_args); + notedeck.check_args(columns.unrecognized_args())?; + chrome.add_app(NotedeckApp::Columns(Box::new(columns))); + } - chrome.add_app(NotedeckApp::Columns(Box::new(columns))); chrome.add_app(NotedeckApp::Dave(Box::new(dave))); #[cfg(feature = "messages")] diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -1,3 +1,13 @@ +mod avatar; +mod config; +pub(crate) mod mesh; +mod messages; +mod quaternion; +pub mod session; +mod tools; +mod ui; +mod vec3; + use async_openai::{ config::OpenAIConfig, types::{ChatCompletionRequestMessage, CreateChatCompletionRequest}, @@ -8,41 +18,37 @@ use egui_wgpu::RenderState; use enostr::KeypairUnowned; use futures::StreamExt; use nostrdb::Transaction; -use notedeck::{AppAction, AppContext, AppResponse}; +use notedeck::{ui::is_narrow, AppAction, AppContext, AppResponse}; use std::collections::HashMap; use std::string::ToString; -use std::sync::mpsc::{self, Receiver}; +use std::sync::mpsc; use std::sync::Arc; pub use avatar::DaveAvatar; pub use config::ModelConfig; pub use messages::{DaveApiResponse, Message}; pub use quaternion::Quaternion; +pub use session::{ChatSession, SessionId, SessionManager}; pub use tools::{ PartialToolCall, QueryCall, QueryResponse, Tool, ToolCall, ToolCalls, ToolResponse, ToolResponses, }; -pub use ui::{DaveAction, DaveResponse, DaveUi}; +pub use ui::{DaveAction, DaveResponse, DaveUi, SessionListAction, SessionListUi}; pub use vec3::Vec3; -mod avatar; -mod config; -pub(crate) mod mesh; -mod messages; -mod quaternion; -mod tools; -mod ui; -mod vec3; - pub struct Dave { - chat: Vec<Message>, + /// Manages multiple chat sessions + session_manager: SessionManager, /// A 3d representation of dave. avatar: Option<DaveAvatar>, - input: String, + /// Shared tools available to all sessions tools: Arc<HashMap<String, Tool>>, + /// Shared API client client: async_openai::Client<OpenAIConfig>, - incoming_tokens: Option<Receiver<DaveApiResponse>>, + /// Model configuration model_config: ModelConfig, + /// Whether to show session list on mobile + show_session_list: bool, } /// Calculate an anonymous user_id from a keypair @@ -92,7 +98,6 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr //let model_config = ModelConfig::ollama(); let client = Client::with_config(model_config.to_api()); - let input = "".to_string(); let avatar = render_state.map(DaveAvatar::new); let mut tools: HashMap<String, Tool> = HashMap::new(); for tool in tools::dave_tools() { @@ -102,11 +107,10 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr Dave { client, avatar, - incoming_tokens: None, + session_manager: SessionManager::new(), tools: Arc::new(tools), - input, model_config, - chat: vec![], + show_session_list: false, } } @@ -116,7 +120,15 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr // we have tool responses to send back to the ai let mut should_send = false; - let Some(recvr) = &self.incoming_tokens else { + // Take the receiver out to avoid borrow conflicts + let recvr = { + let Some(session) = self.session_manager.get_active_mut() else { + return should_send; + }; + session.incoming_tokens.take() + }; + + let Some(recvr) = recvr else { return should_send; }; @@ -124,25 +136,30 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr if let Some(avatar) = &mut self.avatar { avatar.random_nudge(); } + + let Some(session) = self.session_manager.get_active_mut() else { + break; + }; + match res { - DaveApiResponse::Failed(err) => self.chat.push(Message::Error(err)), + DaveApiResponse::Failed(err) => session.chat.push(Message::Error(err)), - DaveApiResponse::Token(token) => match self.chat.last_mut() { + DaveApiResponse::Token(token) => match session.chat.last_mut() { Some(Message::Assistant(msg)) => *msg = msg.clone() + &token, - Some(_) => self.chat.push(Message::Assistant(token)), + Some(_) => session.chat.push(Message::Assistant(token)), None => {} }, DaveApiResponse::ToolCalls(toolcalls) => { tracing::info!("got tool calls: {:?}", toolcalls); - self.chat.push(Message::ToolCalls(toolcalls.clone())); + session.chat.push(Message::ToolCalls(toolcalls.clone())); let txn = Transaction::new(app_ctx.ndb).unwrap(); for call in &toolcalls { // execute toolcall match call.calls() { ToolCalls::PresentNotes(present) => { - self.chat.push(Message::ToolResponse(ToolResponse::new( + session.chat.push(Message::ToolResponse(ToolResponse::new( call.id().to_owned(), ToolResponses::PresentNotes(present.note_ids.len() as i32), ))); @@ -153,7 +170,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr ToolCalls::Invalid(invalid) => { should_send = true; - self.chat.push(Message::tool_error( + session.chat.push(Message::tool_error( call.id().to_string(), invalid.error.clone(), )); @@ -163,7 +180,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr should_send = true; let resp = search_call.execute(&txn, app_ctx.ndb); - self.chat.push(Message::ToolResponse(ToolResponse::new( + session.chat.push(Message::ToolResponse(ToolResponse::new( call.id().to_owned(), ToolResponses::Query(resp), ))) @@ -174,38 +191,132 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } } + // Put the receiver back + if let Some(session) = self.session_manager.get_active_mut() { + session.incoming_tokens = Some(recvr); + } + should_send } fn ui(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse { - /* - let rect = ui.available_rect_before_wrap(); - if let Some(av) = self.avatar.as_mut() { - av.render(rect, ui); - ui.ctx().request_repaint(); + if is_narrow(ui.ctx()) { + self.narrow_ui(app_ctx, ui) + } else { + self.desktop_ui(app_ctx, ui) + } + } + + /// Desktop layout with sidebar for session list + fn desktop_ui(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse { + let available = ui.available_rect_before_wrap(); + let sidebar_width = 280.0; + + let sidebar_rect = + egui::Rect::from_min_size(available.min, egui::vec2(sidebar_width, available.height())); + let chat_rect = egui::Rect::from_min_size( + egui::pos2(available.min.x + sidebar_width, available.min.y), + egui::vec2(available.width() - sidebar_width, available.height()), + ); + + // Render sidebar first - borrow released after this + let session_action = ui + .allocate_new_ui(egui::UiBuilder::new().max_rect(sidebar_rect), |ui| { + egui::Frame::new() + .fill(ui.visuals().faint_bg_color) + .inner_margin(egui::Margin::symmetric(8, 12)) + .show(ui, |ui| SessionListUi::new(&self.session_manager).ui(ui)) + .inner + }) + .inner; + + // Now we can mutably borrow for chat + let chat_response = ui + .allocate_new_ui(egui::UiBuilder::new().max_rect(chat_rect), |ui| { + if let Some(session) = self.session_manager.get_active_mut() { + DaveUi::new(self.model_config.trial, &session.chat, &mut session.input) + .ui(app_ctx, ui) + } else { + DaveResponse::default() + } + }) + .inner; + + // Handle actions after rendering + if let Some(action) = session_action { + match action { + SessionListAction::NewSession => return DaveResponse::new(DaveAction::NewChat), + SessionListAction::SwitchTo(id) => { + self.session_manager.switch_to(id); + } + SessionListAction::Delete(id) => { + self.session_manager.delete_session(id); + } + } } - DaveResponse::default() - */ - DaveUi::new(self.model_config.trial, &self.chat, &mut self.input).ui(app_ctx, ui) + chat_response + } + + /// Narrow/mobile layout - shows either session list or chat + fn narrow_ui(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse { + if self.show_session_list { + // Show session list + let session_action = egui::Frame::new() + .fill(ui.visuals().faint_bg_color) + .inner_margin(egui::Margin::symmetric(8, 12)) + .show(ui, |ui| SessionListUi::new(&self.session_manager).ui(ui)) + .inner; + if let Some(action) = session_action { + match action { + SessionListAction::NewSession => { + self.session_manager.new_session(); + self.show_session_list = false; + } + SessionListAction::SwitchTo(id) => { + self.session_manager.switch_to(id); + self.show_session_list = false; + } + SessionListAction::Delete(id) => { + self.session_manager.delete_session(id); + } + } + } + DaveResponse::default() + } else { + // Show chat + if let Some(session) = self.session_manager.get_active_mut() { + DaveUi::new(self.model_config.trial, &session.chat, &mut session.input) + .ui(app_ctx, ui) + } else { + DaveResponse::default() + } + } } fn handle_new_chat(&mut self) { - self.chat = vec![]; - self.input.clear(); + self.session_manager.new_session(); } /// Handle a user send action triggered by the ui fn handle_user_send(&mut self, app_ctx: &AppContext, ui: &egui::Ui) { - self.chat.push(Message::User(self.input.clone())); + if let Some(session) = self.session_manager.get_active_mut() { + session.chat.push(Message::User(session.input.clone())); + session.input.clear(); + session.update_title_from_first_message(); + } self.send_user_message(app_ctx, ui.ctx()); - self.input.clear(); } fn send_user_message(&mut self, app_ctx: &AppContext, ctx: &egui::Context) { + let Some(session) = self.session_manager.get_active_mut() else { + return; + }; + let messages: Vec<ChatCompletionRequestMessage> = { let txn = Transaction::new(app_ctx.ndb).expect("txn"); - self.chat + session + .chat .iter() .filter_map(|c| c.to_api_msg(&txn, app_ctx.ndb)) .collect() @@ -220,7 +331,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr let model_name = self.model_config.model().to_owned(); let (tx, rx) = mpsc::channel(); - self.incoming_tokens = Some(rx); + session.incoming_tokens = Some(rx); tokio::spawn(async move { let mut token_stream = match client @@ -340,9 +451,11 @@ impl notedeck::App for Dave { fn update(&mut self, ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> AppResponse { let mut app_action: Option<AppAction> = None; - // always insert system prompt if we have no context - if self.chat.is_empty() { - self.chat.push(Dave::system_prompt()); + // always insert system prompt if we have no context in active session + if let Some(session) = self.session_manager.get_active_mut() { + if session.chat.is_empty() { + session.chat.push(Dave::system_prompt()); + } } //update_dave(self, ctx, ui.ctx()); @@ -361,6 +474,9 @@ impl notedeck::App for Dave { DaveAction::Send => { self.handle_user_send(ctx, ui); } + DaveAction::ShowSessionList => { + self.show_session_list = !self.show_session_list; + } } } diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs @@ -0,0 +1,155 @@ +use std::collections::HashMap; +use std::sync::mpsc::Receiver; + +use crate::{DaveApiResponse, Message}; + +pub type SessionId = u32; + +/// A single chat session with Dave +pub struct ChatSession { + pub id: SessionId, + pub title: String, + pub chat: Vec<Message>, + pub input: String, + pub incoming_tokens: Option<Receiver<DaveApiResponse>>, +} + +impl ChatSession { + pub fn new(id: SessionId) -> Self { + ChatSession { + id, + title: "New Chat".to_string(), + chat: vec![], + input: String::new(), + incoming_tokens: None, + } + } + + /// Update the session title from the first user message + pub fn update_title_from_first_message(&mut self) { + for msg in &self.chat { + if let Message::User(text) = msg { + // Use first ~30 chars of first user message as title + let title: String = text.chars().take(30).collect(); + self.title = if text.len() > 30 { + format!("{}...", title) + } else { + title + }; + break; + } + } + } +} + +/// Manages multiple chat sessions +pub struct SessionManager { + sessions: HashMap<SessionId, ChatSession>, + order: Vec<SessionId>, // Sorted by recency (most recent first) + active: Option<SessionId>, + next_id: SessionId, +} + +impl Default for SessionManager { + fn default() -> Self { + Self::new() + } +} + +impl SessionManager { + pub fn new() -> Self { + let mut manager = SessionManager { + sessions: HashMap::new(), + order: Vec::new(), + active: None, + next_id: 1, + }; + // Start with one session + manager.new_session(); + manager + } + + /// Create a new session and make it active + pub fn new_session(&mut self) -> SessionId { + let id = self.next_id; + self.next_id += 1; + + let session = ChatSession::new(id); + self.sessions.insert(id, session); + self.order.insert(0, id); // Most recent first + self.active = Some(id); + + id + } + + /// Get a reference to the active session + pub fn get_active(&self) -> Option<&ChatSession> { + self.active.and_then(|id| self.sessions.get(&id)) + } + + /// Get a mutable reference to the active session + pub fn get_active_mut(&mut self) -> Option<&mut ChatSession> { + self.active.and_then(|id| self.sessions.get_mut(&id)) + } + + /// Get the active session ID + pub fn active_id(&self) -> Option<SessionId> { + self.active + } + + /// Switch to a different session + pub fn switch_to(&mut self, id: SessionId) -> bool { + if self.sessions.contains_key(&id) { + self.active = Some(id); + true + } else { + false + } + } + + /// Delete a session + pub fn delete_session(&mut self, id: SessionId) -> bool { + if self.sessions.remove(&id).is_some() { + self.order.retain(|&x| x != id); + + // If we deleted the active session, switch to another + if self.active == Some(id) { + self.active = self.order.first().copied(); + + // If no sessions left, create a new one + if self.active.is_none() { + self.new_session(); + } + } + true + } else { + false + } + } + + /// Get sessions in order of recency (most recent first) + pub fn sessions_ordered(&self) -> Vec<&ChatSession> { + self.order + .iter() + .filter_map(|id| self.sessions.get(id)) + .collect() + } + + /// Update the recency of a session (move to front of order) + pub fn touch(&mut self, id: SessionId) { + if self.sessions.contains_key(&id) { + self.order.retain(|&x| x != id); + self.order.insert(0, id); + } + } + + /// Get the number of sessions + pub fn len(&self) -> usize { + self.sessions.len() + } + + /// Check if there are no sessions + pub fn is_empty(&self) -> bool { + self.sessions.is_empty() + } +} diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs @@ -7,7 +7,7 @@ use nostrdb::{Ndb, Transaction}; use notedeck::{ tr, Accounts, AppContext, Images, Localization, MediaJobSender, NoteAction, NoteContext, }; -use notedeck_ui::{app_images, icons::search_icon, NoteOptions, ProfilePic}; +use notedeck_ui::{icons::search_icon, NoteOptions, ProfilePic}; /// DaveUi holds all of the data it needs to render itself pub struct DaveUi<'a> { @@ -24,7 +24,7 @@ pub struct DaveResponse { } impl DaveResponse { - fn new(action: DaveAction) -> Self { + pub fn new(action: DaveAction) -> Self { DaveResponse { action: Some(action), } @@ -34,7 +34,7 @@ impl DaveResponse { Self::new(DaveAction::Note(action)) } - fn or(self, r: DaveResponse) -> DaveResponse { + pub fn or(self, r: DaveResponse) -> DaveResponse { DaveResponse { action: self.action.or(r.action), } @@ -60,6 +60,8 @@ pub enum DaveAction { NewChat, ToggleChrome, Note(NoteAction), + /// Toggle showing the session list (for mobile navigation) + ShowSessionList, } impl<'a> DaveUi<'a> { @@ -367,31 +369,6 @@ impl<'a> DaveUi<'a> { } } -fn new_chat_button() -> impl egui::Widget { - move |ui: &mut egui::Ui| { - let img_size = 24.0; - let max_size = 32.0; - - let img = app_images::new_message_image().max_width(img_size); - - let helper = notedeck_ui::anim::AnimationHelper::new( - ui, - "new-chat-button", - egui::vec2(max_size, max_size), - ); - - let cur_img_size = helper.scale_1d_pos(img_size); - img.paint_at( - ui, - helper - .get_animation_rect() - .shrink((max_size - cur_img_size) / 2.0), - ); - - helper.take_animation_response() - } -} - fn query_call_ui( cache: &mut notedeck::Images, ndb: &Ndb, @@ -485,6 +462,20 @@ fn top_buttons_ui(app_ctx: &mut AppContext, ui: &mut egui::Ui) -> Option<DaveAct rect.set_height(32.0); rect.set_width(32.0); + // Show session list button on mobile/narrow screens + if notedeck::ui::is_narrow(ui.ctx()) { + let r = ui + .put(rect, egui::Button::new("\u{2630}").frame(false)) + .on_hover_text("Show chats") + .on_hover_cursor(egui::CursorIcon::PointingHand); + + if r.clicked() { + action = Some(DaveAction::ShowSessionList); + } + + rect = rect.translate(egui::vec2(30.0, 0.0)); + } + let txn = Transaction::new(app_ctx.ndb).unwrap(); let r = ui .put( @@ -503,13 +494,6 @@ fn top_buttons_ui(app_ctx: &mut AppContext, ui: &mut egui::Ui) -> Option<DaveAct action = Some(DaveAction::ToggleChrome); } - rect = rect.translate(egui::vec2(30.0, 0.0)); - let r = ui.put(rect, new_chat_button()); - - if r.clicked() { - action = Some(DaveAction::NewChat); - } - action } diff --git a/crates/notedeck_dave/src/ui/mod.rs b/crates/notedeck_dave/src/ui/mod.rs @@ -1,3 +1,5 @@ mod dave; +pub mod session_list; pub use dave::{DaveAction, DaveResponse, DaveUi}; +pub use session_list::{SessionListAction, SessionListUi}; diff --git a/crates/notedeck_dave/src/ui/session_list.rs b/crates/notedeck_dave/src/ui/session_list.rs @@ -0,0 +1,126 @@ +use egui::{Align, Layout, Sense}; +use notedeck_ui::app_images; + +use crate::session::{SessionId, SessionManager}; + +/// Actions that can be triggered from the session list UI +#[derive(Debug, Clone)] +pub enum SessionListAction { + NewSession, + SwitchTo(SessionId), + Delete(SessionId), +} + +/// UI component for displaying the session list sidebar +pub struct SessionListUi<'a> { + session_manager: &'a SessionManager, +} + +impl<'a> SessionListUi<'a> { + pub fn new(session_manager: &'a SessionManager) -> Self { + SessionListUi { session_manager } + } + + pub fn ui(&self, ui: &mut egui::Ui) -> Option<SessionListAction> { + let mut action: Option<SessionListAction> = None; + + ui.vertical(|ui| { + // Header with New Chat button + action = self.header_ui(ui); + + ui.add_space(8.0); + + // Scrollable list of sessions + egui::ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + if let Some(session_action) = self.sessions_list_ui(ui) { + action = Some(session_action); + } + }); + }); + + action + } + + fn header_ui(&self, ui: &mut egui::Ui) -> Option<SessionListAction> { + let mut action = None; + + ui.horizontal(|ui| { + ui.add_space(4.0); + ui.label(egui::RichText::new("Chats").size(18.0).strong()); + + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let icon = app_images::new_message_image() + .max_height(20.0) + .sense(Sense::click()); + + if ui + .add(icon) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .on_hover_text("New Chat") + .clicked() + { + action = Some(SessionListAction::NewSession); + } + }); + }); + + action + } + + fn sessions_list_ui(&self, ui: &mut egui::Ui) -> Option<SessionListAction> { + let mut action = None; + let active_id = self.session_manager.active_id(); + + for session in self.session_manager.sessions_ordered() { + let is_active = Some(session.id) == active_id; + + let response = self.session_item_ui(ui, &session.title, is_active); + + if response.clicked() { + action = Some(SessionListAction::SwitchTo(session.id)); + } + + // Right-click context menu for delete + response.context_menu(|ui| { + if ui.button("Delete").clicked() { + action = Some(SessionListAction::Delete(session.id)); + ui.close_menu(); + } + }); + } + + action + } + + fn session_item_ui(&self, ui: &mut egui::Ui, title: &str, is_active: bool) -> egui::Response { + let desired_size = egui::vec2(ui.available_width(), 36.0); + let (rect, response) = ui.allocate_exact_size(desired_size, Sense::click()); + let response = response.on_hover_cursor(egui::CursorIcon::PointingHand); + + // Paint background: active > hovered > transparent + let fill = if is_active { + ui.visuals().widgets.active.bg_fill + } else if response.hovered() { + ui.visuals().widgets.hovered.weak_bg_fill + } else { + egui::Color32::TRANSPARENT + }; + + let corner_radius = 8.0; + ui.painter().rect_filled(rect, corner_radius, fill); + + // Draw title text (left-aligned, vertically centered) + let text_pos = rect.left_center() + egui::vec2(8.0, 0.0); + ui.painter().text( + text_pos, + egui::Align2::LEFT_CENTER, + title, + egui::FontId::proportional(14.0), + ui.visuals().text_color(), + ); + + response + } +}