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