notedeck

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

commit 2c6302fe32e61cb76a4ddf7f56b9dccdf7cca3bb
parent be8e36e0b6cc0c77877931c2819fb0ca2c2d19d4
Author: William Casarin <jb55@jb55.com>
Date:   Sat, 24 Jan 2026 16:00:17 -0800

dave: add multiple session support with sidebar

Add support for multiple chat sessions with a sidebar to list and switch
between them. Each session maintains its own conversation history and
input state.

- Add SessionManager to track multiple ChatSession instances
- Desktop layout: 280px sidebar + chat panel using allocate_new_ui
- Mobile layout: hamburger menu toggles between session list and chat
- Session titles derived from first user message
- Right-click context menu for session deletion

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

Diffstat:
Mcrates/notedeck_dave/src/lib.rs | 195+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Acrates/notedeck_dave/src/session.rs | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/ui/dave.rs | 20++++++++++++++++++--
Mcrates/notedeck_dave/src/ui/mod.rs | 2++
Acrates/notedeck_dave/src/ui/session_list.rs | 163+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 489 insertions(+), 46 deletions(-)

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,123 @@ 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| { + SessionListUi::new(&self.session_manager).ui(ui) + }) + .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 + if let Some(action) = SessionListUi::new(&self.session_manager).ui(ui) { + 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 +322,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 +442,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 +465,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 @@ -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> { @@ -485,6 +487,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( 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,163 @@ +use egui::{Align, Layout, Sense}; + +use crate::session::{SessionId, SessionManager}; +use crate::Message; + +/// 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); + ui.separator(); + 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(12.0); + ui.heading("Chats"); + + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.add_space(12.0); + if ui + .button("+") + .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, Self::get_preview(&session.chat), 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, + preview: Option<String>, + is_active: bool, + ) -> egui::Response { + let fill = if is_active { + ui.visuals().widgets.active.bg_fill + } else { + egui::Color32::TRANSPARENT + }; + + let frame = egui::Frame::new() + .fill(fill) + .inner_margin(egui::Margin::symmetric(12, 8)) + .corner_radius(8.0); + + frame + .show(ui, |ui| { + ui.set_width(ui.available_width()); + + ui.vertical(|ui| { + // Title + ui.add( + egui::Label::new( + egui::RichText::new(title) + .strong() + .size(14.0), + ) + .truncate(), + ); + + // Preview of last message + if let Some(preview) = preview { + ui.add( + egui::Label::new( + egui::RichText::new(preview) + .weak() + .size(12.0), + ) + .truncate(), + ); + } + }); + }) + .response + .interact(Sense::click()) + } + + /// Get a preview string from the chat history + fn get_preview(chat: &[Message]) -> Option<String> { + // Find the last user or assistant message + for msg in chat.iter().rev() { + match msg { + Message::User(text) | Message::Assistant(text) => { + let preview: String = text.chars().take(50).collect(); + return Some(if text.len() > 50 { + format!("{}...", preview) + } else { + preview + }); + } + _ => continue, + } + } + None + } +}