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:
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"}}]}}"#;