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:
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
+ }
+}