notedeck

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

commit 1bda26c904b338bb3fbaeb211553590ec6a41345
parent d944ad79196dfd53ef453ace3b598511fa1728d6
Author: William Casarin <jb55@jb55.com>
Date:   Fri, 20 Feb 2026 13:06:06 -0800

Merge remote-tracking branches 'monad/columns', 'monad/dave' and 'monad/dave-dev'

Diffstat:
Mcrates/notedeck/src/lib.rs | 2+-
Mcrates/notedeck_chrome/src/chrome.rs | 1+
Mcrates/notedeck_columns/src/draft.rs | 2++
Mcrates/notedeck_columns/src/ui/add_column.rs | 114++++++++++++++++++-------------------------------------------------------------
Mcrates/notedeck_columns/src/ui/mentions_picker.rs | 110++++++++++++++++---------------------------------------------------------------
Mcrates/notedeck_columns/src/ui/note/post.rs | 47++++++++++++++++++++++++++++++++---------------
Mcrates/notedeck_columns/src/ui/search/mod.rs | 28+++++++++++++++++-----------
Mcrates/notedeck_columns/src/ui/search/state.rs | 9+++++++++
Mcrates/notedeck_dave/src/config.rs | 5+++--
Mcrates/notedeck_dave/src/lib.rs | 39++++++++++++++++++++++++++++++++-------
Mcrates/notedeck_dave/src/ui/mod.rs | 3++-
Mcrates/notedeck_dave/src/update.rs | 9+++------
Mcrates/notedeck_ui/src/contacts_list.rs | 1+
13 files changed, 150 insertions(+), 220 deletions(-)

