notedeck

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

commit dddf12aa6730b4ade3df437f0caf65b3796e34cd
parent e5c350351903b6bc3850a328192de62b62731bf2
Author: William Casarin <jb55@jb55.com>
Date:   Fri, 27 Feb 2026 10:34:59 -0800

fix: auto-steal focus retries across frames via AutoStealState enum

The transition-based auto-steal from 53d5c67 could miss steal
opportunities when suppressed (e.g. user was typing) during the
one-shot transition frame.  Replace the two bools (auto_steal_focus,
pending_auto_steal) with an AutoStealState enum (Disabled / Idle /
Pending).  Pending persists across frames until the steal logic
actually executes unsuppressed, while manual session switching still
works because Pending is only entered on new queue transitions.

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

Diffstat:
Mcrates/notedeck_dave/src/focus_queue.rs | 20++++++++++++++++++++
Mcrates/notedeck_dave/src/lib.rs | 68+++++++++++++++++++++++++++++++++++++++++++-------------------------
2 files changed, 63 insertions(+), 25 deletions(-)

diff --git a/crates/notedeck_dave/src/focus_queue.rs b/crates/notedeck_dave/src/focus_queue.rs @@ -50,6 +50,26 @@ pub struct FocusQueueUpdate { pub changed: bool, } +/// Auto-steal focus state machine. +/// +/// - `Disabled`: auto-steal is off, user controls focus manually. +/// - `Idle`: auto-steal is on but no pending work. +/// - `Pending`: auto-steal is on and a focus-queue transition was +/// detected that hasn't been acted on yet (retries across frames +/// if temporarily suppressed, e.g. user is typing). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AutoStealState { + Disabled, + Idle, + Pending, +} + +impl AutoStealState { + pub fn is_enabled(self) -> bool { + matches!(self, Self::Idle | Self::Pending) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct QueueEntry { pub session_id: SessionId, diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -149,8 +149,9 @@ pub struct Dave { interrupt_pending_since: Option<Instant>, /// Focus queue for agents needing attention focus_queue: FocusQueue, - /// Auto-steal focus mode: automatically cycle through focus queue items - auto_steal_focus: bool, + /// Auto-steal focus state: Disabled, Idle (enabled, nothing pending), + /// or Pending (enabled, waiting to fire / retrying). + auto_steal: focus_queue::AutoStealState, /// The session ID to return to after processing all NeedsInput items home_session: Option<SessionId>, /// Directory picker for selecting working directory when creating sessions @@ -492,7 +493,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr show_scene: false, // Default to list view interrupt_pending_since: None, focus_queue: FocusQueue::new(), - auto_steal_focus: false, + auto_steal: focus_queue::AutoStealState::Disabled, home_session: None, directory_picker, session_picker: SessionPicker::new(), @@ -916,7 +917,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr &mut self.focus_queue, &self.model_config, is_interrupt_pending, - self.auto_steal_focus, + self.auto_steal.is_enabled(), app_ctx, ui, ); @@ -953,7 +954,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr &self.focus_queue, &self.model_config, is_interrupt_pending, - self.auto_steal_focus, + self.auto_steal.is_enabled(), app_ctx, ui, ); @@ -998,7 +999,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr &self.focus_queue, &self.model_config, is_interrupt_pending, - self.auto_steal_focus, + self.auto_steal.is_enabled(), self.show_session_list, app_ctx, ui, @@ -2213,7 +2214,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr &mut self.focus_queue, get_backend(&self.backends, bt), self.show_scene, - self.auto_steal_focus, + self.auto_steal.is_enabled(), &mut self.home_session, egui_ctx, ) { @@ -2233,7 +2234,11 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr self.delete_session(id); } KeyActionResult::SetAutoSteal(new_state) => { - self.auto_steal_focus = new_state; + self.auto_steal = if new_state { + focus_queue::AutoStealState::Pending + } else { + focus_queue::AutoStealState::Disabled + }; } KeyActionResult::PublishPermissionResponse(publish) => { self.pending_perm_responses.push(publish); @@ -2311,10 +2316,14 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr &mut self.session_manager, &mut self.scene, self.show_scene, - self.auto_steal_focus, + self.auto_steal.is_enabled(), &mut self.home_session, ); - self.auto_steal_focus = new_state; + self.auto_steal = if new_state { + focus_queue::AutoStealState::Pending + } else { + focus_queue::AutoStealState::Disabled + }; None } UiActionResult::NewChat => { @@ -2876,27 +2885,36 @@ impl notedeck::App for Dave { notedeck::platform::try_vibrate(); } - // Only auto-steal on queue transitions, not every frame. - // This lets the user manually switch sessions between transitions. - if queue_update.changed { - // Suppress auto-steal while the user is typing (non-empty input) + // Transition to Pending on queue changes so auto-steal retries + // across frames if temporarily suppressed (e.g. user is typing). + if queue_update.changed && self.auto_steal.is_enabled() { + self.auto_steal = focus_queue::AutoStealState::Pending; + } + + // Run auto-steal when pending. Transitions back to Idle once + // the steal logic executes (even if no switch was needed). + // Stays Pending while the user is typing so it retries next frame. + if self.auto_steal == focus_queue::AutoStealState::Pending { let user_is_typing = self .session_manager .get_active() .is_some_and(|s| !s.input.is_empty()); - let stole_focus = update::process_auto_steal_focus( - &mut self.session_manager, - &mut self.focus_queue, - &mut self.scene, - self.show_scene, - self.auto_steal_focus && !user_is_typing, - &mut self.home_session, - ); + if !user_is_typing { + let stole_focus = update::process_auto_steal_focus( + &mut self.session_manager, + &mut self.focus_queue, + &mut self.scene, + self.show_scene, + true, + &mut self.home_session, + ); + + if stole_focus { + activate_app(egui_ctx); + } - // Raise the OS window when auto-steal switches to a NeedsInput session - if stole_focus { - activate_app(egui_ctx); + self.auto_steal = focus_queue::AutoStealState::Idle; } }