diff --git a/apps/desktop-tauri/src-tauri/src/auto_refresh.rs b/apps/desktop-tauri/src-tauri/src/auto_refresh.rs index 91d26dd773..4e40926ca2 100644 --- a/apps/desktop-tauri/src-tauri/src/auto_refresh.rs +++ b/apps/desktop-tauri/src-tauri/src/auto_refresh.rs @@ -1,28 +1,73 @@ -use std::sync::{Arc, OnceLock}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; use std::time::{Duration, Instant}; +use codexbar::core::{ + AdaptiveRefreshInput, AdaptiveRefreshReason, ThermalPressure, next_delay as adaptive_next_delay, +}; use codexbar::settings::Settings; const AUTO_REFRESH_POLL_INTERVAL: Duration = Duration::from_secs(15); +static LAST_MENU_OPEN: OnceLock>> = OnceLock::new(); +static LAST_CODING_ACTIVITY: OnceLock>> = OnceLock::new(); +static ADAPTIVE_ACTIVE: AtomicBool = AtomicBool::new(false); + +/// Record that the tray/flyout panel just opened (recent-interaction signal). +pub fn note_menu_open() { + *LAST_MENU_OPEN + .get_or_init(|| Mutex::new(None)) + .lock() + .unwrap_or_else(|e| e.into_inner()) = Some(Instant::now()); +} + +/// Weak coding-activity signal (e.g. a refresh cycle that found live usage). +#[allow(dead_code)] +pub fn note_coding_activity() { + *LAST_CODING_ACTIVITY + .get_or_init(|| Mutex::new(None)) + .lock() + .unwrap_or_else(|e| e.into_inner()) = Some(Instant::now()); +} + +fn age_since(slot: &OnceLock>>) -> Option { + let guard = slot + .get_or_init(|| Mutex::new(None)) + .lock() + .unwrap_or_else(|e| e.into_inner()); + guard.map(|at| at.elapsed()) +} + pub fn install(app: tauri::AppHandle) { tauri::async_runtime::spawn(async move { - let mut schedule: Option<(Duration, Instant)> = None; + let mut schedule: Option<(Duration, Instant, bool)> = None; loop { - let interval = refresh_interval(Settings::load().refresh_interval_secs); + let settings = Settings::load(); + ADAPTIVE_ACTIVE.store(settings.adaptive_refresh, Ordering::Relaxed); + let interval = resolve_refresh_interval(&settings); match interval { None => schedule = None, Some(interval) => { let now = Instant::now(); + let adaptive = settings.adaptive_refresh; let scheduled_at = schedule - .filter(|(scheduled_interval, _)| *scheduled_interval == interval) - .map(|(_, scheduled_at)| scheduled_at) + .filter(|(scheduled_interval, _, was_adaptive)| { + *scheduled_interval == interval && *was_adaptive == adaptive + }) + .map(|(_, scheduled_at, _)| scheduled_at) .unwrap_or(now); if now >= scheduled_at { let _ = crate::commands::do_refresh_providers_if_stale(&app).await; + // Treat a completed refresh as a weak activity signal while adaptive. + if adaptive { + note_coding_activity(); + } + let next_interval = + resolve_refresh_interval(&Settings::load()).unwrap_or(interval); schedule = Some(( - interval, - next_fixed_tick(scheduled_at, Instant::now(), interval), + next_interval, + next_fixed_tick(scheduled_at, Instant::now(), next_interval), + adaptive, )); } } @@ -32,6 +77,73 @@ pub fn install(app: tauri::AppHandle) { }); } +fn resolve_refresh_interval(settings: &Settings) -> Option { + if settings.adaptive_refresh { + return Some(adaptive_delay_now()); + } + refresh_interval(settings.refresh_interval_secs) +} + +fn adaptive_delay_now() -> Duration { + let decision = adaptive_next_delay(AdaptiveRefreshInput { + last_menu_open_age: age_since(&LAST_MENU_OPEN), + last_coding_activity_age: age_since(&LAST_CODING_ACTIVITY), + low_power_mode_enabled: low_power_mode_enabled(), + thermal_pressure: ThermalPressure::Nominal, + }); + tracing::debug!( + delay_secs = decision.delay.as_secs(), + reason = ?decision.reason, + "adaptive refresh delay" + ); + let _ = AdaptiveRefreshReason::LongIdle; + decision.delay +} + +/// Best-effort Windows battery/AC check. Returns false when unknown. +fn low_power_mode_enabled() -> bool { + #[cfg(windows)] + { + // SYSTEM_POWER_STATUS via kernel32. ACLineStatus: 0 = offline (battery). + // BatteryFlag bit 0x08 = charging; BatteryLifePercent 0-100 or 255 unknown. + // Treat "on battery and < 20% remaining" as low-power; skip on failure. + #[repr(C)] + struct SystemPowerStatus { + ac_line_status: u8, + battery_flag: u8, + battery_life_percent: u8, + system_status_flag: u8, + battery_life_time: u32, + battery_full_life_time: u32, + } + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetSystemPowerStatus(status: *mut SystemPowerStatus) -> i32; + } + let mut status = SystemPowerStatus { + ac_line_status: 255, + battery_flag: 255, + battery_life_percent: 255, + system_status_flag: 0, + battery_life_time: 0, + battery_full_life_time: 0, + }; + let ok = unsafe { GetSystemPowerStatus(&mut status) } != 0; + if !ok { + return false; + } + let on_battery = status.ac_line_status == 0; + let low_pct = status.battery_life_percent <= 20; + // system_status_flag bit 0x01 = Battery Saver is on (Win10+) + let battery_saver = status.system_status_flag & 0x01 != 0; + return on_battery && (low_pct || battery_saver); + } + #[cfg(not(windows))] + { + false + } +} + fn next_fixed_tick( previous_scheduled_at: Instant, completed_at: Instant, @@ -87,6 +199,26 @@ mod tests { assert_eq!(refresh_interval(0), None); } + #[test] + fn adaptive_enabled_uses_policy_delay() { + // Clear shared activity slots so parallel/prior tests cannot shrink the delay. + *LAST_MENU_OPEN + .get_or_init(|| Mutex::new(None)) + .lock() + .unwrap_or_else(|e| e.into_inner()) = None; + *LAST_CODING_ACTIVITY + .get_or_init(|| Mutex::new(None)) + .lock() + .unwrap_or_else(|e| e.into_inner()) = None; + + let mut settings = Settings::default(); + settings.adaptive_refresh = true; + settings.refresh_interval_secs = 0; + let delay = resolve_refresh_interval(&settings).expect("adaptive always schedules"); + // No menu open → long idle 30m + assert_eq!(delay, Duration::from_secs(30 * 60)); + } + #[test] fn fixed_cadence_advances_from_the_scheduled_tick() { let start = Instant::now(); @@ -118,4 +250,11 @@ mod tests { vec!["codex".to_string()] ); } + + #[test] + fn note_menu_open_sets_recent_age() { + note_menu_open(); + let age = age_since(&LAST_MENU_OPEN).expect("menu open recorded"); + assert!(age < Duration::from_secs(5)); + } } diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index a92fe9f9ba..753edd23b3 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -408,6 +408,7 @@ pub struct SettingsSnapshot { enabled_providers: Vec, provider_order: Vec, refresh_interval_secs: u64, + adaptive_refresh: bool, refresh_all_providers_on_menu_open: bool, start_at_login: bool, start_minimized: bool, @@ -504,6 +505,7 @@ impl From for SettingsSnapshot { enabled_providers, provider_order, refresh_interval_secs: settings.refresh_interval_secs, + adaptive_refresh: settings.adaptive_refresh, refresh_all_providers_on_menu_open: settings.refresh_all_providers_on_menu_open, start_at_login: settings.start_at_login, start_minimized: settings.start_minimized, diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 1d12ac816e..54008b5769 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -110,6 +110,9 @@ pub(crate) fn build_fetch_context( let api_region = settings.api_region(id).trim().to_string(); let gateway_url = (id == ProviderId::Wayfinder && !settings.gateway_url(id).is_empty()) .then(|| settings.gateway_url(id).to_string()); + // Local-first Auto providers (OpenCode Go) flip to web-first when a + // token account or manual cookie source scopes the session to web creds. + let auto_prefer_web = token_override.is_some() || cookie_source == "manual"; FetchContext { source_mode, @@ -118,6 +121,7 @@ pub(crate) fn build_fetch_context( workspace_id: (!workspace_id.is_empty()).then_some(workspace_id), api_region: (!api_region.is_empty()).then_some(api_region), gateway_url, + auto_prefer_web, ..FetchContext::default() } } diff --git a/apps/desktop-tauri/src-tauri/src/commands/settings.rs b/apps/desktop-tauri/src-tauri/src/commands/settings.rs index ccd8185532..94f87ad6d0 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/settings.rs @@ -9,6 +9,7 @@ use super::*; pub struct SettingsUpdate { pub enabled_providers: Option>, pub refresh_interval_secs: Option, + pub adaptive_refresh: Option, pub refresh_all_providers_on_menu_open: Option, pub start_at_login: Option, pub start_minimized: Option, @@ -126,6 +127,9 @@ impl SettingsUpdate { if let Some(v) = self.refresh_interval_secs { settings.refresh_interval_secs = v; } + if let Some(v) = self.adaptive_refresh { + settings.adaptive_refresh = v; + } if let Some(v) = self.refresh_all_providers_on_menu_open { settings.refresh_all_providers_on_menu_open = v; } diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index 173b84a448..de9c78d1e0 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -318,6 +318,7 @@ pub fn setup(app: &mut tauri::App) -> Result<(), Box> { fn handle_menu_event(app: &AppHandle, id: &str) { match resolve_menu_action(id) { Some(MenuAction::Transition(request)) => { + crate::auto_refresh::note_menu_open(); match resolve_menu_transition_dispatch(id, request) { // Pass None so default_surface_position can use remembered PopOut // geometry first, then fall back to tray/current-monitor placement. @@ -346,6 +347,7 @@ fn handle_menu_event(app: &AppHandle, id: &str) { // Pass None: open_or_focus falls back to the tray-anchored // default position (same placement chain the old TrayPanel // transition used) when no explicit position is given. + crate::auto_refresh::note_menu_open(); let _ = shell::flyout_window::open_or_focus(app, None); } Some(MenuAction::Refresh) => { diff --git a/apps/desktop-tauri/src/App.test.tsx b/apps/desktop-tauri/src/App.test.tsx index 14230f1519..cd073ed89d 100644 --- a/apps/desktop-tauri/src/App.test.tsx +++ b/apps/desktop-tauri/src/App.test.tsx @@ -63,6 +63,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { return { enabledProviders: ["codex", "claude"], refreshIntervalSecs: 300, + adaptiveRefresh: false, refreshAllProvidersOnMenuOpen: false, startAtLogin: false, startMinimized: false, diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx index 67dc7871c8..5722be9a19 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx @@ -92,6 +92,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { return { enabledProviders: ["claude", "codex"], refreshIntervalSecs: 300, + adaptiveRefresh: false, refreshAllProvidersOnMenuOpen: false, startAtLogin: false, startMinimized: false, diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 5ffc32c235..baaea8eb37 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -674,6 +674,7 @@ export const ALL_LOCALE_KEYS = [ // Tauri desktop shell — Refresh interval + provider display names "RefreshIntervalManual", + "RefreshIntervalAdaptive", "RefreshInterval15Min", "RefreshInterval30Min", "RefreshInterval1Hour", diff --git a/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx b/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx index d908988c85..dc96b99c34 100644 --- a/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx @@ -118,6 +118,7 @@ function settings(): SettingsSnapshot { return { enabledProviders: ["codex", "claude"], refreshIntervalSecs: 300, + adaptiveRefresh: false, refreshAllProvidersOnMenuOpen: false, startAtLogin: false, startMinimized: false, diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index 6df1796c8b..e1f9318d20 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -104,6 +104,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { return { enabledProviders: ["codex", "claude"], refreshIntervalSecs: 300, + adaptiveRefresh: false, refreshAllProvidersOnMenuOpen: false, startAtLogin: false, startMinimized: false, diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx index 4e0102f0b7..592fc6cf08 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx @@ -40,6 +40,7 @@ import type { SettingsSnapshot } from "../../../types/bridge"; const settings: SettingsSnapshot = { enabledProviders: [], refreshIntervalSecs: 300, + adaptiveRefresh: false, refreshAllProvidersOnMenuOpen: false, startAtLogin: false, startMinimized: false, diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx index 512322b446..fdd684fffa 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx @@ -23,6 +23,7 @@ import type { SettingsSnapshot } from "../../../types/bridge"; const settings: SettingsSnapshot = { enabledProviders: [], refreshIntervalSecs: 300, + adaptiveRefresh: false, refreshAllProvidersOnMenuOpen: false, startAtLogin: false, startMinimized: false, diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx index 771222ebd5..d821a469ac 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx @@ -18,6 +18,7 @@ const FALLBACK_LANGUAGE_OPTIONS: LanguageOption[] = [ const REFRESH_CADENCE_OPTIONS: { value: string; labelKey: LocaleKey }[] = [ { value: "0", labelKey: "RefreshIntervalManual" }, + { value: "adaptive", labelKey: "RefreshIntervalAdaptive" }, { value: "60", labelKey: "RefreshInterval1Min" }, { value: "300", labelKey: "RefreshInterval5Min" }, { value: "900", labelKey: "RefreshInterval15Min" }, @@ -310,13 +311,26 @@ export default function GeneralTab({ description={t("RefreshIntervalHelper")} >