Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 146 additions & 7 deletions apps/desktop-tauri/src-tauri/src/auto_refresh.rs
Original file line number Diff line number Diff line change
@@ -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<Mutex<Option<Instant>>> = OnceLock::new();
static LAST_CODING_ACTIVITY: OnceLock<Mutex<Option<Instant>>> = 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<Mutex<Option<Instant>>>) -> Option<Duration> {
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,
));
}
}
Expand All @@ -32,6 +77,73 @@ pub fn install(app: tauri::AppHandle) {
});
}

fn resolve_refresh_interval(settings: &Settings) -> Option<Duration> {
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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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));
}
}
2 changes: 2 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,7 @@ pub struct SettingsSnapshot {
enabled_providers: Vec<String>,
provider_order: Vec<String>,
refresh_interval_secs: u64,
adaptive_refresh: bool,
refresh_all_providers_on_menu_open: bool,
start_at_login: bool,
start_minimized: bool,
Expand Down Expand Up @@ -504,6 +505,7 @@ impl From<Settings> 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,
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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()
}
}
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use super::*;
pub struct SettingsUpdate {
pub enabled_providers: Option<Vec<String>>,
pub refresh_interval_secs: Option<u64>,
pub adaptive_refresh: Option<bool>,
pub refresh_all_providers_on_menu_open: Option<bool>,
pub start_at_login: Option<bool>,
pub start_minimized: Option<bool>,
Expand Down Expand Up @@ -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;
}
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop-tauri/src-tauri/src/tray_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ pub fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
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.
Expand Down Expand Up @@ -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) => {
Expand Down
1 change: 1 addition & 0 deletions apps/desktop-tauri/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ function settings(overrides: Partial<SettingsSnapshot> = {}): SettingsSnapshot {
return {
enabledProviders: ["codex", "claude"],
refreshIntervalSecs: 300,
adaptiveRefresh: false,
refreshAllProvidersOnMenuOpen: false,
startAtLogin: false,
startMinimized: false,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop-tauri/src/floatbar/FloatBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ function settings(overrides: Partial<SettingsSnapshot> = {}): SettingsSnapshot {
return {
enabledProviders: ["claude", "codex"],
refreshIntervalSecs: 300,
adaptiveRefresh: false,
refreshAllProvidersOnMenuOpen: false,
startAtLogin: false,
startMinimized: false,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop-tauri/src/i18n/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,7 @@ export const ALL_LOCALE_KEYS = [

// Tauri desktop shell — Refresh interval + provider display names
"RefreshIntervalManual",
"RefreshIntervalAdaptive",
"RefreshInterval15Min",
"RefreshInterval30Min",
"RefreshInterval1Hour",
Expand Down
1 change: 1 addition & 0 deletions apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ function settings(): SettingsSnapshot {
return {
enabledProviders: ["codex", "claude"],
refreshIntervalSecs: 300,
adaptiveRefresh: false,
refreshAllProvidersOnMenuOpen: false,
startAtLogin: false,
startMinimized: false,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ function settings(overrides: Partial<SettingsSnapshot> = {}): SettingsSnapshot {
return {
enabledProviders: ["codex", "claude"],
refreshIntervalSecs: 300,
adaptiveRefresh: false,
refreshAllProvidersOnMenuOpen: false,
startAtLogin: false,
startMinimized: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import type { SettingsSnapshot } from "../../../types/bridge";
const settings: SettingsSnapshot = {
enabledProviders: [],
refreshIntervalSecs: 300,
adaptiveRefresh: false,
refreshAllProvidersOnMenuOpen: false,
startAtLogin: false,
startMinimized: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type { SettingsSnapshot } from "../../../types/bridge";
const settings: SettingsSnapshot = {
enabledProviders: [],
refreshIntervalSecs: 300,
adaptiveRefresh: false,
refreshAllProvidersOnMenuOpen: false,
startAtLogin: false,
startMinimized: false,
Expand Down
18 changes: 16 additions & 2 deletions apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -310,13 +311,26 @@ export default function GeneralTab({
description={t("RefreshIntervalHelper")}
>
<Select
value={String(settings.refreshIntervalSecs)}
value={
settings.adaptiveRefresh
? "adaptive"
: String(settings.refreshIntervalSecs)
}
disabled={saving}
options={REFRESH_CADENCE_OPTIONS.map((o) => ({
value: o.value,
label: t(o.labelKey),
}))}
onChange={(v) => set({ refreshIntervalSecs: Number(v) })}
onChange={(v) => {
if (v === "adaptive") {
set({ adaptiveRefresh: true });
return;
}
set({
adaptiveRefresh: false,
refreshIntervalSecs: Number(v),
});
}}
/>
</Field>
<Field
Expand Down
1 change: 1 addition & 0 deletions apps/desktop-tauri/src/types/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ describe("Language type", () => {
const snap: SettingsSnapshot = {
enabledProviders: [],
refreshIntervalSecs: 300,
adaptiveRefresh: false,
refreshAllProvidersOnMenuOpen: false,
startAtLogin: false,
startMinimized: false,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop-tauri/src/types/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ export interface SettingsSnapshot {
enabledProviders: string[];
providerOrder?: string[];
refreshIntervalSecs: number;
adaptiveRefresh: boolean;
refreshAllProvidersOnMenuOpen: boolean;
startAtLogin: boolean;
startMinimized: boolean;
Expand Down Expand Up @@ -291,6 +292,7 @@ export interface SettingsSnapshot {
export interface SettingsUpdate {
enabledProviders?: string[];
refreshIntervalSecs?: number;
adaptiveRefresh?: boolean;
refreshAllProvidersOnMenuOpen?: boolean;
startAtLogin?: boolean;
startMinimized?: boolean;
Expand Down
1 change: 1 addition & 0 deletions rust/src/cli/diagnose.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ async fn collect_provider_diagnostic(
gateway_url: settings
.provider_config(provider_id)
.and_then(|config| config.gateway_url.clone()),
auto_prefer_web: false,
};

let fetch_result = provider.fetch_usage(&ctx).await;
Expand Down
Loading
Loading