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:
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(¬e, "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(¬e, "title")
.unwrap_or("Untitled")
.to_string(),
+ custom_title: get_tag_value(¬e, "custom_title").map(|s| s.to_string()),
cwd: get_tag_value(¬e, "cwd").unwrap_or("").to_string(),
status: get_tag_value(¬e, "status").unwrap_or("idle").to_string(),
hostname: get_tag_value(¬e, "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() {