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:
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(¬e, "indicator")
+ .and_then(focus_queue::FocusPriority::from_indicator_str);
if let Some(pm) =
session_events::get_tag_value(¬e, "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()),