notedeck

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

commit fb2175f7e8d9449846320bba9a968676f311619d
parent 74ae440f5c3e1dabdf718b710913ddcc900a6fc2
Author: kernelkind <kernelkind@gmail.com>
Date:   Mon, 23 Feb 2026 13:58:06 -0500

dave: custom session titles with cross-device sync

Add ability to rename session titles via right-click context menu
(desktop) or long-press (mobile). User-set titles are stored as a
separate `custom_title` field that takes precedence over the
auto-generated title, so incoming messages don't clobber the rename.

Custom titles are published as a `custom_title` tag in the kind-31988
session state event and synced across devices in real time via the
existing ndb subscription.

Signed-off-by: kernelkind <kernelkind@gmail.com>

Diffstat:
Mcrates/notedeck_dave/src/lib.rs | 43+++++++++++++++++++++++++++++++++++--------
Mcrates/notedeck_dave/src/session.rs | 10++++++++++
Mcrates/notedeck_dave/src/session_events.rs | 6++++++
Mcrates/notedeck_dave/src/session_loader.rs | 3+++
Mcrates/notedeck_dave/src/ui/dave.rs | 2+-
Mcrates/notedeck_dave/src/ui/mod.rs | 2+-
Mcrates/notedeck_dave/src/ui/scene.rs | 2+-
Mcrates/notedeck_dave/src/ui/session_list.rs | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
8 files changed, 164 insertions(+), 13 deletions(-)

diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -1059,6 +1059,9 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr SessionListAction::Delete(id) => { self.delete_session(id); } + SessionListAction::Rename(id, new_title) => { + self.rename_session(id, new_title); + } } } @@ -1093,6 +1096,9 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr SessionListAction::Delete(id) => { self.delete_session(id); } + SessionListAction::Rename(id, new_title) => { + self.rename_session(id, new_title); + } } } @@ -1319,7 +1325,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr }; for session in self.session_manager.iter_mut() { - if !session.state_dirty || session.is_remote() { + if !session.state_dirty { continue; } @@ -1339,6 +1345,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr session_events::build_session_state_event( &claude_sid, &session.details.title, + session.details.custom_title.as_deref(), &cwd, status, &self.hostname, @@ -1371,6 +1378,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr session_events::build_session_state_event( &info.claude_session_id, &info.title, + None, &info.cwd, "deleted", &self.hostname, @@ -1501,6 +1509,8 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr self.hostname.clone() }; + session.details.custom_title = state.custom_title.clone(); + // Use home_dir from the event for remote abbreviation if !state.home_dir.is_empty() { session.details.home_dir = state.home_dir.clone(); @@ -1631,14 +1641,23 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr if existing_ids.contains(claude_sid) { let ts = note.created_at(); let new_status = AgentStatus::from_status_str(status_str); + let new_custom_title = + session_events::get_tag_value(&note, "custom_title").map(|s| s.to_string()); for session in self.session_manager.iter_mut() { - if session.is_remote() { - if let Some(agentic) = &mut session.agentic { - if agentic.event_session_id() == Some(claude_sid) - && ts > agentic.remote_status_ts - { + let is_remote = session.is_remote(); + if let Some(agentic) = &mut session.agentic { + if agentic.event_session_id() == Some(claude_sid) + && ts > agentic.remote_status_ts + { + agentic.remote_status_ts = ts; + // custom_title syncs for both local and remote + if new_custom_title.is_some() { + session.details.custom_title = new_custom_title.clone(); + } + // Status only updates for remote sessions (local + // sessions derive status from the actual process) + if is_remote { agentic.remote_status = new_status; - agentic.remote_status_ts = ts; } } } @@ -1676,6 +1695,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr if let Some(session) = self.session_manager.get_mut(dave_sid) { session.details.hostname = state.hostname.clone(); + session.details.custom_title = state.custom_title.clone(); if !state.home_dir.is_empty() { session.details.home_dir = state.home_dir.clone(); } @@ -1970,7 +1990,14 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr (remote_user_messages, events_to_publish) } - /// Delete a session and clean up backend resources + fn rename_session(&mut self, id: SessionId, new_title: String) { + let Some(session) = self.session_manager.get_mut(id) else { + return; + }; + session.details.custom_title = Some(new_title); + session.state_dirty = true; + } + fn delete_session(&mut self, id: SessionId) { // Capture session info before deletion so we can publish a "deleted" state event if let Some(session) = self.session_manager.get(id) { diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs @@ -30,6 +30,8 @@ pub enum SessionSource { /// Session metadata for display in chat headers pub struct SessionDetails { pub title: String, + /// User-set title that takes precedence over the auto-generated one. + pub custom_title: Option<String>, pub hostname: String, pub cwd: Option<PathBuf>, /// Home directory of the machine where this session originated. @@ -37,6 +39,13 @@ pub struct SessionDetails { pub home_dir: String, } +impl SessionDetails { + /// Returns custom_title if set, otherwise the auto-generated title. + pub fn display_title(&self) -> &str { + self.custom_title.as_deref().unwrap_or(&self.title) + } +} + /// Tracks the "Compact & Approve" lifecycle. /// /// Button click → `WaitingForCompaction` (intent recorded). @@ -357,6 +366,7 @@ impl ChatSession { source: SessionSource::Local, details: SessionDetails { title: "New Chat".to_string(), + custom_title: None, hostname: String::new(), cwd: details_cwd, home_dir: dirs::home_dir() diff --git a/crates/notedeck_dave/src/session_events.rs b/crates/notedeck_dave/src/session_events.rs @@ -649,9 +649,11 @@ pub fn build_permission_response_event( /// Published on every status change so remote clients and startup restore /// can discover active sessions. nostrdb auto-replaces older versions /// with same (kind, pubkey, d-tag). +#[allow(clippy::too_many_arguments)] pub fn build_session_state_event( claude_session_id: &str, title: &str, + custom_title: Option<&str>, cwd: &str, status: &str, hostname: &str, @@ -665,6 +667,9 @@ pub fn build_session_state_event( // Session metadata as tags builder = builder.start_tag().tag_str("title").tag_str(title); + if let Some(ct) = custom_title { + builder = builder.start_tag().tag_str("custom_title").tag_str(ct); + } builder = builder.start_tag().tag_str("cwd").tag_str(cwd); builder = builder.start_tag().tag_str("status").tag_str(status); builder = builder.start_tag().tag_str("hostname").tag_str(hostname); @@ -1116,6 +1121,7 @@ mod tests { let event = build_session_state_event( "sess-state-test", "Fix the login bug", + Some("My Custom Title"), "/tmp/project", "working", "my-laptop", diff --git a/crates/notedeck_dave/src/session_loader.rs b/crates/notedeck_dave/src/session_loader.rs @@ -240,6 +240,7 @@ pub fn load_session_messages(ndb: &Ndb, txn: &Transaction, session_id: &str) -> pub struct SessionState { pub claude_session_id: String, pub title: String, + pub custom_title: Option<String>, pub cwd: String, pub status: String, pub hostname: String, @@ -285,6 +286,7 @@ pub fn load_session_states(ndb: &Ndb, txn: &Transaction) -> Vec<SessionState> { title: get_tag_value(&note, "title") .unwrap_or("Untitled") .to_string(), + custom_title: get_tag_value(&note, "custom_title").map(|s| s.to_string()), cwd: get_tag_value(&note, "cwd").unwrap_or("").to_string(), status: get_tag_value(&note, "status").unwrap_or("idle").to_string(), hostname: get_tag_value(&note, "hostname").unwrap_or("").to_string(), @@ -329,6 +331,7 @@ pub fn latest_valid_session( title: get_tag_value(note, "title") .unwrap_or("Untitled") .to_string(), + custom_title: get_tag_value(note, "custom_title").map(|s| s.to_string()), cwd: get_tag_value(note, "cwd").unwrap_or("").to_string(), status: get_tag_value(note, "status").unwrap_or("idle").to_string(), hostname: get_tag_value(note, "hostname").unwrap_or("").to_string(), diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs @@ -1374,7 +1374,7 @@ fn session_header_ui(ui: &mut egui::Ui, details: &SessionDetails) { ui.vertical(|ui| { ui.spacing_mut().item_spacing.y = 1.0; ui.add( - egui::Label::new(egui::RichText::new(&details.title).size(13.0)) + egui::Label::new(egui::RichText::new(details.display_title()).size(13.0)) .wrap_mode(egui::TextWrapMode::Truncate), ); if let Some(cwd) = &details.cwd { diff --git a/crates/notedeck_dave/src/ui/mod.rs b/crates/notedeck_dave/src/ui/mod.rs @@ -263,7 +263,7 @@ pub fn scene_ui( .show(ui, |ui| { if let Some(selected_id) = scene.primary_selection() { if let Some(session) = session_manager.get_mut(selected_id) { - ui.heading(&session.details.title); + ui.heading(session.details.display_title()); ui.separator(); let response = build_dave_ui( diff --git a/crates/notedeck_dave/src/ui/scene.rs b/crates/notedeck_dave/src/ui/scene.rs @@ -165,7 +165,7 @@ impl AgentScene { let keybind_number = keybind_idx + 1; // 1-indexed for display let position = agentic.scene_position; let status = session.status(); - let title = &session.details.title; + let title = session.details.display_title(); let is_selected = selected_ids.contains(&id); let queue_priority = focus_queue.get_session_priority(id); diff --git a/crates/notedeck_dave/src/ui/session_list.rs b/crates/notedeck_dave/src/ui/session_list.rs @@ -15,6 +15,7 @@ pub enum SessionListAction { NewSession, SwitchTo(SessionId), Delete(SessionId), + Rename(SessionId, String), } /// UI component for displaying the session list sidebar @@ -153,9 +154,22 @@ impl<'a> SessionListUi<'a> { let empty_path = PathBuf::new(); let cwd = session.cwd().unwrap_or(&empty_path); + let rename_id = egui::Id::new("session_rename_state"); + let mut renaming: Option<(SessionId, String)> = + ui.data(|d| d.get_temp::<(SessionId, String)>(rename_id)); + let is_renaming = renaming + .as_ref() + .map(|(id, _)| *id == session.id) + .unwrap_or(false); + + let display_title = if is_renaming { + "" + } else { + session.details.display_title() + }; let response = self.session_item_ui( ui, - &session.details.title, + display_title, cwd, &session.details.home_dir, is_active, @@ -166,10 +180,57 @@ impl<'a> SessionListUi<'a> { ); let mut action = None; - if response.clicked() { + + if is_renaming { + let outcome = renaming + .as_mut() + .and_then(|(_, buf)| inline_rename_ui(ui, &response, buf)); + match outcome { + Some(RenameOutcome::Confirmed(title)) => { + action = Some(SessionListAction::Rename(session.id, title)); + ui.data_mut(|d| d.remove_by_type::<(SessionId, String)>()); + } + Some(RenameOutcome::Cancelled) => { + ui.data_mut(|d| d.remove_by_type::<(SessionId, String)>()); + } + None => { + if let Some(r) = renaming { + ui.data_mut(|d| d.insert_temp(rename_id, r)); + } + } + } + } else if response.clicked() { action = Some(SessionListAction::SwitchTo(session.id)); } + + // Long-press to rename (mobile) + if !is_renaming { + let press_id = egui::Id::new("session_long_press"); + if response.is_pointer_button_down_on() { + let now = ui.input(|i| i.time); + let start: Option<PressStart> = ui.data(|d| d.get_temp(press_id)); + if start.is_none() { + ui.data_mut(|d| d.insert_temp(press_id, PressStart(now))); + } else if let Some(s) = start { + if now - s.0 > 0.5 { + let rename_state = + (session.id, session.details.display_title().to_string()); + ui.data_mut(|d| d.insert_temp(rename_id, rename_state)); + ui.data_mut(|d| d.remove_by_type::<PressStart>()); + } + } + } else { + ui.data_mut(|d| d.remove_by_type::<PressStart>()); + } + } + response.context_menu(|ui| { + if ui.button("Rename").clicked() { + let rename_state = (session.id, session.details.display_title().to_string()); + ui.ctx() + .data_mut(|d| d.insert_temp(rename_id, rename_state)); + ui.close_menu(); + } if ui.button("Delete").clicked() { action = Some(SessionListAction::Delete(session.id)); ui.close_menu(); @@ -294,6 +355,50 @@ impl<'a> SessionListUi<'a> { } } +#[derive(Clone, Copy)] +struct PressStart(f64); + +enum RenameOutcome { + Confirmed(String), + Cancelled, +} + +fn inline_rename_ui( + ui: &mut egui::Ui, + response: &egui::Response, + buf: &mut String, +) -> Option<RenameOutcome> { + let edit_rect = response.rect.shrink2(egui::vec2(8.0, 4.0)); + let edit = egui::Area::new(egui::Id::new("rename_textedit")) + .fixed_pos(edit_rect.min) + .order(egui::Order::Foreground) + .show(ui.ctx(), |ui| { + ui.set_width(edit_rect.width()); + ui.add( + egui::TextEdit::singleline(buf) + .font(egui::FontId::proportional(14.0)) + .frame(false), + ) + }) + .inner; + + if !edit.has_focus() && !edit.lost_focus() { + edit.request_focus(); + } + + if edit.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + Some(RenameOutcome::Confirmed(buf.clone())) + } else if edit.lost_focus() { + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + Some(RenameOutcome::Cancelled) + } else { + Some(RenameOutcome::Confirmed(buf.clone())) + } + } else { + None + } +} + /// Draw cwd text (monospace, weak+small) with clipping. fn cwd_ui(ui: &mut egui::Ui, cwd_path: &Path, home_dir: &str, pos: egui::Pos2, max_width: f32) { let display_text = if home_dir.is_empty() {