notedeck

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

commit c72a82c576e8fbbf72dc39e54d4dfc340bb2b2f0
parent 977da5529895e00be9514e4e9b9551ddcdca6c63
Author: William Casarin <jb55@jb55.com>
Date:   Thu, 26 Feb 2026 19:39:42 -0800

dave: persist focus indicator in kind-31988 session notes

The session list dot (NeedsInput/Done/Error) is now stored as an
`indicator` tag in kind-31988 state events. This makes the indicator
work for remote/observer sessions and survive restarts.

FocusQueue is driven directly from the indicator field via
update_from_indicators() instead of round-tripping through AgentStatus.
The indicator auto-clears when the agent resumes work (Working/Idle)
and can be independently dismissed by the user.

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

Diffstat:
Mcrates/notedeck_dave/src/focus_queue.rs | 60++++++++++++++++++++++++++++++++++++++----------------------
Mcrates/notedeck_dave/src/lib.rs | 39++++++++++++++++++++++++++++++++++-----
Mcrates/notedeck_dave/src/session.rs | 13+++++++++++++
Mcrates/notedeck_dave/src/session_events.rs | 5+++++
Mcrates/notedeck_dave/src/session_loader.rs | 2++
5 files changed, 92 insertions(+), 27 deletions(-)

diff --git a/crates/notedeck_dave/src/focus_queue.rs b/crates/notedeck_dave/src/focus_queue.rs @@ -26,6 +26,23 @@ impl FocusPriority { Self::Done => egui::Color32::from_rgb(70, 130, 220), } } + + pub fn as_str(&self) -> &'static str { + match self { + Self::NeedsInput => "needs_input", + Self::Error => "error", + Self::Done => "done", + } + } + + pub fn from_indicator_str(s: &str) -> Option<Self> { + match s { + "needs_input" => Some(Self::NeedsInput), + "error" => Some(Self::Error), + "done" => Some(Self::Done), + _ => None, + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -37,7 +54,7 @@ pub struct QueueEntry { pub struct FocusQueue { entries: Vec<QueueEntry>, // kept sorted: NeedsInput -> Error -> Done cursor: Option<usize>, // index into entries - previous_statuses: HashMap<SessionId, AgentStatus>, + previous_indicators: HashMap<SessionId, Option<FocusPriority>>, } impl Default for FocusQueue { @@ -51,7 +68,7 @@ impl FocusQueue { Self { entries: Vec::new(), cursor: None, - previous_statuses: HashMap::new(), + previous_indicators: HashMap::new(), } } @@ -284,17 +301,17 @@ impl FocusQueue { Some((self.current_position()?, self.len(), entry.priority)) } - /// Update focus queue based on current session statuses. + /// Update focus queue based on session indicator fields. /// Returns true if any session transitioned to NeedsInput. - pub fn update_from_statuses( + pub fn update_from_indicators( &mut self, - sessions: impl Iterator<Item = (SessionId, AgentStatus)>, + sessions: impl Iterator<Item = (SessionId, Option<FocusPriority>)>, ) -> bool { let mut has_new_needs_input = false; - for (session_id, status) in sessions { - let prev = self.previous_statuses.get(&session_id).copied(); - if prev != Some(status) { - if let Some(priority) = FocusPriority::from_status(status) { + for (session_id, indicator) in sessions { + let prev = self.previous_indicators.get(&session_id).copied(); + if prev != Some(indicator) { + if let Some(priority) = indicator { self.enqueue(session_id, priority); if priority == FocusPriority::NeedsInput { has_new_needs_input = true; @@ -303,7 +320,7 @@ impl FocusQueue { self.dequeue(session_id); } } - self.previous_statuses.insert(session_id, status); + self.previous_indicators.insert(session_id, indicator); } has_new_needs_input } @@ -317,7 +334,7 @@ impl FocusQueue { pub fn remove_session(&mut self, session_id: SessionId) { self.dequeue(session_id); - self.previous_statuses.remove(&session_id); + self.previous_indicators.remove(&session_id); } } @@ -543,26 +560,25 @@ mod tests { } #[test] - fn test_update_from_statuses() { + fn test_update_from_indicators() { let mut queue = FocusQueue::new(); - // Initial statuses - order matters for cursor position - // First item added gets cursor, subsequent inserts shift it - let statuses = vec![ - (session(1), AgentStatus::Done), - (session(2), AgentStatus::NeedsInput), - (session(3), AgentStatus::Working), // Should not be added (Idle/Working excluded) + // Initial indicators - order matters for cursor position + let indicators = vec![ + (session(1), Some(FocusPriority::Done)), + (session(2), Some(FocusPriority::NeedsInput)), + (session(3), None), // No indicator = no dot ]; - queue.update_from_statuses(statuses.into_iter()); + queue.update_from_indicators(indicators.into_iter()); assert_eq!(queue.len(), 2); // Verify NeedsInput is first in priority order assert_eq!(queue.entries[0].session_id, session(2)); assert_eq!(queue.entries[0].priority, FocusPriority::NeedsInput); - // Update: session 2 becomes Idle (should be removed from queue) - let statuses = vec![(session(2), AgentStatus::Idle)]; - queue.update_from_statuses(statuses.into_iter()); + // Update: session 2 indicator cleared (should be removed from queue) + let indicators = vec![(session(2), None)]; + queue.update_from_indicators(indicators.into_iter()); assert_eq!(queue.len(), 1); assert_eq!(queue.current().unwrap().session_id, session(1)); diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -970,6 +970,12 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } SessionListAction::DismissDone(id) => { self.focus_queue.dequeue_done(id); + if let Some(session) = self.session_manager.get_mut(id) { + if session.indicator == Some(focus_queue::FocusPriority::Done) { + session.indicator = None; + session.state_dirty = true; + } + } } } } @@ -1010,6 +1016,12 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } SessionListAction::DismissDone(id) => { self.focus_queue.dequeue_done(id); + if let Some(session) = self.session_manager.get_mut(id) { + if session.indicator == Some(focus_queue::FocusPriority::Done) { + session.indicator = None; + session.state_dirty = true; + } + } } } } @@ -1277,6 +1289,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr let cwd = agentic.cwd.to_string_lossy(); let status = session.status().as_str(); + let indicator = session.indicator.as_ref().map(|i| i.as_str()); let perm_mode = crate::session::permission_mode_to_str(agentic.permission_mode); queue_built_event( @@ -1286,6 +1299,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr session.details.custom_title.as_deref(), &cwd, status, + indicator, &self.hostname, &session.details.home_dir, session.backend_type.as_str(), @@ -1321,6 +1335,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr None, &info.cwd, "deleted", + None, // no indicator for deleted sessions &self.hostname, &info.home_dir, info.backend.as_str(), @@ -1483,6 +1498,12 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr session.details.custom_title = state.custom_title.clone(); + // Restore focus indicator from state event + session.indicator = state + .indicator + .as_deref() + .and_then(focus_queue::FocusPriority::from_indicator_str); + // Use home_dir from the event for remote abbreviation if !state.home_dir.is_empty() { session.details.home_dir = state.home_dir.clone(); @@ -1626,10 +1647,14 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr if is_remote && !new_hostname.is_empty() { session.details.hostname = new_hostname.to_string(); } - // Status and permission mode only update for remote - // sessions (local sessions derive from the process) + // Status, indicator, and permission mode only update + // for remote sessions (local sessions derive from + // the process) if is_remote { agentic.remote_status = new_status; + session.indicator = + session_events::get_tag_value(&note, "indicator") + .and_then(focus_queue::FocusPriority::from_indicator_str); if let Some(pm) = session_events::get_tag_value(&note, "permission-mode") { @@ -1687,6 +1712,10 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr if let Some(session) = self.session_manager.get_mut(dave_sid) { session.details.hostname = state.hostname.clone(); session.details.custom_title = state.custom_title.clone(); + session.indicator = state + .indicator + .as_deref() + .and_then(focus_queue::FocusPriority::from_indicator_str); if !state.home_dir.is_empty() { session.details.home_dir = state.home_dir.clone(); } @@ -2785,9 +2814,9 @@ impl notedeck::App for Dave { // Publish "deleted" state events for recently deleted sessions self.publish_pending_deletions(ctx); - // Update focus queue based on status changes - let status_iter = self.session_manager.iter().map(|s| (s.id, s.status())); - let new_needs_input = self.focus_queue.update_from_statuses(status_iter); + // Update focus queue from persisted indicator field + let indicator_iter = self.session_manager.iter().map(|s| (s.id, s.indicator)); + let new_needs_input = self.focus_queue.update_from_indicators(indicator_iter); // Vibrate on Android whenever a session transitions to NeedsInput if new_needs_input { diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs @@ -6,6 +6,7 @@ use std::time::Instant; use crate::agent_status::AgentStatus; use crate::backend::BackendType; use crate::config::AiMode; +use crate::focus_queue::FocusPriority; use crate::git_status::GitStatusCache; use crate::messages::{ AnswerSummary, CompactionInfo, ExecutedTool, PermissionResponse, PermissionResponseType, @@ -447,6 +448,9 @@ pub struct ChatSession { pub backend_type: BackendType, /// When the last AI response token was received (for "5m ago" display) pub last_activity: Option<Instant>, + /// Focus indicator dot state (persisted in kind-31988 note). + /// Set on status transitions, cleared when user dismisses it. + pub indicator: Option<FocusPriority>, } impl Drop for ChatSession { @@ -493,6 +497,7 @@ impl ChatSession { }, backend_type, last_activity: None, + indicator: None, } } @@ -632,10 +637,18 @@ impl ChatSession { /// Update the cached status based on current session state. /// Sets `state_dirty` when the status actually changes. + /// Also sets the focus indicator when transitioning to a notable state. pub fn update_status(&mut self) { let new_status = self.derive_status(); if new_status != self.cached_status { self.cached_status = new_status; + if let Some(priority) = FocusPriority::from_status(new_status) { + // Set indicator when entering a notable state + self.indicator = Some(priority); + } else if self.indicator.is_some() { + // Clear stale indicator when agent resumes work + self.indicator = None; + } self.state_dirty = true; } } diff --git a/crates/notedeck_dave/src/session_events.rs b/crates/notedeck_dave/src/session_events.rs @@ -740,6 +740,7 @@ pub fn build_session_state_event( custom_title: Option<&str>, cwd: &str, status: &str, + indicator: Option<&str>, hostname: &str, home_dir: &str, backend: &str, @@ -758,6 +759,9 @@ pub fn build_session_state_event( } builder = builder.start_tag().tag_str("cwd").tag_str(cwd); builder = builder.start_tag().tag_str("status").tag_str(status); + if let Some(ind) = indicator { + builder = builder.start_tag().tag_str("indicator").tag_str(ind); + } builder = builder.start_tag().tag_str("hostname").tag_str(hostname); builder = builder.start_tag().tag_str("home_dir").tag_str(home_dir); builder = builder.start_tag().tag_str("backend").tag_str(backend); @@ -1337,6 +1341,7 @@ mod tests { Some("My Custom Title"), "/tmp/project", "working", + Some("needs_input"), "my-laptop", "/home/testuser", "claude", diff --git a/crates/notedeck_dave/src/session_loader.rs b/crates/notedeck_dave/src/session_loader.rs @@ -250,6 +250,7 @@ pub struct SessionState { pub custom_title: Option<String>, pub cwd: String, pub status: String, + pub indicator: Option<String>, pub hostname: String, pub home_dir: String, pub backend: Option<String>, @@ -274,6 +275,7 @@ impl SessionState { custom_title: get_tag_value(note, "custom_title").map(|s| s.to_string()), cwd: get_tag_value(note, "cwd").unwrap_or("").to_string(), status: get_tag_value(note, "status").unwrap_or("idle").to_string(), + indicator: get_tag_value(note, "indicator").map(|s| s.to_string()), hostname: get_tag_value(note, "hostname").unwrap_or("").to_string(), home_dir: get_tag_value(note, "home_dir").unwrap_or("").to_string(), backend: get_tag_value(note, "backend").map(|s| s.to_string()),