From 048d8a29195d580014b24ee3fc7e89ac6bdfee8a Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Sat, 6 Jun 2026 17:21:12 +0300 Subject: [PATCH] fix: debounce droid working detection refs #488 --- src/pane.rs | 23 +++++++-- src/terminal/state.rs | 105 ++++++++++++++++++++++++++++++++++++------ 2 files changed, 110 insertions(+), 18 deletions(-) diff --git a/src/pane.rs b/src/pane.rs index 214eeef2..66517ab4 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -413,6 +413,7 @@ fn spawn_basic_detection_task( let mut last_visible_idle = false; let mut last_visible_working = false; let mut last_visible_signal_refresh = None; + let mut last_screen_working_at = None; let mut last_process_check = std::time::Instant::now(); let mut last_foreground_pgid = None; let mut has_process_probe = false; @@ -431,6 +432,7 @@ fn spawn_basic_detection_task( last_visible_idle = false; last_visible_working = false; last_visible_signal_refresh = None; + last_screen_working_at = None; last_process_check = std::time::Instant::now(); last_foreground_pgid = None; has_process_probe = false; @@ -514,13 +516,23 @@ fn spawn_basic_detection_task( if agent_presence.observe_process_probe(new_agent) { agent = agent_presence.current_agent(); agent_changed = previous_agent != agent; + if agent_changed { + last_screen_working_at = None; + } } } let Some(detection) = detection_update_for_publish(agent, &content, false) else { continue; }; - let new_state = detection.state; + let new_state = crate::terminal::state::stabilize_agent_detection( + agent, + state, + detection, + false, + now, + &mut last_screen_working_at, + ); let visible_blocker = detection.visible_blocker && new_state == AgentState::Blocked; let visible_idle = detection.visible_idle && new_state == AgentState::Idle; let visible_working = detection.visible_working && new_state == AgentState::Working; @@ -1529,7 +1541,7 @@ impl PaneRuntime { let mut foreground_shell_exit_reported = false; let mut release_was_active = false; let mut pending_restore_probe = initial_state.detected_agent.is_some(); - let mut last_claude_working_at = None; + let mut last_screen_working_at = None; let mut last_visible_blocker = false; let mut last_visible_idle = false; let mut last_visible_working = false; @@ -1562,7 +1574,7 @@ impl PaneRuntime { foreground_shell_exit_reported = false; release_was_active = false; pending_restore_probe = false; - last_claude_working_at = None; + last_screen_working_at = None; last_visible_blocker = false; last_visible_idle = false; last_visible_working = false; @@ -1675,6 +1687,9 @@ impl PaneRuntime { } if changed { agent = agent_presence.current_agent(); + if agent != previous_agent { + last_screen_working_at = None; + } if let Some(process_name) = process_name { info!( pane = pane_id.raw(), @@ -1722,7 +1737,7 @@ impl PaneRuntime { detection, process_exited, now, - &mut last_claude_working_at, + &mut last_screen_working_at, ); let visible_blocker = detection.visible_blocker && new_state == AgentState::Blocked; diff --git a/src/terminal/state.rs b/src/terminal/state.rs index 0b96250f..ec113b14 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -15,7 +15,7 @@ use crate::terminal::TerminalId; mod metadata; pub use metadata::{AgentMetadata, AgentMetadataReport, EffectivePresentation}; -const CLAUDE_WORKING_HOLD: Duration = Duration::from_millis(1200); +const SCREEN_WORKING_HOLD: Duration = Duration::from_millis(1200); const STALE_HOOK_IDLE_GRACE: Duration = Duration::from_secs(2); #[derive(Debug, Clone, PartialEq, Eq)] @@ -640,7 +640,7 @@ impl TerminalState { ) .is_some_and(|(observed_at, reported_at)| { reported_at >= observed_at - && reported_at.duration_since(observed_at) < CLAUDE_WORKING_HOLD + && reported_at.duration_since(observed_at) < SCREEN_WORKING_HOLD }) } @@ -783,21 +783,21 @@ pub(crate) fn stabilize_agent_state( previous: AgentState, raw: AgentState, now: std::time::Instant, - last_claude_working_at: &mut Option, + last_screen_working_at: &mut Option, ) -> AgentState { - if agent != Some(Agent::Claude) { + if !matches!(agent, Some(Agent::Claude | Agent::Droid)) { return raw; } match raw { AgentState::Working => { - *last_claude_working_at = Some(now); + *last_screen_working_at = Some(now); AgentState::Working } AgentState::Blocked => AgentState::Blocked, AgentState::Idle if previous == AgentState::Working => { - if last_claude_working_at - .is_some_and(|last_working| now.duration_since(last_working) < CLAUDE_WORKING_HOLD) + if last_screen_working_at + .is_some_and(|last_working| now.duration_since(last_working) < SCREEN_WORKING_HOLD) { AgentState::Working } else { @@ -814,7 +814,7 @@ pub(crate) fn stabilize_agent_detection( detection: crate::detect::AgentDetection, process_exited: bool, now: std::time::Instant, - last_claude_working_at: &mut Option, + last_screen_working_at: &mut Option, ) -> AgentState { if process_exited { return detection.state; @@ -825,7 +825,7 @@ pub(crate) fn stabilize_agent_detection( previous, detection.state, now, - last_claude_working_at, + last_screen_working_at, ) } @@ -871,7 +871,7 @@ mod tests { Some(Agent::Claude), AgentState::Working, AgentState::Idle, - now + CLAUDE_WORKING_HOLD + std::time::Duration::from_millis(1), + now + SCREEN_WORKING_HOLD + std::time::Duration::from_millis(1), &mut last_working, ); assert_eq!(state, AgentState::Idle); @@ -924,7 +924,84 @@ mod tests { } #[test] - fn non_claude_states_are_unchanged() { + fn droid_working_is_sticky_for_short_gap() { + let now = std::time::Instant::now(); + let mut last_working = None; + + let working = stabilize_agent_state( + Some(Agent::Droid), + AgentState::Idle, + AgentState::Working, + now, + &mut last_working, + ); + assert_eq!(working, AgentState::Working); + + let still_working = stabilize_agent_state( + Some(Agent::Droid), + AgentState::Working, + AgentState::Idle, + now + std::time::Duration::from_millis(400), + &mut last_working, + ); + assert_eq!(still_working, AgentState::Working); + } + + #[test] + fn droid_transitions_to_idle_after_hold_expires() { + let now = std::time::Instant::now(); + let mut last_working = Some(now); + + let state = stabilize_agent_state( + Some(Agent::Droid), + AgentState::Working, + AgentState::Idle, + now + SCREEN_WORKING_HOLD + std::time::Duration::from_millis(1), + &mut last_working, + ); + assert_eq!(state, AgentState::Idle); + } + + #[test] + fn process_exit_idle_bypasses_droid_working_hold() { + let now = std::time::Instant::now(); + let mut last_working = Some(now); + + let state = stabilize_agent_detection( + Some(Agent::Droid), + AgentState::Working, + AgentDetection { + state: AgentState::Idle, + skip_state_update: false, + visible_blocker: false, + visible_idle: false, + visible_working: false, + }, + true, + now + std::time::Duration::from_millis(100), + &mut last_working, + ); + + assert_eq!(state, AgentState::Idle); + } + + #[test] + fn droid_blocked_bypasses_working_hold() { + let now = std::time::Instant::now(); + let mut last_working = Some(now); + + let state = stabilize_agent_state( + Some(Agent::Droid), + AgentState::Working, + AgentState::Blocked, + now + std::time::Duration::from_millis(100), + &mut last_working, + ); + assert_eq!(state, AgentState::Blocked); + } + + #[test] + fn codex_states_are_unchanged_by_screen_working_hold() { let now = std::time::Instant::now(); let mut last_working = None; @@ -1290,7 +1367,7 @@ mod tests { None, None, None, - now + CLAUDE_WORKING_HOLD + Duration::from_millis(1), + now + SCREEN_WORKING_HOLD + Duration::from_millis(1), ); assert_eq!(terminal.state, AgentState::Idle); @@ -1325,7 +1402,7 @@ mod tests { Some("permission".into()), None, None, - now + CLAUDE_WORKING_HOLD + Duration::from_millis(1), + now + SCREEN_WORKING_HOLD + Duration::from_millis(1), ); assert_eq!(terminal.state, AgentState::Blocked); @@ -1337,7 +1414,7 @@ mod tests { false, true, false, - now + CLAUDE_WORKING_HOLD + Duration::from_millis(800), + now + SCREEN_WORKING_HOLD + Duration::from_millis(800), ); assert_eq!(terminal.state, AgentState::Working);