notedeck

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

commit 00e1437fc22acc4ddc98ef9ccfca03a0a3d5a991
parent 172743f0a6f2f5f039ed176bc77bd4a93ee8087c
Author: William Casarin <jb55@jb55.com>
Date:   Mon, 16 Feb 2026 11:18:49 -0800

session_events: skip redundant archive conversion, fix threading root selection

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Diffstat:
Mcrates/notedeck_dave/src/lib.rs | 118++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mcrates/notedeck_dave/src/session_events.rs | 19++++++++++++++-----
Mcrates/notedeck_dave/src/session_loader.rs | 14++++++++++++--
3 files changed, 107 insertions(+), 44 deletions(-)

diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -1033,47 +1033,91 @@ impl notedeck::App for Dave { // Process pending archive conversion (JSONL → nostr events) if let Some((file_path, dave_sid, claude_sid)) = self.pending_archive_convert.take() { - let keypair = ctx.accounts.get_selected_account().keypair(); - if let Some(sk) = keypair.secret_key { - // Subscribe for 1988 events BEFORE ingesting so we catch them - let filter = nostrdb::Filter::new() - .kinds([session_events::AI_CONVERSATION_KIND as u64]) - .tags([claude_sid.as_str()], 'd') - .build(); - - match ctx.ndb.subscribe(&[filter]) { - Ok(sub) => { - let sb = sk.as_secret_bytes(); - let secret_bytes: [u8; 32] = - sb.try_into().expect("secret key is 32 bytes"); - match session_converter::convert_session_to_events( - &file_path, - ctx.ndb, - &secret_bytes, - ) { - Ok(note_ids) => { - tracing::info!( - "archived session: {} events from {}, awaiting indexing", - note_ids.len(), - file_path.display() - ); - self.pending_message_load = Some(PendingMessageLoad { - sub, - dave_session_id: dave_sid, - claude_session_id: claude_sid, - }); - } - Err(e) => { - tracing::error!("archive conversion failed: {}", e); - } + // Check if events already exist for this session in ndb + let txn = Transaction::new(ctx.ndb).expect("txn"); + let filter = nostrdb::Filter::new() + .kinds([session_events::AI_CONVERSATION_KIND as u64]) + .tags([claude_sid.as_str()], 'd') + .limit(1) + .build(); + let already_exists = ctx + .ndb + .query(&txn, &[filter], 1) + .map(|r| !r.is_empty()) + .unwrap_or(false); + drop(txn); + + if already_exists { + // Events already in ndb (from previous conversion or live events). + // Skip archive conversion and load directly. + tracing::info!( + "session {} already has events in ndb, skipping archive conversion", + claude_sid + ); + let loaded_txn = Transaction::new(ctx.ndb).expect("txn"); + let loaded = session_loader::load_session_messages( + ctx.ndb, + &loaded_txn, + &claude_sid, + ); + if let Some(session) = self.session_manager.get_mut(dave_sid) { + tracing::info!("loaded {} messages into chat UI", loaded.messages.len()); + session.chat = loaded.messages; + + if let (Some(root), Some(last)) = + (loaded.root_note_id, loaded.last_note_id) + { + if let Some(agentic) = &mut session.agentic { + agentic.live_threading.seed(root, last, loaded.event_count); } } - Err(e) => { - tracing::error!("failed to subscribe for archive events: {:?}", e); - } } } else { - tracing::warn!("no secret key available for archive conversion"); + let keypair = ctx.accounts.get_selected_account().keypair(); + if let Some(sk) = keypair.secret_key { + // Subscribe for 1988 events BEFORE ingesting so we catch them + let sub_filter = nostrdb::Filter::new() + .kinds([session_events::AI_CONVERSATION_KIND as u64]) + .tags([claude_sid.as_str()], 'd') + .build(); + + match ctx.ndb.subscribe(&[sub_filter]) { + Ok(sub) => { + let sb = sk.as_secret_bytes(); + let secret_bytes: [u8; 32] = + sb.try_into().expect("secret key is 32 bytes"); + match session_converter::convert_session_to_events( + &file_path, + ctx.ndb, + &secret_bytes, + ) { + Ok(note_ids) => { + tracing::info!( + "archived session: {} events from {}, awaiting indexing", + note_ids.len(), + file_path.display() + ); + self.pending_message_load = Some(PendingMessageLoad { + sub, + dave_session_id: dave_sid, + claude_session_id: claude_sid, + }); + } + Err(e) => { + tracing::error!("archive conversion failed: {}", e); + } + } + } + Err(e) => { + tracing::error!( + "failed to subscribe for archive events: {:?}", + e + ); + } + } + } else { + tracing::warn!("no secret key available for archive conversion"); + } } } diff --git a/crates/notedeck_dave/src/session_events.rs b/crates/notedeck_dave/src/session_events.rs @@ -116,8 +116,12 @@ impl ThreadingState { } /// Record a built event's note ID, associated with a JSONL uuid. - fn record(&mut self, uuid: Option<&str>, note_id: [u8; 32]) { - if self.root_note_id.is_none() { + /// + /// `can_be_root`: if true, this event may become the conversation root. + /// Metadata events (queue-operation, progress, etc.) should pass false + /// so they don't become the root of the threading chain. + pub fn record(&mut self, uuid: Option<&str>, note_id: [u8; 32], can_be_root: bool) { + if can_be_root && self.root_note_id.is_none() { self.root_note_id = Some(note_id); } if let Some(uuid) = uuid { @@ -128,6 +132,11 @@ impl ThreadingState { } } +/// Whether a role represents a conversation message (not metadata). +pub fn is_conversation_role(role: &str) -> bool { + matches!(role, "user" | "assistant" | "tool_call" | "tool_result") +} + /// Build nostr events from a single JSONL line. /// /// Returns one or more events. Assistant messages with mixed content blocks @@ -188,7 +197,7 @@ pub fn build_events( threading, secret_key, )?; - threading.record(line.uuid(), event.note_id); + threading.record(line.uuid(), event.note_id, is_conversation_role(role)); events.push(event); } events @@ -224,7 +233,7 @@ pub fn build_events( threading, secret_key, )?; - threading.record(line.uuid(), event.note_id); + threading.record(line.uuid(), event.note_id, is_conversation_role(role)); vec![event] }; @@ -481,7 +490,7 @@ pub fn build_live_event( secret_key, )?; - threading.record(None, event.note_id); + threading.record(None, event.note_id, true); Ok(event) } diff --git a/crates/notedeck_dave/src/session_loader.rs b/crates/notedeck_dave/src/session_loader.rs @@ -5,7 +5,7 @@ //! for populating the chat UI. use crate::messages::{AssistantMessage, ToolResult}; -use crate::session_events::{get_tag_value, AI_CONVERSATION_KIND}; +use crate::session_events::{get_tag_value, is_conversation_role, AI_CONVERSATION_KIND}; use crate::Message; use nostrdb::{Filter, Ndb, Transaction}; @@ -54,7 +54,17 @@ pub fn load_session_messages(ndb: &Ndb, txn: &Transaction, session_id: &str) -> notes.sort_by_key(|note| note.created_at()); let event_count = notes.len() as u32; - let root_note_id = notes.first().map(|n| *n.id()); + + // Find the first conversation note (skip metadata like queue-operation) + // so the threading root is a real message. + let root_note_id = notes + .iter() + .find(|n| { + get_tag_value(n, "role") + .map(is_conversation_role) + .unwrap_or(false) + }) + .map(|n| *n.id()); let last_note_id = notes.last().map(|n| *n.id()); let mut messages = Vec::new();