notedeck

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

commit c417c785f7835e9ff04e44b7f1842e88719a0eab
parent 9698db99d2f78ea9ab252b462c9c0766706d13fa
Author: William Casarin <jb55@jb55.com>
Date:   Sun, 15 Feb 2026 23:22:54 -0800

session_events: fix timestamp inheritance, subscription-based archive loading

- Wire set_sub_callback in ndb Config to trigger UI repaint on subscription matches
- Replace synchronous message loading with subscribe-before-ingest + poll pattern
  (process_event_with queues async indexing, so immediate loads found nothing)
- Fix file-history-snapshot lines lacking top-level timestamp/sessionId by
  adding context inheritance to ThreadingState (carries forward last-seen values)
- Add timestamp() fallback to snapshot.timestamp in session_jsonl.rs

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

Diffstat:
Mcrates/notedeck/src/app.rs | 8+++++++-
Mcrates/notedeck_dave/src/lib.rs | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mcrates/notedeck_dave/src/session_events.rs | 91++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/notedeck_dave/src/session_jsonl.rs | 18+++++++++++++++++-
4 files changed, 181 insertions(+), 32 deletions(-)

diff --git a/crates/notedeck/src/app.rs b/crates/notedeck/src/app.rs @@ -220,7 +220,13 @@ impl Notedeck { let settings = SettingsHandler::new(&path).load(); - let config = Config::new().set_ingester_threads(2).set_mapsize(map_size); + let config = Config::new() + .set_ingester_threads(2) + .set_mapsize(map_size) + .set_sub_callback({ + let ctx = ctx.clone(); + move |_| ctx.request_repaint() + }); let keystore = if parsed_args.options.contains(NotedeckOptions::UseKeystore) { let keys_path = path.path(DataPathType::Keys); diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -28,7 +28,7 @@ use chrono::{Duration, Local}; use egui_wgpu::RenderState; use enostr::KeypairUnowned; use focus_queue::FocusQueue; -use nostrdb::Transaction; +use nostrdb::{Subscription, Transaction}; use notedeck::{ui::is_narrow, AppAction, AppContext, AppResponse}; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; @@ -106,9 +106,21 @@ pub struct Dave { active_overlay: DaveOverlay, /// IPC listener for external spawn-agent commands ipc_listener: Option<ipc::IpcListener>, - /// JSONL file path pending archive conversion to nostr events. + /// Pending archive conversion: (jsonl_path, dave_session_id, claude_session_id). /// Set when resuming a session; processed in update() where AppContext is available. - pending_archive_convert: Option<std::path::PathBuf>, + pending_archive_convert: Option<(std::path::PathBuf, SessionId, String)>, + /// Waiting for ndb to finish indexing 1988 events so we can load messages. + pending_message_load: Option<PendingMessageLoad>, +} + +/// Subscription waiting for ndb to index 1988 conversation events. +struct PendingMessageLoad { + /// ndb subscription for kind-1988 events matching the session + sub: Subscription, + /// Dave's internal session ID + dave_session_id: SessionId, + /// Claude session ID (the `d` tag value) + claude_session_id: String, } /// Calculate an anonymous user_id from a keypair @@ -222,6 +234,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr active_overlay, ipc_listener, pending_archive_convert: None, + pending_message_load: None, } } @@ -482,8 +495,10 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr title, file_path, } => { - self.create_resumed_session_with_cwd(cwd, session_id, title); - self.pending_archive_convert = Some(file_path); + let claude_session_id = session_id.clone(); + let sid = self.create_resumed_session_with_cwd(cwd, session_id, title); + self.pending_archive_convert = + Some((file_path, sid, claude_session_id)); self.session_picker.close(); self.active_overlay = DaveOverlay::None; } @@ -641,7 +656,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr cwd: PathBuf, resume_session_id: String, title: String, - ) { + ) -> SessionId { update::create_resumed_session_with_cwd( &mut self.session_manager, &mut self.directory_picker, @@ -651,7 +666,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr cwd, resume_session_id, title, - ); + ) } /// Clone the active agent, creating a new session with the same working directory @@ -893,25 +908,44 @@ impl notedeck::App for Dave { update::poll_editor_job(&mut self.session_manager); // Process pending archive conversion (JSONL → nostr events) - if let Some(file_path) = self.pending_archive_convert.take() { + 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 { - 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 {}", - note_ids.len(), - file_path.display() - ); + // 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); + } + } } Err(e) => { - tracing::error!("archive conversion failed: {}", e); + tracing::error!("failed to subscribe for archive events: {:?}", e); } } } else { @@ -919,6 +953,24 @@ impl notedeck::App for Dave { } } + // Poll pending message load — wait for ndb to index 1988 events + if let Some(pending) = &self.pending_message_load { + 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( + 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; + } + self.pending_message_load = None; + } + } + // Handle global keybindings (when no text input has focus) let has_pending_permission = self.first_pending_permission().is_some(); let has_pending_question = self.has_pending_question(); diff --git a/crates/notedeck_dave/src/session_events.rs b/crates/notedeck_dave/src/session_events.rs @@ -54,6 +54,10 @@ pub struct ThreadingState { last_note_id: Option<[u8; 32]>, /// Monotonic sequence counter for unambiguous ordering. seq: u32, + /// Last seen session ID (carried forward for lines that lack it). + session_id: Option<String>, + /// Last seen timestamp in seconds (carried forward for lines that lack it). + last_timestamp: Option<u64>, } impl Default for ThreadingState { @@ -69,6 +73,8 @@ impl ThreadingState { root_note_id: None, last_note_id: None, seq: 0, + session_id: None, + last_timestamp: None, } } @@ -77,6 +83,28 @@ impl ThreadingState { self.seq } + /// Update session context from a JSONL line (session_id, timestamp). + fn update_context(&mut self, line: &JsonlLine) { + if let Some(sid) = line.session_id() { + self.session_id = Some(sid.to_string()); + } + if let Some(ts) = line.timestamp_secs() { + self.last_timestamp = Some(ts); + } + } + + /// Get the session ID for the current line, falling back to the last seen. + fn session_id_for(&self, line: &JsonlLine) -> Option<String> { + line.session_id() + .map(|s| s.to_string()) + .or_else(|| self.session_id.clone()) + } + + /// Get the timestamp for the current line, falling back to the last seen. + fn timestamp_for(&self, line: &JsonlLine) -> Option<u64> { + line.timestamp_secs().or(self.last_timestamp) + } + /// 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() { @@ -101,6 +129,12 @@ pub fn build_events( threading: &mut ThreadingState, secret_key: &[u8; 32], ) -> Result<Vec<BuiltEvent>, EventBuildError> { + // Resolve session_id and timestamp with fallback to last seen values, + // then update context for subsequent lines. + let session_id = threading.session_id_for(line); + let timestamp = threading.timestamp_for(line); + threading.update_context(line); + let msg = line.message(); let is_assistant = line.line_type() == Some("assistant"); @@ -137,6 +171,8 @@ pub fn build_events( role, Some((i, total)), tool_id, + session_id.as_deref(), + timestamp, threading, secret_key, )?; @@ -169,6 +205,8 @@ pub fn build_events( role, None, tool_id.as_deref(), + session_id.as_deref(), + timestamp, threading, secret_key, )?; @@ -178,8 +216,14 @@ pub fn build_events( // Build a kind-1989 source-data companion event linked to the first 1988 event. let first_note_id = events[0].note_id; - let source_data_event = - build_source_data_event(line, &first_note_id, threading.seq() - 1, secret_key)?; + let source_data_event = build_source_data_event( + line, + &first_note_id, + threading.seq() - 1, + session_id.as_deref(), + timestamp, + secret_key, + )?; events.push(source_data_event); Ok(events) @@ -208,6 +252,8 @@ fn build_source_data_event( line: &JsonlLine, conversation_note_id: &[u8; 32], seq: u32, + session_id: Option<&str>, + timestamp: Option<u64>, secret_key: &[u8; 32], ) -> Result<BuiltEvent, EventBuildError> { let raw_json = line.to_json(); @@ -218,7 +264,7 @@ fn build_source_data_event( .content("") .options(NoteBuildOptions::default()); - if let Some(ts) = line.timestamp_secs() { + if let Some(ts) = timestamp { builder = builder.created_at(ts); } @@ -228,8 +274,7 @@ fn build_source_data_event( .tag_str("e") .tag_id(conversation_note_id); - // Same session ID for querying - if let Some(session_id) = line.session_id() { + if let Some(session_id) = session_id { builder = builder.start_tag().tag_str("d").tag_str(session_id); } @@ -275,6 +320,8 @@ fn build_single_event( role: &str, split_index: Option<(usize, usize)>, tool_id: Option<&str>, + session_id: Option<&str>, + timestamp: Option<u64>, threading: &ThreadingState, secret_key: &[u8; 32], ) -> Result<BuiltEvent, EventBuildError> { @@ -283,13 +330,12 @@ fn build_single_event( .content(content) .options(NoteBuildOptions::default()); - // Set timestamp from JSONL - if let Some(ts) = line.timestamp_secs() { + if let Some(ts) = timestamp { builder = builder.created_at(ts); } // -- Session identity tags -- - if let Some(session_id) = line.session_id() { + if let Some(session_id) = session_id { builder = builder.start_tag().tag_str("d").tag_str(session_id); } if let Some(slug) = line.slug() { @@ -701,4 +747,33 @@ mod tests { ); } } + + #[test] + fn test_file_history_snapshot_inherits_context() { + // file-history-snapshot lines lack sessionId and top-level timestamp. + // They should inherit session_id from a prior line and get timestamp + // from snapshot.timestamp. + let lines = vec![ + r#"{"type":"user","uuid":"u1","parentUuid":null,"sessionId":"ctx-test","timestamp":"2026-02-09T20:00:00Z","cwd":"/tmp","version":"2.0.64","message":{"role":"user","content":"hello"}}"#, + r#"{"type":"file-history-snapshot","messageId":"abc","snapshot":{"messageId":"abc","trackedFileBackups":{},"timestamp":"2026-02-11T01:29:31.555Z"},"isSnapshotUpdate":false}"#, + ]; + + let mut threading = ThreadingState::new(); + let sk = test_secret_key(); + + // First line sets context + let line = JsonlLine::parse(lines[0]).unwrap(); + let events = build_events(&line, &mut threading, &sk).unwrap(); + assert!(events[0].json.contains(r#""d","ctx-test"#)); + + // Second line (file-history-snapshot) should inherit session_id + let line = JsonlLine::parse(lines[1]).unwrap(); + assert!(line.session_id().is_none()); // no top-level sessionId + let events = build_events(&line, &mut threading, &sk).unwrap(); + + // 1988 event should have inherited d tag + assert!(events[0].json.contains(r#""d","ctx-test"#)); + // Should have snapshot timestamp (1770773371), not the user's + assert!(events[0].json.contains(r#""created_at":1770773371"#)); + } } diff --git a/crates/notedeck_dave/src/session_jsonl.rs b/crates/notedeck_dave/src/session_jsonl.rs @@ -57,7 +57,14 @@ impl JsonlLine { } pub fn timestamp(&self) -> Option<&str> { - self.get_str("timestamp") + // Top-level timestamp, falling back to snapshot.timestamp + // (file-history-snapshot lines nest it there) + self.get_str("timestamp").or_else(|| { + self.0 + .get("snapshot") + .and_then(|s| s.get("timestamp")) + .and_then(|v| v.as_str()) + }) } /// Parse the timestamp as a unix timestamp (seconds). @@ -430,6 +437,15 @@ mod tests { } #[test] + fn test_timestamp_fallback_snapshot() { + // file-history-snapshot lines have timestamp nested in snapshot + let line = r#"{"type":"file-history-snapshot","messageId":"abc","snapshot":{"messageId":"abc","trackedFileBackups":{},"timestamp":"2026-02-11T01:29:31.555Z"},"isSnapshotUpdate":false}"#; + let parsed = JsonlLine::parse(line).unwrap(); + assert_eq!(parsed.timestamp(), Some("2026-02-11T01:29:31.555Z")); + assert!(parsed.timestamp_secs().is_some()); + } + + #[test] fn test_mixed_assistant_content() { let line = r#"{"type":"assistant","uuid":"u1","sessionId":"s","timestamp":"2026-02-09T20:00:00Z","message":{"role":"assistant","model":"claude-opus-4-5-20251101","content":[{"type":"text","text":"Here is what I found:"},{"type":"tool_use","id":"t1","name":"Glob","input":{"pattern":"**/*.rs"}}]}}"#;