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:
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();