notedeck

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

commit 172743f0a6f2f5f039ed176bc77bd4a93ee8087c
parent cb39bcb0e95e30b7b08d3dd4c18e9bbdf26b0cbc
Author: William Casarin <jb55@jb55.com>
Date:   Sun, 15 Feb 2026 23:51:23 -0800

session_events: seed live threading from archive events on resume

Live events were starting a new NIP-10 root instead of threading onto
the existing archive conversation. Seed ThreadingState with root/last
note IDs from loaded archive events so resumed sessions thread correctly.

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

Diffstat:
Mcrates/notedeck_dave/src/lib.rs | 16+++++++++++++---
Mcrates/notedeck_dave/src/session_events.rs | 10++++++++++
Mcrates/notedeck_dave/src/session_loader.rs | 36++++++++++++++++++++++++++++++++----
3 files changed, 55 insertions(+), 7 deletions(-)

diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -1082,14 +1082,24 @@ impl notedeck::App for Dave { let notes = ctx.ndb.poll_for_notes(pending.sub, 4096); if !notes.is_empty() { let txn = Transaction::new(ctx.ndb).expect("txn"); - let messages = session_loader::load_session_messages( + let loaded = session_loader::load_session_messages( ctx.ndb, &txn, &pending.claude_session_id, ); if let Some(session) = self.session_manager.get_mut(pending.dave_session_id) { - tracing::info!("loaded {} messages into chat UI", messages.len()); - session.chat = messages; + tracing::info!("loaded {} messages into chat UI", loaded.messages.len()); + session.chat = loaded.messages; + + // Seed live threading from archive events so new events + // thread as replies to the existing conversation. + 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); + } + } } self.pending_message_load = None; } diff --git a/crates/notedeck_dave/src/session_events.rs b/crates/notedeck_dave/src/session_events.rs @@ -105,6 +105,16 @@ impl ThreadingState { line.timestamp_secs().or(self.last_timestamp) } + /// Seed threading state from existing events (e.g. loaded from ndb). + /// + /// Sets root and last note IDs so that subsequent live events + /// thread correctly as replies to the existing conversation. + pub fn seed(&mut self, root_note_id: [u8; 32], last_note_id: [u8; 32], event_count: u32) { + self.root_note_id = Some(root_note_id); + self.last_note_id = Some(last_note_id); + self.seq = event_count; + } + /// 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() { diff --git a/crates/notedeck_dave/src/session_loader.rs b/crates/notedeck_dave/src/session_loader.rs @@ -9,11 +9,23 @@ use crate::session_events::{get_tag_value, AI_CONVERSATION_KIND}; use crate::Message; use nostrdb::{Filter, Ndb, Transaction}; +/// Result of loading session messages, including threading info for live events. +pub struct LoadedSession { + pub messages: Vec<Message>, + /// Root note ID of the conversation (first event chronologically). + pub root_note_id: Option<[u8; 32]>, + /// Last note ID of the conversation (most recent event). + pub last_note_id: Option<[u8; 32]>, + /// Total number of events found. + pub event_count: u32, +} + /// Load conversation messages from ndb for a given session ID. /// /// Returns messages in chronological order, suitable for populating -/// `ChatSession.chat` before streaming begins. -pub fn load_session_messages(ndb: &Ndb, txn: &Transaction, session_id: &str) -> Vec<Message> { +/// `ChatSession.chat` before streaming begins. Also returns note IDs +/// for seeding live threading state. +pub fn load_session_messages(ndb: &Ndb, txn: &Transaction, session_id: &str) -> LoadedSession { let filter = Filter::new() .kinds([AI_CONVERSATION_KIND as u64]) .tags([session_id], 'd') @@ -22,7 +34,14 @@ pub fn load_session_messages(ndb: &Ndb, txn: &Transaction, session_id: &str) -> let results = match ndb.query(txn, &[filter], 10000) { Ok(r) => r, - Err(_) => return vec![], + Err(_) => { + return LoadedSession { + messages: vec![], + root_note_id: None, + last_note_id: None, + event_count: 0, + } + } }; // Collect notes with their created_at for sorting @@ -34,6 +53,10 @@ pub fn load_session_messages(ndb: &Ndb, txn: &Transaction, session_id: &str) -> // Sort by created_at (chronological order) 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()); + let last_note_id = notes.last().map(|n| *n.id()); + let mut messages = Vec::new(); for note in &notes { let content = note.content(); @@ -66,7 +89,12 @@ pub fn load_session_messages(ndb: &Ndb, txn: &Transaction, session_id: &str) -> } } - messages + LoadedSession { + messages, + root_note_id, + last_note_id, + event_count, + } } fn truncate(s: &str, max_chars: usize) -> String {