diff --git a/crates/notedeck/src/lib.rs b/crates/notedeck/src/lib.rs @@ -36,7 +36,7 @@ mod style; pub mod theme; mod time; mod timecache; -mod timed_serializer; +pub mod timed_serializer; pub mod ui; mod unknowns; mod urls; diff --git a/crates/notedeck_chrome/src/chrome.rs b/crates/notedeck_chrome/src/chrome.rs @@ -159,6 +159,7 @@ impl Chrome { cc.wgpu_render_state.as_ref(), context.ndb.clone(), cc.egui_ctx.clone(), + context.path, ); let mut chrome = Chrome::default(); diff --git a/crates/notedeck_columns/src/draft.rs b/crates/notedeck_columns/src/draft.rs @@ -7,6 +7,7 @@ use crate::{ ui::{note::PostType, search::FocusState}, Error, }; +use notedeck_ui::ProfileSearchResult; use std::collections::HashMap; #[derive(Default)] @@ -24,6 +25,7 @@ pub struct MentionHint { pub index: usize, pub pos: egui::Pos2, pub text: String, + pub results: Vec<ProfileSearchResult>, } #[derive(Default)] diff --git a/crates/notedeck_columns/src/ui/add_column.rs b/crates/notedeck_columns/src/ui/add_column.rs @@ -27,7 +27,7 @@ use tokenator::{ParseError, TokenParser, TokenSerializable, TokenWriter}; use crate::ui::widgets::styled_button; use notedeck_ui::{ anim::AnimationHelper, padding, profile_row, search_input_box, search_profiles, - ContactsListView, ProfilePreview, + ContactsListView, }; pub enum AddColumnResponse { @@ -241,10 +241,7 @@ impl<'a> AddColumnView<'a> { } fn external_notification_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> { - let id = ui.id().with("external_notif"); - self.external_ui(ui, id, |pubkey| { - AddColumnOption::Notification(PubkeySource::Explicit(pubkey)) - }) + self.external_search_ui(ui, "external_notif", notification_column_response) } fn algo_last_per_pk_ui( @@ -308,7 +305,16 @@ impl<'a> AddColumnView<'a> { } fn external_individual_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> { - let id = ui.id().with("external_individual"); + self.external_search_ui(ui, "external_individual", individual_column_response) + } + + fn external_search_ui( + &mut self, + ui: &mut Ui, + id_salt: &str, + to_response: fn(Pubkey, &UserAccount) -> AddColumnResponse, + ) -> Option<AddColumnResponse> { + let id = ui.id().with(id_salt); ui.add_space(8.0); let hint = tr!( @@ -337,6 +343,7 @@ impl<'a> AddColumnView<'a> { self.jobs, self.i18n, self.cur_account, + to_response, ) } else if query.is_empty() { self.key_state_map.remove(&id); @@ -348,6 +355,7 @@ impl<'a> AddColumnView<'a> { self.img_cache, self.i18n, self.cur_account, + to_response, ) } else { self.key_state_map.remove(&id); @@ -360,79 +368,11 @@ impl<'a> AddColumnView<'a> { self.jobs, self.i18n, self.cur_account, + to_response, ) } } - fn external_ui( - &mut self, - ui: &mut Ui, - id: egui::Id, - to_option: fn(Pubkey) -> AddColumnOption, - ) -> Option<AddColumnResponse> { - padding(16.0, ui, |ui| { - let key_state = self.key_state_map.entry(id).or_default(); - - let text_edit = key_state.get_acquire_textedit(|text| { - egui::TextEdit::singleline(text) - .hint_text( - RichText::new(tr!( - self.i18n, - "Enter the user's key (npub, hex, nip05) here...", - "Hint text to prompt entering the user's public key." - )) - .text_style(NotedeckTextStyle::Body.text_style()), - ) - .vertical_align(Align::Center) - .desired_width(f32::INFINITY) - .min_size(Vec2::new(0.0, 40.0)) - .margin(Margin::same(12)) - }); - - ui.add(text_edit); - - key_state.handle_input_change_after_acquire(); - key_state.loading_and_error_ui(ui, self.i18n); - - if key_state.get_login_keypair().is_none() - && ui.add(find_user_button(self.i18n)).clicked() - { - key_state.apply_acquire(); - } - - let resp = if let Some(keypair) = key_state.get_login_keypair() { - { - let txn = Transaction::new(self.ndb).expect("txn"); - if let Ok(profile) = - self.ndb.get_profile_by_pubkey(&txn, keypair.pubkey.bytes()) - { - egui::Frame::window(ui.style()) - .outer_margin(Margin { - left: 4, - right: 4, - top: 12, - bottom: 32, - }) - .show(ui, |ui| { - ProfilePreview::new(&profile, self.img_cache, self.jobs).ui(ui); - }); - } - } - - ui.add(add_column_button(self.i18n)) - .clicked() - .then(|| to_option(keypair.pubkey).take_as_response(self.cur_account)) - } else { - None - }; - if resp.is_some() { - self.key_state_map.remove(&id); - }; - resp - }) - .inner - } - fn column_option_ui(&mut self, ui: &mut Ui, data: ColumnOptionData) -> egui::Response { let icon_padding = 8.0; let min_icon_width = 32.0; @@ -685,12 +625,6 @@ impl<'a> AddColumnView<'a> { } } -fn find_user_button(i18n: &mut Localization) -> impl Widget { - let label = tr!(i18n, "Find User", "Label for find user button"); - let color = notedeck_ui::colors::PINK; - move |ui: &mut egui::Ui| styled_button(label.as_str(), color).ui(ui) -} - fn add_column_button(i18n: &mut Localization) -> impl Widget { let label = tr!(i18n, "Add", "Label for add column button"); let color = notedeck_ui::colors::PINK; @@ -701,6 +635,10 @@ fn individual_column_response(pubkey: Pubkey, cur_account: &UserAccount) -> AddC AddColumnOption::Individual(PubkeySource::Explicit(pubkey)).take_as_response(cur_account) } +fn notification_column_response(pubkey: Pubkey, cur_account: &UserAccount) -> AddColumnResponse { + AddColumnOption::Notification(PubkeySource::Explicit(pubkey)).take_as_response(cur_account) +} + #[allow(clippy::too_many_arguments)] fn nip05_profile_ui( ui: &mut Ui, @@ -712,6 +650,7 @@ fn nip05_profile_ui( jobs: &MediaJobSender, i18n: &mut Localization, cur_account: &UserAccount, + to_response: fn(Pubkey, &UserAccount) -> AddColumnResponse, ) -> Option<AddColumnResponse> { let key_state = key_state_map.entry(id).or_default(); @@ -730,7 +669,7 @@ fn nip05_profile_ui( let profile = ndb.get_profile_by_pubkey(&txn, keypair.pubkey.bytes()).ok(); profile_row(ui, profile.as_ref(), false, img_cache, jobs, i18n) - .then(|| individual_column_response(keypair.pubkey, cur_account)) + .then(|| to_response(keypair.pubkey, cur_account)) } else { None }; @@ -750,6 +689,7 @@ fn contacts_list_column_ui( img_cache: &mut Images, i18n: &mut Localization, cur_account: &UserAccount, + to_response: fn(Pubkey, &UserAccount) -> AddColumnResponse, ) -> Option<AddColumnResponse> { let ContactState::Received { contacts: contact_set, @@ -763,9 +703,7 @@ fn contacts_list_column_ui( let resp = ContactsListView::new(contact_set, jobs, ndb, img_cache, &txn, i18n).ui(ui); resp.output.map(|a| match a { - notedeck_ui::ContactsListAction::Select(pubkey) => { - individual_column_response(pubkey, cur_account) - } + notedeck_ui::ContactsListAction::Select(pubkey) => to_response(pubkey, cur_account), }) } @@ -779,6 +717,7 @@ fn profile_search_column_ui( jobs: &MediaJobSender, i18n: &mut Localization, cur_account: &UserAccount, + to_response: fn(Pubkey, &UserAccount) -> AddColumnResponse, ) -> Option<AddColumnResponse> { let txn = Transaction::new(ndb).expect("txn"); let results = search_profiles(ndb, &txn, query, contacts, 128); @@ -808,10 +747,7 @@ fn profile_search_column_ui( jobs, i18n, ) { - action = Some(individual_column_response( - Pubkey::new(result.pk), - cur_account, - )); + action = Some(to_response(Pubkey::new(result.pk), cur_account)); } } }); diff --git a/crates/notedeck_columns/src/ui/mentions_picker.rs b/crates/notedeck_columns/src/ui/mentions_picker.rs @@ -1,14 +1,7 @@ -use egui::{vec2, FontId, Layout, Pos2, Rect, ScrollArea, UiBuilder, Vec2b}; -use nostrdb::{Ndb, ProfileRecord, Transaction}; -use notedeck::{ - fonts::get_font_size, name::get_display_name, profile::get_profile_url, DragResponse, Images, - MediaJobSender, NotedeckTextStyle, -}; -use notedeck_ui::{ - anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, - widgets::x_button, - ProfilePic, -}; +use egui::{vec2, Layout, ScrollArea, UiBuilder, Vec2b}; +use nostrdb::{Ndb, Transaction}; +use notedeck::{DragResponse, Images, Localization, MediaJobSender}; +use notedeck_ui::{profile_row, widgets::x_button, ProfileSearchResult}; use tracing::error; /// Displays user profiles for the user to pick from. @@ -17,8 +10,9 @@ pub struct MentionPickerView<'a> { ndb: &'a Ndb, txn: &'a Transaction, img_cache: &'a mut Images, - results: &'a Vec<&'a [u8; 32]>, + results: &'a [ProfileSearchResult], jobs: &'a MediaJobSender, + i18n: &'a mut Localization, } pub enum MentionPickerResponse { @@ -31,8 +25,9 @@ impl<'a> MentionPickerView<'a> { img_cache: &'a mut Images, ndb: &'a Ndb, txn: &'a Transaction, - results: &'a Vec<&'a [u8; 32]>, + results: &'a [ProfileSearchResult], jobs: &'a MediaJobSender, + i18n: &'a mut Localization, ) -> Self { Self { ndb, @@ -40,25 +35,30 @@ impl<'a> MentionPickerView<'a> { img_cache, results, jobs, + i18n, } } - fn show(&mut self, ui: &mut egui::Ui, width: f32) -> MentionPickerResponse { + fn show(&mut self, ui: &mut egui::Ui) -> MentionPickerResponse { let mut selection = None; ui.vertical(|ui| { for (i, res) in self.results.iter().enumerate() { - let profile = match self.ndb.get_profile_by_pubkey(self.txn, res) { + let profile = match self.ndb.get_profile_by_pubkey(self.txn, &res.pk) { Ok(rec) => rec, Err(e) => { - error!("Error fetching profile for pubkey {:?}: {e}", res); + error!("Error fetching profile for pubkey {:?}: {e}", res.pk); return; } }; - if ui - .add(user_result(&profile, self.img_cache, self.jobs, i, width)) - .clicked() - { + if profile_row( + ui, + Some(&profile), + res.is_contact, + self.img_cache, + self.jobs, + self.i18n, + ) { selection = Some(i) } } @@ -82,11 +82,10 @@ impl<'a> MentionPickerView<'a> { egui::Frame::NONE .fill(ui.visuals().panel_fill) .show(ui, |ui| { - let width = rect.width() - (2.0 * inner_margin_size); - ui.allocate_space(vec2(ui.available_width(), inner_margin_size)); let close_button_resp = { let close_button_size = 16.0; + let width = rect.width() - (2.0 * inner_margin_size); let (close_section_rect, _) = ui.allocate_exact_size( vec2(width, close_button_size), egui::Sense::hover(), @@ -109,7 +108,7 @@ impl<'a> MentionPickerView<'a> { let scroll_resp = ScrollArea::vertical() .max_width(rect.width()) .auto_shrink(Vec2b::FALSE) - .show(ui, |ui| Some(self.show(ui, width))); + .show(ui, |ui| Some(self.show(ui))); ui.advance_cursor_after_rect(rect); DragResponse::scroll(scroll_resp).map_output(|o| { @@ -126,68 +125,3 @@ impl<'a> MentionPickerView<'a> { area_resp.inner } } - -fn user_result<'a>( - profile: &'a ProfileRecord<'_>, - cache: &'a mut Images, - jobs: &'a MediaJobSender, - index: usize, - width: f32, -) -> impl egui::Widget + 'a { - move |ui: &mut egui::Ui| -> egui::Response { - let min_img_size = 48.0; - let max_image = min_img_size * ICON_EXPANSION_MULTIPLE; - let spacing = 8.0; - let body_font_size = get_font_size(ui.ctx(), &NotedeckTextStyle::Body); - - let animation_rect = { - let max_width = ui.available_width(); - let extra_width = (max_width - width) / 2.0; - let left = ui.cursor().left(); - let (rect, _) = - ui.allocate_exact_size(vec2(width + extra_width, max_image), egui::Sense::click()); - - let (_, right) = rect.split_left_right_at_x(left + extra_width); - right - }; - - let helper = AnimationHelper::new_from_rect(ui, ("user_result", index), animation_rect); - - let icon_rect = { - let r = helper.get_animation_rect(); - let mut center = r.center(); - center.x = r.left() + (max_image / 2.0); - let size = helper.scale_1d_pos(min_img_size); - Rect::from_center_size(center, vec2(size, size)) - }; - - let pfp_resp = ui.put( - icon_rect, - &mut ProfilePic::new(cache, jobs, get_profile_url(Some(profile))) - .size(helper.scale_1d_pos(min_img_size)), - ); - - let name_font = FontId::new( - helper.scale_1d_pos(body_font_size), - NotedeckTextStyle::Body.font_family(), - ); - let painter = ui.painter_at(helper.get_animation_rect()); - let name_galley = painter.layout( - get_display_name(Some(profile)).name().to_owned(), - name_font, - ui.visuals().text_color(), - width, - ); - - let galley_pos = { - let right_top = pfp_resp.rect.right_top(); - let galley_pos_y = pfp_resp.rect.center().y - (name_galley.rect.height() / 2.0); - Pos2::new(right_top.x + spacing, galley_pos_y) - }; - - painter.galley(galley_pos, name_galley, ui.visuals().text_color()); - ui.advance_cursor_after_rect(helper.get_animation_rect()); - - pfp_resp.union(helper.take_animation_response()) - } -} diff --git a/crates/notedeck_columns/src/ui/note/post.rs b/crates/notedeck_columns/src/ui/note/post.rs @@ -25,7 +25,7 @@ use notedeck_ui::{ app_images, context_menu::{input_context, PasteBehavior}, note::render_note_preview, - NoteOptions, ProfilePic, + search_profiles, NoteOptions, ProfilePic, }; use tracing::error; #[cfg(not(target_os = "android"))] @@ -278,20 +278,39 @@ impl<'a, 'd> PostView<'a, 'd> { let mention_str = self.draft.buffer.get_mention_string(&mention); if !mention_str.is_empty() { - if let Some(mention_hint) = &mut self.draft.cur_mention_hint { - if mention_hint.index != mention.index { - mention_hint.index = mention.index; - mention_hint.pos = + let text_changed; + if let Some(hint) = &mut self.draft.cur_mention_hint { + text_changed = hint.text != mention_str; + if hint.index != mention.index { + hint.index = mention.index; + hint.pos = calculate_mention_hints_pos(textedit_output, mention.info.start_index); } - mention_hint.text = mention_str.to_owned(); + if text_changed { + hint.text = mention_str.to_owned(); + } } else { + text_changed = true; self.draft.cur_mention_hint = Some(MentionHint { index: mention.index, text: mention_str.to_owned(), pos: calculate_mention_hints_pos(textedit_output, mention.info.start_index), + results: Vec::new(), }); } + + if text_changed { + let contacts = self + .note_context + .accounts + .get_selected_account() + .data + .contacts + .get_state(); + let hint = self.draft.cur_mention_hint.as_mut().unwrap(); + hint.results = + search_profiles(self.note_context.ndb, txn, &hint.text, contacts, 128); + } } let hint_rect = { @@ -304,18 +323,15 @@ impl<'a, 'd> PostView<'a, 'd> { hint_rect }; - let res = self - .note_context - .ndb - .search_profile(txn, mention_str, 10) - .ok()?; + let hint = self.draft.cur_mention_hint.as_ref().unwrap(); let resp = MentionPickerView::new( self.note_context.img_cache, self.note_context.ndb, txn, - &res, + &hint.results, self.note_context.jobs, + self.note_context.i18n, ) .show_in_rect(hint_rect, ui); @@ -328,14 +344,15 @@ impl<'a, 'd> PostView<'a, 'd> { match out { ui::mentions_picker::MentionPickerResponse::SelectResult(selection) => { if let Some(hint_index) = selection { - if let Some(pk) = res.get(hint_index) { - let record = self.note_context.ndb.get_profile_by_pubkey(txn, pk); + let hint = self.draft.cur_mention_hint.as_ref().unwrap(); + if let Some(result) = hint.results.get(hint_index) { + let record = self.note_context.ndb.get_profile_by_pubkey(txn, &result.pk); if let Some(made_selection) = self.draft.buffer.select_mention_and_replace_name( mention.index, get_display_name(record.ok().as_ref()).name(), - Pubkey::new(**pk), + Pubkey::new(result.pk), ) { selection_made = Some(made_selection); diff --git a/crates/notedeck_columns/src/ui/search/mod.rs b/crates/notedeck_columns/src/ui/search/mod.rs @@ -164,21 +164,27 @@ impl<'a, 'd> SearchView<'a, 'd> { return; }; - 's: { - let Ok(results) = self + if self.query.last_mention_query != mention_name { + let contacts = self .note_context - .ndb - .search_profile(self.txn, mention_name, 10) - else { - break 's; - }; + .accounts + .get_selected_account() + .data + .contacts + .get_state(); + self.query.mention_results = + search_profiles(self.note_context.ndb, self.txn, mention_name, contacts, 128); + self.query.last_mention_query = mention_name.to_owned(); + } + 's: { let search_res = MentionPickerView::new( self.note_context.img_cache, self.note_context.ndb, self.txn, - &results, + &self.query.mention_results, self.note_context.jobs, + self.note_context.i18n, ) .show_in_rect(ui.available_rect_before_wrap(), ui); @@ -188,20 +194,20 @@ impl<'a, 'd> SearchView<'a, 'd> { *search_action = match res { MentionPickerResponse::SelectResult(Some(index)) => { - let Some(pk_bytes) = results.get(index) else { + let Some(result) = self.query.mention_results.get(index) else { break 's; }; let username = self .note_context .ndb - .get_profile_by_pubkey(self.txn, pk_bytes) + .get_profile_by_pubkey(self.txn, &result.pk) .ok() .and_then(|p| p.record().profile().and_then(|p| p.name())) .unwrap_or(&self.query.string); Some(SearchAction::NewSearch { - search_type: SearchType::Profile(Pubkey::new(**pk_bytes)), + search_type: SearchType::Profile(Pubkey::new(result.pk)), new_search_text: format!("@{username}"), }) } diff --git a/crates/notedeck_columns/src/ui/search/state.rs b/crates/notedeck_columns/src/ui/search/state.rs @@ -1,5 +1,6 @@ use crate::timeline::TimelineTab; use enostr::Pubkey; +use notedeck_ui::ProfileSearchResult; use super::SearchType; @@ -60,6 +61,12 @@ pub struct SearchQueryState { /// Recent search history (most recent first, max 10) pub recent_searches: Vec<RecentSearchItem>, + + /// Cached @mention search results + pub mention_results: Vec<ProfileSearchResult>, + + /// The query string that produced `mention_results` + pub last_mention_query: String, } impl Default for SearchQueryState { @@ -78,6 +85,8 @@ impl SearchQueryState { selected_index: -1, user_results: Vec::new(), recent_searches: Vec::new(), + mention_results: Vec::new(), + last_mention_query: String::new(), } } diff --git a/crates/notedeck_dave/src/config.rs b/crates/notedeck_dave/src/config.rs @@ -1,5 +1,6 @@ use crate::backend::BackendType; use async_openai::config::OpenAIConfig; +use serde::{Deserialize, Serialize}; /// AI interaction mode - determines UI complexity and feature set #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -11,7 +12,7 @@ pub enum AiMode { } /// Available AI providers for Dave -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] pub enum AiProvider { #[default] OpenAI, @@ -77,7 +78,7 @@ impl AiProvider { } /// User-configurable settings for Dave AI -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct DaveSettings { pub provider: AiProvider, pub model: String, diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -31,7 +31,10 @@ use egui_wgpu::RenderState; use enostr::KeypairUnowned; use focus_queue::FocusQueue; use nostrdb::{Subscription, Transaction}; -use notedeck::{try_process_events_core, ui::is_narrow, AppAction, AppContext, AppResponse}; +use notedeck::{ + timed_serializer::TimedSerializer, try_process_events_core, ui::is_narrow, AppAction, + AppContext, AppResponse, DataPath, DataPathType, +}; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::string::ToString; @@ -149,6 +152,8 @@ pub struct Dave { hostname: String, /// PNS relay URL (configurable via DAVE_RELAY env or settings UI). pns_relay_url: String, + /// Persists DaveSettings to dave_settings.json + settings_serializer: TimedSerializer<DaveSettings>, } use update::PermissionPublish; @@ -299,9 +304,25 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr )) } - pub fn new(render_state: Option<&RenderState>, ndb: nostrdb::Ndb, ctx: egui::Context) -> Self { - let model_config = ModelConfig::default(); - //let model_config = ModelConfig::ollama(); + pub fn new( + render_state: Option<&RenderState>, + ndb: nostrdb::Ndb, + ctx: egui::Context, + path: &DataPath, + ) -> Self { + let settings_serializer = + TimedSerializer::new(path, DataPathType::Setting, "dave_settings.json".to_owned()); + + // Load saved settings, falling back to env-var-based defaults + let (model_config, settings) = if let Some(saved_settings) = settings_serializer.get_item() + { + let config = ModelConfig::from_settings(&saved_settings); + (config, saved_settings) + } else { + let config = ModelConfig::default(); + let settings = DaveSettings::from_model_config(&config); + (config, settings) + }; // Determine AI mode from backend type let ai_mode = model_config.ai_mode(); @@ -329,7 +350,6 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr tools.insert(tool.name().to_string(), tool); } - let settings = DaveSettings::from_model_config(&model_config); let pns_relay_url = model_config .pns_relay .clone() @@ -388,6 +408,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr pending_summaries: Vec::new(), hostname, pns_relay_url, + settings_serializer, } } @@ -396,13 +417,15 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr &self.settings } - /// Apply new settings. Note: Provider changes require app restart to take effect. + /// Apply new settings and persist to disk. + /// Note: Provider changes require app restart to take effect. pub fn apply_settings(&mut self, settings: DaveSettings) { self.model_config = ModelConfig::from_settings(&settings); self.pns_relay_url = settings .pns_relay .clone() .unwrap_or_else(|| DEFAULT_PNS_RELAY.to_string()); + self.settings_serializer.try_save(settings.clone()); self.settings = settings; } @@ -927,7 +950,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr let (dave_response, view_action) = ui::scene_ui( &mut self.session_manager, &mut self.scene, - &self.focus_queue, + &mut self.focus_queue, &self.model_config, is_interrupt_pending, self.auto_steal_focus, @@ -981,6 +1004,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr SessionListAction::NewSession => return DaveResponse::new(DaveAction::NewChat), SessionListAction::SwitchTo(id) => { self.session_manager.switch_to(id); + self.focus_queue.dequeue(id); } SessionListAction::Delete(id) => { self.delete_session(id); @@ -1013,6 +1037,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } SessionListAction::SwitchTo(id) => { self.session_manager.switch_to(id); + self.focus_queue.dequeue(id); self.show_session_list = false; } SessionListAction::Delete(id) => { diff --git a/crates/notedeck_dave/src/ui/mod.rs b/crates/notedeck_dave/src/ui/mod.rs @@ -208,7 +208,7 @@ pub enum SceneViewAction { pub fn scene_ui( session_manager: &mut SessionManager, scene: &mut AgentScene, - focus_queue: &FocusQueue, + focus_queue: &mut FocusQueue, model_config: &ModelConfig, is_interrupt_pending: bool, auto_steal_focus: bool, @@ -294,6 +294,7 @@ pub fn scene_ui( SceneAction::SelectionChanged(ids) => { if let Some(id) = ids.first() { session_manager.switch_to(*id); + focus_queue.dequeue(*id); } } SceneAction::SpawnAgent => { diff --git a/crates/notedeck_dave/src/update.rs b/crates/notedeck_dave/src/update.rs @@ -574,17 +574,14 @@ pub fn process_auto_steal_focus( tracing::debug!("Auto-steal: saved home session {:?}", home_session); } - // Jump to first Done item and clear it from the queue + // Jump to first Done item (keep in queue so blue dot renders; + // cleared when user manually focuses the session) if let Some(idx) = focus_queue.first_done_index() { focus_queue.set_cursor(idx); if let Some(entry) = focus_queue.current() { let sid = entry.session_id; switch_and_focus_session(session_manager, scene, show_scene, sid); - focus_queue.dequeue(sid); - tracing::debug!( - "Auto-steal: switched to Done session {:?} and cleared indicator", - sid - ); + tracing::debug!("Auto-steal: switched to Done session {:?}", sid); return true; } } diff --git a/crates/notedeck_ui/src/contacts_list.rs b/crates/notedeck_ui/src/contacts_list.rs @@ -214,6 +214,7 @@ where } /// A profile search result. +#[derive(Debug)] pub struct ProfileSearchResult { /// The public key bytes of the matched profile. pub pk: [u8; 32],