From 9794dc10426898df56a5165086e24b2366813f89 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Thu, 15 Oct 2026 16:33:22 -0400 Subject: [PATCH 1/3] feat: add opt-in per-account Smart refresh --- README.md | 8 ++ src-tauri/src/lib.rs | 8 +- src-tauri/src/plugin_engine/ccusage_cache.rs | 96 +++++++++++++++++ src-tauri/src/plugin_engine/host_api.rs | 78 ++++++++++---- src-tauri/src/plugin_engine/mod.rs | 1 + src-tauri/src/plugin_engine/runtime.rs | 14 +++ src/components/app/app-content.tsx | 4 +- src/hooks/app/use-probe-auto-update.ts | 24 ++++- src/hooks/app/use-probe.ts | 5 +- src/hooks/app/use-settings-bootstrap.ts | 4 +- .../app/use-settings-system-actions.test.ts | 3 + src/hooks/app/use-settings-system-actions.ts | 8 +- src/hooks/app/use-smart-refresh.test.ts | 102 ++++++++++++++++++ src/hooks/app/use-smart-refresh.ts | 90 ++++++++++++++++ src/hooks/use-probe-events.test.ts | 9 ++ src/hooks/use-probe-events.ts | 5 +- src/lib/settings.test.ts | 7 ++ src/lib/settings.ts | 26 ++--- src/lib/smart-refresh.test.ts | 53 +++++++++ src/lib/smart-refresh.ts | 48 +++++++++ src/locales/de.ts | 2 + src/locales/en.lock.json | 2 + src/locales/en.ts | 2 + src/locales/es-419.ts | 2 + src/locales/es-ES.ts | 2 + src/locales/fr.ts | 2 + src/locales/it.ts | 2 + src/locales/ja.ts | 2 + src/locales/ko.ts | 2 + src/locales/pt-BR.ts | 2 + src/locales/tr.ts | 2 + src/locales/zh-CN.ts | 2 + src/pages/settings.test.tsx | 9 ++ src/pages/settings.tsx | 9 +- src/stores/app-preferences-store.ts | 6 +- 35 files changed, 585 insertions(+), 56 deletions(-) create mode 100644 src-tauri/src/plugin_engine/ccusage_cache.rs create mode 100644 src/hooks/app/use-smart-refresh.test.ts create mode 100644 src/hooks/app/use-smart-refresh.ts create mode 100644 src/lib/smart-refresh.test.ts create mode 100644 src/lib/smart-refresh.ts diff --git a/README.md b/README.md index 488ffb2..dcffef4 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,14 @@ Settings → **Tray icon** picks what sits next to the clock. Settings → **Tas ![The panel in the pure black dark theme and in the light theme](docs/images/themes.webp) +## Smart refresh + +Settings → **Auto refresh → Smart** adjusts each account independently. After a quota change, it checks every minute for 5 minutes. Unchanged results then increase the wait to 2, 5, 10 and finally 15 minutes. Any further quota change restarts the one-minute checks. Existing fixed intervals and the default 15-minute setting stay available. + +Checks are scheduled after results arrive, so a slow account does not overlap itself or block another account. Failures use a separate 2/5/10/15-minute backoff, and the existing HTTP rate-limit gate still enforces server retry delays. Opening the panel refreshes data at least a minute old, without bypassing error backoff. At the 15-minute cadence, usage elsewhere can take up to 15 minutes to be detected. + +Smart polls reuse successful local token-history scans for up to 15 minutes; manual refreshes and fixed intervals still scan fresh history. Quota checks remain live. Only raw progress values and limits determine activity, so countdown text and history charts cannot keep an idle account polling rapidly. No file watchers or burn-rate prediction are involved. + ## Supported Providers - [**Amp**](docs/providers/amp.md) / free tier, bonus, credits diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c6dbc90..928272b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -188,6 +188,7 @@ async fn start_probe_batch( state: tauri::State<'_, Mutex>, batch_id: Option, plugin_ids: Option>, + cache_local_usage: Option, ) -> Result { let batch_id = batch_id .and_then(|id| { @@ -325,7 +326,12 @@ async fn start_probe_batch( wsl::stale_output(&plugin, local_http_api::cached_snapshot(&plugin_id)) } else { catch_probe_panic(&plugin, || { - plugin_engine::runtime::run_probe(&plugin, &data_dir, &version) + plugin_engine::runtime::run_probe_with_cache( + &plugin, + &data_dir, + &version, + cache_local_usage.unwrap_or(false), + ) }) }; // The account may have been removed while this probe was queued: drop its result diff --git a/src-tauri/src/plugin_engine/ccusage_cache.rs b/src-tauri/src/plugin_engine/ccusage_cache.rs new file mode 100644 index 0000000..47d2393 --- /dev/null +++ b/src-tauri/src/plugin_engine/ccusage_cache.rs @@ -0,0 +1,96 @@ +//! Smart polls reuse successful local-history scans for 15 minutes. Manual/fixed polls stay fresh. +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +const TTL: Duration = Duration::from_secs(15 * 60); + +struct Entry { + options: String, + value: String, + at: Instant, +} + +#[derive(Default)] +struct Cache(HashMap); + +impl Cache { + fn get(&self, account: &str, options: &str, now: Instant) -> Option { + self.0 + .get(account) + .filter(|entry| entry.options == options && now.duration_since(entry.at) < TTL) + .map(|entry| entry.value.clone()) + } + + fn put(&mut self, account: &str, options: &str, value: &str, now: Instant) { + self.0.retain(|_, entry| now.duration_since(entry.at) < TTL); + if serde_json::from_str::(value) + .ok() + .is_some_and(|value| value["status"] == "ok") + { + self.0.insert( + account.into(), + Entry { + options: options.into(), + value: value.into(), + at: now, + }, + ); + } else { + self.0.remove(account); + } + } +} + +pub(crate) fn query(account: &str, options: &str, reuse: bool, fetch: impl FnOnce() -> String) -> String { + static CACHE: OnceLock> = OnceLock::new(); + let cache = CACHE.get_or_init(|| Mutex::new(Cache::default())); + if reuse { + if let Some(value) = cache + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(account, options, Instant::now()) + { + return value; + } + } + // Never hold the lock while starting a subprocess; other accounts can scan independently. + let value = fetch(); + cache + .lock() + .unwrap_or_else(|e| e.into_inner()) + .put(account, options, &value, Instant::now()); + value +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn expires_and_isolates_accounts_and_query_ranges() { + let mut cache = Cache::default(); + let now = Instant::now(); + let ok = r#"{"status":"ok","data":{"daily":[]}}"#; + cache.put("claude-work", "today", ok, now); + assert_eq!(cache.get("claude-work", "today", now).as_deref(), Some(ok)); + assert!(cache.get("claude-personal", "today", now).is_none()); + assert!(cache.get("claude-work", "tomorrow", now).is_none()); + assert!(cache.get("claude-work", "today", now + TTL).is_none()); + cache.put("claude-work", "today", r#"{"status":"runner_failed"}"#, now); + assert!(cache.get("claude-work", "today", now).is_none()); + } + + #[test] + fn smart_reuses_but_manual_refresh_replaces_the_cache() { + let old = r#"{"status":"ok","data":{"daily":[1]}}"#; + let new = r#"{"status":"ok","data":{"daily":[2]}}"#; + assert_eq!(query("cache-test", "opts", true, || old.into()), old); + assert_eq!(query("cache-test", "opts", true, || panic!("must reuse")), old); + assert_eq!(query("cache-test", "opts", false, || new.into()), new); + assert_eq!( + query("cache-test", "opts", true, || panic!("must reuse latest")), + new + ); + } +} diff --git a/src-tauri/src/plugin_engine/host_api.rs b/src-tauri/src/plugin_engine/host_api.rs index 2dd2ba2..0be15f7 100644 --- a/src-tauri/src/plugin_engine/host_api.rs +++ b/src-tauri/src/plugin_engine/host_api.rs @@ -593,6 +593,7 @@ pub(crate) fn inject_host_api<'js>( app_version, ProbeDeadline::none(), None, + false, ) } @@ -603,6 +604,7 @@ pub(crate) fn inject_host_api_with_deadline<'js>( app_version: &str, deadline: ProbeDeadline, account: Option<&crate::plugin_engine::manifest::AccountBinding>, + cache_local_usage: bool, ) -> rquickjs::Result<()> { let globals = ctx.globals(); let probe_ctx = Object::new(ctx.clone())?; @@ -648,7 +650,9 @@ pub(crate) fn inject_host_api_with_deadline<'js>( // An account copy reads the same kind of local logs as its base plugin (from its own home). A // WSL copy reads none: its logs live in WSL, far too slow to scan over \\wsl.localhost. let ccusage_id = account.map_or(plugin_id, |account| account.base_id.as_str()); - inject_ccusage(ctx, &host, ccusage_id, deadline, home.is_none())?; + inject_ccusage( + ctx, &host, ccusage_id, deadline, home.is_none(), plugin_id, cache_local_usage, + )?; probe_ctx.set("host", host)?; globals.set("__openusage_ctx", probe_ctx)?; @@ -2678,9 +2682,12 @@ fn inject_ccusage<'js>( plugin_id: &str, deadline: ProbeDeadline, enabled: bool, + cache_id: &str, + reuse: bool, ) -> rquickjs::Result<()> { let ccusage_obj = Object::new(ctx.clone())?; let pid = plugin_id.to_string(); + let cache_id = cache_id.to_string(); ccusage_obj.set( "_queryRaw", @@ -2697,25 +2704,27 @@ fn inject_ccusage<'js>( CcusageQueryOpts::default() } }; - let provider = resolve_ccusage_provider(&opts, &pid); - let Some(_active_query) = - CcusageQueryGuard::acquire(provider, ccusage_home_override(&opts, provider)) - else { - log::warn!("[plugin:{}] ccusage query already running", pid); - return Ok(serde_json::json!({ "status": "runner_failed" }).to_string()); - }; - let runners = collect_ccusage_runners(); - Ok(run_ccusage_query_with_runners( - runners, - &opts, - provider, - &pid, - |kind, program, opts, provider, plugin_id| { - run_ccusage_with_runner_deadline( - kind, program, opts, provider, plugin_id, deadline, - ) - }, - )) + Ok(super::ccusage_cache::query(&cache_id, &opts_json, reuse, || { + let provider = resolve_ccusage_provider(&opts, &pid); + let Some(_active_query) = + CcusageQueryGuard::acquire(provider, ccusage_home_override(&opts, provider)) + else { + log::warn!("[plugin:{}] ccusage query already running", pid); + return serde_json::json!({ "status": "runner_failed" }).to_string(); + }; + let runners = collect_ccusage_runners(); + run_ccusage_query_with_runners( + runners, + &opts, + provider, + &pid, + |kind, program, opts, provider, plugin_id| { + run_ccusage_with_runner_deadline( + kind, program, opts, provider, plugin_id, deadline, + ) + }, + ) + })) }, )?, )?; @@ -3491,6 +3500,7 @@ mod tests { "0.0.0", ProbeDeadline::none(), Some(&account), + false, ) .expect("inject host api"); let home: Option = ctx @@ -5272,6 +5282,7 @@ wait "0.0.0", ProbeDeadline::none(), Some(&account), + false, ) .expect("inject host api"); patch_ccusage_wrapper(&ctx).expect("ccusage wrapper"); @@ -5285,4 +5296,31 @@ wait assert_eq!(status, "no_runner"); }); } + + #[test] + fn smart_probe_reads_cached_history_without_starting_a_runner() { + let account = "smart-cache-host-test"; + super::super::ccusage_cache::query(account, "{}", false, || { + r#"{"status":"ok","data":{"daily":[]}}"#.to_string() + }); + let rt = Runtime::new().expect("runtime"); + let ctx = Context::full(&rt).expect("context"); + ctx.with(|ctx| { + inject_host_api_with_deadline( + &ctx, + account, + &std::env::temp_dir(), + "0.0.0", + ProbeDeadline::none(), + None, + true, + ) + .expect("inject host api"); + patch_ccusage_wrapper(&ctx).expect("ccusage wrapper"); + let status: String = ctx + .eval(r#"__openusage_ctx.host.ccusage.query({}).status"#) + .expect("cached ccusage"); + assert_eq!(status, "ok"); + }); + } } diff --git a/src-tauri/src/plugin_engine/mod.rs b/src-tauri/src/plugin_engine/mod.rs index 8b8c9a6..2c0824e 100644 --- a/src-tauri/src/plugin_engine/mod.rs +++ b/src-tauri/src/plugin_engine/mod.rs @@ -1,4 +1,5 @@ pub mod host_api; +mod ccusage_cache; mod http_gate; pub mod manifest; pub mod runtime; diff --git a/src-tauri/src/plugin_engine/runtime.rs b/src-tauri/src/plugin_engine/runtime.rs index 0fc1a12..84e07fb 100644 --- a/src-tauri/src/plugin_engine/runtime.rs +++ b/src-tauri/src/plugin_engine/runtime.rs @@ -74,11 +74,21 @@ pub struct PluginOutput { } pub fn run_probe(plugin: &LoadedPlugin, app_data_dir: &PathBuf, app_version: &str) -> PluginOutput { + run_probe_with_cache(plugin, app_data_dir, app_version, false) +} + +pub fn run_probe_with_cache( + plugin: &LoadedPlugin, + app_data_dir: &PathBuf, + app_version: &str, + cache_local_usage: bool, +) -> PluginOutput { run_probe_with_timeout( plugin, app_data_dir, app_version, Duration::from_secs(PROBE_TIMEOUT_SECS), + cache_local_usage, ) } @@ -112,6 +122,7 @@ pub fn run_account_key( app_version, host_api::ProbeDeadline::at(deadline_at), plugin.account.as_ref(), + false, ) .ok()?; host_api::inject_utils(&ctx).ok()?; @@ -138,6 +149,7 @@ fn run_probe_with_timeout( app_data_dir: &PathBuf, app_version: &str, timeout: Duration, + cache_local_usage: bool, ) -> PluginOutput { let fallback = error_output(plugin, "runtime error".to_string()); let timeout_message = probe_timeout_message(timeout); @@ -171,6 +183,7 @@ fn run_probe_with_timeout( app_version, deadline, plugin.account.as_ref(), + cache_local_usage, ) .is_err() { @@ -869,6 +882,7 @@ mod tests { &temp_app_dir("timeout"), "0.0.0", Duration::from_millis(5), + false, ); assert_eq!(error_text(output), "probe timed out after 5ms"); diff --git a/src/components/app/app-content.tsx b/src/components/app/app-content.tsx index f16d2d2..136c21d 100644 --- a/src/components/app/app-content.tsx +++ b/src/components/app/app-content.tsx @@ -9,7 +9,7 @@ import { useAppPreferencesStore } from "@/stores/app-preferences-store" import { useAppUiStore } from "@/stores/app-ui-store" import type { AccountsChange } from "@/lib/accounts" import type { - AutoUpdateIntervalMinutes, + AutoUpdateInterval, DisplayMode, GlobalShortcut, MenubarIconStyle, @@ -29,7 +29,7 @@ export type AppContentActionProps = { onReorder: (orderedIds: string[]) => void onToggle: (id: string) => void onAccountsChanged: (change: AccountsChange) => void - onAutoUpdateIntervalChange: (value: AutoUpdateIntervalMinutes) => void + onAutoUpdateIntervalChange: (value: AutoUpdateInterval) => void onThemeModeChange: (mode: ThemeMode) => void onDisplayModeChange: (mode: DisplayMode) => void onResetTimerDisplayModeChange: (mode: ResetTimerDisplayMode) => void diff --git a/src/hooks/app/use-probe-auto-update.ts b/src/hooks/app/use-probe-auto-update.ts index 5bc59b0..593683b 100644 --- a/src/hooks/app/use-probe-auto-update.ts +++ b/src/hooks/app/use-probe-auto-update.ts @@ -1,22 +1,26 @@ import { useCallback, useEffect, useState } from "react" +import type { PluginState } from "@/hooks/app/types" +import { useSmartRefresh } from "@/hooks/app/use-smart-refresh" import { getEnabledPluginIds, - type AutoUpdateIntervalMinutes, + type AutoUpdateInterval, type PluginSettings, } from "@/lib/settings" type UseProbeAutoUpdateArgs = { pluginSettings: PluginSettings | null - autoUpdateInterval: AutoUpdateIntervalMinutes + autoUpdateInterval: AutoUpdateInterval + pluginStates?: Record setLoadingForPlugins: (ids: string[]) => void setErrorForPlugins: (ids: string[], error: string) => void isPluginLoading: (id: string) => boolean - startBatch: (pluginIds?: string[]) => Promise + startBatch: (pluginIds?: string[], cacheLocalUsage?: boolean) => Promise } export function useProbeAutoUpdate({ pluginSettings, autoUpdateInterval, + pluginStates, setLoadingForPlugins, setErrorForPlugins, isPluginLoading, @@ -24,9 +28,17 @@ export function useProbeAutoUpdate({ }: UseProbeAutoUpdateArgs) { const [autoUpdateNextAt, setAutoUpdateNextAt] = useState(null) const [autoUpdateResetToken, setAutoUpdateResetToken] = useState(0) + const smartNextAt = useSmartRefresh({ + enabled: autoUpdateInterval === "smart", + pluginSettings, + pluginStates, + setLoadingForPlugins, + setErrorForPlugins, + startBatch, + }) useEffect(() => { - if (!pluginSettings) { + if (!pluginSettings || autoUpdateInterval === "smart") { setAutoUpdateNextAt(null) return } @@ -68,6 +80,8 @@ export function useProbeAutoUpdate({ ]) const resetAutoUpdateSchedule = useCallback(() => { + // Smart schedules advance per account when its result arrives, including manual refreshes. + if (autoUpdateInterval === "smart") return if (!pluginSettings) return const enabledIds = getEnabledPluginIds(pluginSettings) /* v8 ignore start */ @@ -82,7 +96,7 @@ export function useProbeAutoUpdate({ }, [autoUpdateInterval, pluginSettings]) return { - autoUpdateNextAt, + autoUpdateNextAt: autoUpdateInterval === "smart" ? smartNextAt : autoUpdateNextAt, setAutoUpdateNextAt, resetAutoUpdateSchedule, } diff --git a/src/hooks/app/use-probe.ts b/src/hooks/app/use-probe.ts index 6609133..825c54e 100644 --- a/src/hooks/app/use-probe.ts +++ b/src/hooks/app/use-probe.ts @@ -1,7 +1,7 @@ import { useCallback } from "react" import { useProbeEvents } from "@/hooks/use-probe-events" import { - type AutoUpdateIntervalMinutes, + type AutoUpdateInterval, type PluginSettings, } from "@/lib/settings" import { useProbeAutoUpdate } from "@/hooks/app/use-probe-auto-update" @@ -10,7 +10,7 @@ import { useProbeState } from "@/hooks/app/use-probe-state" type UseProbeArgs = { pluginSettings: PluginSettings | null - autoUpdateInterval: AutoUpdateIntervalMinutes + autoUpdateInterval: AutoUpdateInterval onProbeResult?: () => void } @@ -47,6 +47,7 @@ export function useProbe({ } = useProbeAutoUpdate({ pluginSettings, autoUpdateInterval, + pluginStates, setLoadingForPlugins, setErrorForPlugins, isPluginLoading, diff --git a/src/hooks/app/use-settings-bootstrap.ts b/src/hooks/app/use-settings-bootstrap.ts index a6b988d..6f3c4e3 100644 --- a/src/hooks/app/use-settings-bootstrap.ts +++ b/src/hooks/app/use-settings-bootstrap.ts @@ -43,7 +43,7 @@ import { normalizePluginSettings, savePluginSettings, type AlertSettings, - type AutoUpdateIntervalMinutes, + type AutoUpdateInterval, type DisplayMode, type GlobalShortcut, type MenubarIconStyle, @@ -61,7 +61,7 @@ import { useLocaleStore } from "@/lib/i18n" type UseSettingsBootstrapArgs = { setPluginSettings: (value: PluginSettings | null) => void setPluginsMeta: (value: PluginMeta[]) => void - setAutoUpdateInterval: (value: AutoUpdateIntervalMinutes) => void + setAutoUpdateInterval: (value: AutoUpdateInterval) => void setThemeMode: (value: ThemeMode) => void setDisplayMode: (value: DisplayMode) => void setResetTimerDisplayMode: (value: ResetTimerDisplayMode) => void diff --git a/src/hooks/app/use-settings-system-actions.test.ts b/src/hooks/app/use-settings-system-actions.test.ts index bf23758..4b52893 100644 --- a/src/hooks/app/use-settings-system-actions.test.ts +++ b/src/hooks/app/use-settings-system-actions.test.ts @@ -68,6 +68,9 @@ describe("useSettingsSystemActions", () => { expect(setAutoUpdateInterval).toHaveBeenCalledWith(15) expect(setAutoUpdateNextAt).toHaveBeenCalledWith(910_000) expect(saveAutoUpdateIntervalMock).toHaveBeenCalledWith(15) + act(() => result.current.handleAutoUpdateIntervalChange("smart")) + expect(setAutoUpdateNextAt).toHaveBeenLastCalledWith(70_000) + expect(saveAutoUpdateIntervalMock).toHaveBeenLastCalledWith("smart") nowSpy.mockRestore() }) diff --git a/src/hooks/app/use-settings-system-actions.ts b/src/hooks/app/use-settings-system-actions.ts index 3ab86ea..55c4406 100644 --- a/src/hooks/app/use-settings-system-actions.ts +++ b/src/hooks/app/use-settings-system-actions.ts @@ -5,14 +5,14 @@ import { saveAutoUpdateInterval, saveGlobalShortcut, saveStartOnLogin, - type AutoUpdateIntervalMinutes, + type AutoUpdateInterval, type GlobalShortcut, type PluginSettings, } from "@/lib/settings" type UseSettingsSystemActionsArgs = { pluginSettings: PluginSettings | null - setAutoUpdateInterval: (value: AutoUpdateIntervalMinutes) => void + setAutoUpdateInterval: (value: AutoUpdateInterval) => void setAutoUpdateNextAt: (value: number | null) => void setGlobalShortcut: (value: GlobalShortcut) => void setStartOnLogin: (value: boolean) => void @@ -27,13 +27,13 @@ export function useSettingsSystemActions({ setStartOnLogin, applyStartOnLogin, }: UseSettingsSystemActionsArgs) { - const handleAutoUpdateIntervalChange = useCallback((value: AutoUpdateIntervalMinutes) => { + const handleAutoUpdateIntervalChange = useCallback((value: AutoUpdateInterval) => { setAutoUpdateInterval(value) if (pluginSettings) { const enabledIds = getEnabledPluginIds(pluginSettings) if (enabledIds.length > 0) { - setAutoUpdateNextAt(Date.now() + value * 60_000) + setAutoUpdateNextAt(Date.now() + (value === "smart" ? 1 : value) * 60_000) } else { setAutoUpdateNextAt(null) } diff --git a/src/hooks/app/use-smart-refresh.test.ts b/src/hooks/app/use-smart-refresh.test.ts new file mode 100644 index 0000000..3a03161 --- /dev/null +++ b/src/hooks/app/use-smart-refresh.test.ts @@ -0,0 +1,102 @@ +import { act, renderHook } from "@testing-library/react" +import { afterEach, beforeEach, expect, it, vi } from "vitest" +import type { PluginState } from "@/hooks/app/types" +import { useSmartRefresh } from "@/hooks/app/use-smart-refresh" +import { MINUTE } from "@/lib/smart-refresh" + +const settings = { order: ["claude", "codex-work"], disabled: [] as string[] } +const state = (id: string, used = 20, loading = false, error: string | null = null): PluginState => ({ + data: { providerId: id, displayName: id, iconUrl: "", lines: [ + { type: "progress", label: "Session", used, limit: 100, format: { kind: "percent" } }, + ] }, loading, error, lastUpdatedAt: Date.now(), lastManualRefreshAt: null, +}) + +function setup(initial: Record = {}) { + let states = initial + let enabled = true + let pluginSettings = settings + const startBatch = vi.fn().mockResolvedValue(settings.order) + const setLoadingForPlugins = vi.fn((ids: string[]) => { + for (const id of ids) states = { ...states, [id]: state(id, states[id]?.data?.lines[0]?.type === "progress" ? states[id].data.lines[0].used : 20, true) } + rerender() + }) + const setErrorForPlugins = vi.fn((ids: string[], error: string) => { + for (const id of ids) states = { ...states, [id]: state(id, 20, false, error) } + rerender() + }) + const { result, rerender, unmount } = renderHook(() => useSmartRefresh({ + enabled, pluginSettings, pluginStates: states, startBatch, setLoadingForPlugins, setErrorForPlugins, + })) + return { + result, startBatch, unmount, + async tick(minutes: number) { await act(() => vi.advanceTimersByTimeAsync(minutes * MINUTE)) }, + complete(id: string, used = 20, error: string | null = null) { + act(() => { states = { ...states, [id]: state(id, used, false, error) }; rerender() }) + }, + configure(nextEnabled: boolean, disabled: string[] = []) { + act(() => { enabled = nextEnabled; pluginSettings = { ...settings, disabled }; rerender() }) + }, + } +} + +beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(100_000) }) +afterEach(() => { vi.useRealTimers() }) + +it("schedules from completion, keeps slow accounts independent, and never overlaps a probe", async () => { + const app = setup() + await app.tick(1) + expect(app.startBatch).toHaveBeenLastCalledWith(["claude", "codex-work"], true) + await app.tick(3) + expect(app.startBatch).toHaveBeenCalledTimes(1) + app.complete("claude") + expect(app.result.current).toBe(Date.now() + MINUTE) + await app.tick(1) + expect(app.startBatch).toHaveBeenLastCalledWith(["claude"], true) + expect(app.startBatch).toHaveBeenCalledTimes(2) +}) + +it("backs off an idle account independently of another account's usage changes", async () => { + const app = setup({ claude: state("claude"), "codex-work": state("codex-work") }) + for (let minute = 1; minute <= 5; minute++) { + await app.tick(1) + app.complete("claude", 20 + minute) + app.complete("codex-work", 20) + } + app.startBatch.mockClear() + await app.tick(1) + expect(app.startBatch).toHaveBeenCalledWith(["claude"], true) + app.complete("claude", 30) + await app.tick(1) + expect(app.startBatch).toHaveBeenLastCalledWith(["claude", "codex-work"], true) +}) + +it("backs off failed starts and cancels timers when disabled or unmounted", async () => { + const app = setup() + app.startBatch.mockRejectedValueOnce(new Error("offline")) + await app.tick(1) + expect(app.result.current).toBe(Date.now() + 2 * MINUTE) + app.configure(true, ["codex-work"]) + await app.tick(2) + expect(app.startBatch).toHaveBeenLastCalledWith(["claude"], true) + app.configure(false) + expect(app.result.current).toBeNull() + await app.tick(30) + expect(app.startBatch).toHaveBeenCalledTimes(2) + app.unmount() + expect(vi.getTimerCount()).toBe(0) +}) + +it("refreshes stale data on panel focus without bypassing error backoff", async () => { + const app = setup({ claude: state("claude"), "codex-work": state("codex-work", 20, false, "offline") }) + act(() => { + vi.setSystemTime(Date.now() + MINUTE) + window.dispatchEvent(new Event("focus")) + }) + await app.tick(0) + expect(app.startBatch).toHaveBeenCalledWith(["claude"], true) + app.complete("claude") + app.startBatch.mockClear() + act(() => window.dispatchEvent(new Event("focus"))) + await app.tick(0) + expect(app.startBatch).not.toHaveBeenCalled() +}) diff --git a/src/hooks/app/use-smart-refresh.ts b/src/hooks/app/use-smart-refresh.ts new file mode 100644 index 0000000..8da2cc5 --- /dev/null +++ b/src/hooks/app/use-smart-refresh.ts @@ -0,0 +1,90 @@ +import { useEffect, useRef, useState } from "react" +import type { PluginState } from "@/hooks/app/types" +import type { PluginSettings } from "@/lib/settings" +import { completeSmartCheck, MINUTE, newSmartSchedule, type SmartSchedule } from "@/lib/smart-refresh" + +type Entry = SmartSchedule & { observed?: PluginState } +const EMPTY_STATES: Record = {} + +export function useSmartRefresh({ + enabled, + pluginSettings, + pluginStates = EMPTY_STATES, + setLoadingForPlugins, + setErrorForPlugins, + startBatch, +}: { + enabled: boolean + pluginSettings: PluginSettings | null + pluginStates?: Record + setLoadingForPlugins: (ids: string[]) => void + setErrorForPlugins: (ids: string[], error: string) => void + startBatch: (ids?: string[], cacheLocalUsage?: boolean) => Promise +}) { + const entries = useRef(new Map()) + const [nextAt, setNextAt] = useState(null) + const [wake, setWake] = useState(0) + + useEffect(() => { + if (!enabled || !pluginSettings) { + entries.current.clear() + setNextAt(null) + return + } + const ids = pluginSettings.order.filter((id) => !pluginSettings.disabled.includes(id)) + for (const id of entries.current.keys()) if (!ids.includes(id)) entries.current.delete(id) + const now = Date.now() + for (const id of ids) { + const state = pluginStates[id] + let entry: Entry = entries.current.get(id) ?? newSmartSchedule(now) + if (state && state !== entry.observed) { + entry = state.loading + ? { ...entry, nextAt: null } + : completeSmartCheck(entry, state.data, !!state.error, now) + } + entries.current.set(id, { ...entry, observed: state }) + } + const times = [...entries.current.values()].flatMap((entry) => entry.nextAt == null ? [] : [entry.nextAt]) + const next = times.length ? Math.min(...times) : null + setNextAt(next) + if (next == null) return + const timer = window.setTimeout(() => { + const due = ids.filter((id) => { + const entry = entries.current.get(id) + return entry?.nextAt != null && entry.nextAt <= Date.now() && !pluginStates[id]?.loading + }) + if (!due.length) return + for (const id of due) entries.current.get(id)!.nextAt = null + setLoadingForPlugins(due) + startBatch(due, true).catch((error) => { + console.error("Failed to start smart refresh:", error) + setErrorForPlugins(due, "Failed to start probe") + }) + }, Math.max(0, next - now)) + return () => window.clearTimeout(timer) + }, [enabled, pluginSettings, pluginStates, setLoadingForPlugins, setErrorForPlugins, startBatch, wake]) + + // Opening the panel refreshes old data, but never bypasses an account's error backoff. + useEffect(() => { + if (!enabled) return + const refreshVisible = () => { + if (document.visibilityState !== "visible") return + const now = Date.now() + let changed = false + for (const entry of entries.current.values()) { + if (entry.nextAt != null && !entry.failures && now - entry.completedAt >= MINUTE) { + entry.nextAt = now + changed = true + } + } + if (changed) setWake((value) => value + 1) + } + window.addEventListener("focus", refreshVisible) + document.addEventListener("visibilitychange", refreshVisible) + return () => { + window.removeEventListener("focus", refreshVisible) + document.removeEventListener("visibilitychange", refreshVisible) + } + }, [enabled]) + return nextAt +} diff --git a/src/hooks/use-probe-events.test.ts b/src/hooks/use-probe-events.test.ts index c08b4b3..5ed789e 100644 --- a/src/hooks/use-probe-events.test.ts +++ b/src/hooks/use-probe-events.test.ts @@ -71,6 +71,15 @@ describe("useProbeEvents", () => { } }) + it("opts Smart polls into the local-history cache without changing manual probes", async () => { + invokeMock.mockResolvedValue({ batchId: "test", pluginIds: ["claude"] }) + const { result } = renderHook(() => useProbeEvents({ onResult: vi.fn(), onBatchComplete: vi.fn() })) + await act(() => result.current.startBatch(["claude"], true)) + expect(invokeMock).toHaveBeenLastCalledWith("start_probe_batch", expect.objectContaining({ cacheLocalUsage: true })) + await act(() => result.current.startBatch(["claude"])) + expect(invokeMock.mock.lastCall?.[1]).not.toHaveProperty("cacheLocalUsage") + }) + it("uses crypto randomUUID when available", async () => { const originalCrypto = globalThis.crypto // @ts-expect-error test randomUUID path diff --git a/src/hooks/use-probe-events.ts b/src/hooks/use-probe-events.ts index dee1d3b..eaf97ac 100644 --- a/src/hooks/use-probe-events.ts +++ b/src/hooks/use-probe-events.ts @@ -92,7 +92,7 @@ export function useProbeEvents({ onResult, onBatchComplete }: UseProbeEventsOpti } }, [onBatchComplete, onResult]) - const startBatch = useCallback(async (pluginIds?: string[]) => { + const startBatch = useCallback(async (pluginIds?: string[], cacheLocalUsage?: boolean) => { // Wait for listeners to be ready before starting the batch if (listenersReadyRef.current) { await listenersReadyRef.current @@ -110,8 +110,9 @@ export function useProbeEvents({ onResult, onBatchComplete }: UseProbeEventsOpti const args = pluginIds ? { batchId, pluginIds } : { batchId } + const probeArgs = cacheLocalUsage ? { ...args, cacheLocalUsage: true } : args try { - const result = await invoke("start_probe_batch", args) + const result = await invoke("start_probe_batch", probeArgs) return result.pluginIds } catch (error) { activeBatchIds.current.delete(batchId) diff --git a/src/lib/settings.test.ts b/src/lib/settings.test.ts index 41bb301..62d006f 100644 --- a/src/lib/settings.test.ts +++ b/src/lib/settings.test.ts @@ -180,6 +180,13 @@ describe("settings", () => { await expect(loadAutoUpdateInterval()).resolves.toBe(5) }) + it("persists Smart refresh without changing fixed interval support", async () => { + await saveAutoUpdateInterval("smart") + await expect(loadAutoUpdateInterval()).resolves.toBe("smart") + storeState.set("autoUpdateInterval", "broken") + await expect(loadAutoUpdateInterval()).resolves.toBe(DEFAULT_AUTO_UPDATE_INTERVAL) + }) + it("loads default theme mode when missing", async () => { await expect(loadThemeMode()).resolves.toBe(DEFAULT_THEME_MODE) }) diff --git a/src/lib/settings.ts b/src/lib/settings.ts index cb1ee8d..bfb0b2b 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -12,7 +12,7 @@ export type PluginSettings = { disabled: string[]; }; -export type AutoUpdateIntervalMinutes = 5 | 15 | 30 | 60; +export type AutoUpdateInterval = 5 | 15 | 30 | 60 | "smart"; /** "dark" is pure black (the former OLED theme). */ export type ThemeMode = "dark" | "light"; @@ -64,7 +64,7 @@ const GLOBAL_SHORTCUT_KEY = "globalShortcut"; const START_ON_LOGIN_KEY = "startOnLogin"; const LANGUAGE_KEY = "language"; -export const DEFAULT_AUTO_UPDATE_INTERVAL: AutoUpdateIntervalMinutes = 15; +export const DEFAULT_AUTO_UPDATE_INTERVAL: AutoUpdateInterval = 15; export const DEFAULT_THEME_MODE: ThemeMode = "dark"; export const DEFAULT_DISPLAY_MODE: DisplayMode = "left"; export const DEFAULT_RESET_TIMER_DISPLAY_MODE: ResetTimerDisplayMode = "relative"; @@ -76,7 +76,7 @@ export const DEFAULT_TRAY_PERCENT_COLOR: TrayPercentColor = "#ffffff"; export const DEFAULT_GLOBAL_SHORTCUT: GlobalShortcut = null; export const DEFAULT_START_ON_LOGIN = true; -const AUTO_UPDATE_INTERVALS: AutoUpdateIntervalMinutes[] = [5, 15, 30, 60]; +const AUTO_UPDATE_INTERVALS: AutoUpdateInterval[] = [5, 15, 30, 60, "smart"]; const THEME_MODES: ThemeMode[] = ["dark", "light"]; const DISPLAY_MODES: DisplayMode[] = ["used", "left"]; const RESET_TIMER_DISPLAY_MODES: ResetTimerDisplayMode[] = ["relative", "absolute"]; @@ -103,11 +103,13 @@ export function isPerProviderTrayStyle(style: MenubarIconStyle): boolean { return style === "numbers" || style === "logos"; } -export const AUTO_UPDATE_OPTIONS: SettingOption[] = +export const AUTO_UPDATE_OPTIONS: SettingOption[] = AUTO_UPDATE_INTERVALS.map((value) => - value === 60 - ? { value, labelKey: "settings.autoRefresh.hour" } - : { value, labelKey: "settings.autoRefresh.minutes", labelVars: { n: value } } + value === "smart" + ? { value, labelKey: "settings.autoRefresh.smart" } + : value === 60 + ? { value, labelKey: "settings.autoRefresh.hour" } + : { value, labelKey: "settings.autoRefresh.minutes", labelVars: { n: value } } ); export const THEME_OPTIONS: SettingOption[] = THEME_MODES.map((value) => ({ @@ -181,21 +183,21 @@ export function migrateWindsurfToDevin(settings: PluginSettings): PluginSettings }; } -function isAutoUpdateInterval(value: unknown): value is AutoUpdateIntervalMinutes { +function isAutoUpdateInterval(value: unknown): value is AutoUpdateInterval { return ( - typeof value === "number" && - AUTO_UPDATE_INTERVALS.includes(value as AutoUpdateIntervalMinutes) + (typeof value === "number" || value === "smart") && + AUTO_UPDATE_INTERVALS.includes(value as AutoUpdateInterval) ); } -export async function loadAutoUpdateInterval(): Promise { +export async function loadAutoUpdateInterval(): Promise { const stored = await store.get(AUTO_UPDATE_SETTINGS_KEY); if (isAutoUpdateInterval(stored)) return stored; return DEFAULT_AUTO_UPDATE_INTERVAL; } export async function saveAutoUpdateInterval( - interval: AutoUpdateIntervalMinutes + interval: AutoUpdateInterval ): Promise { await store.set(AUTO_UPDATE_SETTINGS_KEY, interval); await store.save(); diff --git a/src/lib/smart-refresh.test.ts b/src/lib/smart-refresh.test.ts new file mode 100644 index 0000000..8e2df42 --- /dev/null +++ b/src/lib/smart-refresh.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest" +import type { PluginOutput } from "@/lib/plugin-types" +import { completeSmartCheck, MINUTE, newSmartSchedule, quotaSignature } from "@/lib/smart-refresh" + +const output = (used = 20): PluginOutput => ({ + providerId: "claude", displayName: "Claude", iconUrl: "", + lines: [{ type: "progress", label: "Session", used, limit: 100, format: { kind: "percent" } }], +}) + +describe("Smart refresh schedule", () => { + it("holds one minute for five minutes, then backs off 2, 5, 10, 15 and stays capped", () => { + let now = 100_000 + let state = completeSmartCheck(newSmartSchedule(now), output(), false, now) + for (let minute = 1; minute < 5; minute++) { + now = state.nextAt! + state = completeSmartCheck(state, output(), false, now) + expect(state.nextAt! - now).toBe(MINUTE) + } + for (const minutes of [2, 5, 10, 15, 15]) { + now = state.nextAt! + state = completeSmartCheck(state, output(), false, now) + expect(state.nextAt! - now).toBe(minutes * MINUTE) + } + state = completeSmartCheck(state, output(20.01), false, now) + expect(state.nextAt).toBe(now + MINUTE) + }) + + it("compares raw numbers independently of text, countdowns, charts and ordering", () => { + const data = output() + const other: PluginOutput = { ...data, lines: [ + { type: "text", label: "Reset", value: "3 minutes" }, + { ...data.lines[0], resetsAt: "tomorrow" } as PluginOutput["lines"][number], + { type: "barChart", label: "Local history", points: [{ label: "today", value: 5 }] }, + ] } + expect(quotaSignature(data)).toBe(quotaSignature(other)) + expect(quotaSignature(output(20.01))).not.toBe(quotaSignature(data)) + }) + + it("backs off failures and stale data without treating either as idle success", () => { + let now = 100_000 + let state = newSmartSchedule(now) + for (const minutes of [2, 5, 10, 15, 15]) { + state = completeSmartCheck(state, output(), true, now) + expect(state.nextAt! - now).toBe(minutes * MINUTE) + now = state.nextAt! + } + state = completeSmartCheck(state, { ...output(), staleSince: now - MINUTE }, false, now) + expect(state.failures).toBe(6) + state = completeSmartCheck(state, output(), false, now) + expect(state.failures).toBe(0) + expect(state.nextAt).toBe(now + MINUTE) + }) +}) diff --git a/src/lib/smart-refresh.ts b/src/lib/smart-refresh.ts new file mode 100644 index 0000000..a016386 --- /dev/null +++ b/src/lib/smart-refresh.ts @@ -0,0 +1,48 @@ +import type { PluginOutput } from "@/lib/plugin-types" + +export const MINUTE = 60_000 +const IDLE_MINUTES = [2, 5, 10, 15] + +export type SmartSchedule = { + signature: string | null + activeUntil: number + idleStep: number + failures: number + completedAt: number + nextAt: number | null +} + +export function newSmartSchedule(now: number): SmartSchedule { + return { + signature: null, activeUntil: now + 5 * MINUTE, idleStep: 0, + failures: 0, completedAt: 0, nextAt: now + MINUTE, + } +} + +/** Compare raw quota numbers, never ticking reset text, status badges or local history charts. */ +export function quotaSignature(output: PluginOutput): string { + return JSON.stringify(output.lines.flatMap((line) => + line.type === "progress" ? [[line.label, line.used, line.limit]] : [] + ).sort((a, b) => String(a[0]).localeCompare(String(b[0])))) +} + +/** A result, not a request start, advances the account's schedule. */ +export function completeSmartCheck(schedule: SmartSchedule, output: PluginOutput | null, error: boolean, now: number): SmartSchedule { + if (error || !output || output.staleSince != null) { + const failures = schedule.failures + 1 + return { ...schedule, failures, completedAt: now, nextAt: now + IDLE_MINUTES[Math.min(failures - 1, 3)] * MINUTE } + } + const signature = quotaSignature(output) + const changed = signature !== schedule.signature + const activeUntil = changed ? now + 5 * MINUTE : schedule.activeUntil + const active = now < activeUntil + const idleStep = active || schedule.failures > 0 ? 0 : schedule.idleStep + return { + signature, + activeUntil, + idleStep: active ? 0 : Math.min(idleStep + 1, 3), + failures: 0, + completedAt: now, + nextAt: now + (active ? 1 : IDLE_MINUTES[idleStep]) * MINUTE, + } +} diff --git a/src/locales/de.ts b/src/locales/de.ts index c267343..2c22606 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -41,6 +41,8 @@ export const de: Messages = { "settings.autoRefresh.title": "Automatische Aktualisierung", "settings.autoRefresh.desc": "Wie oft die Nutzung abgerufen wird", "settings.autoRefresh.minutes": "{n} Min.", + "settings.autoRefresh.smart": "Intelligent", + "settings.autoRefresh.smartHint": "Prüft nach Nutzungsänderungen jede Minute. Nach 5 Minuten ohne Änderung verlängert sich der Abstand pro Konto auf 2, 5, 10 und schließlich 15 Minuten.", "settings.autoRefresh.hour": "1 Stunde", "settings.usageMode.title": "Nutzungsanzeige", "settings.usageMode.desc": "Verbleibende oder verbrauchte Menge anzeigen", diff --git a/src/locales/en.lock.json b/src/locales/en.lock.json index 747b2bf..8de8160 100644 --- a/src/locales/en.lock.json +++ b/src/locales/en.lock.json @@ -35,6 +35,8 @@ "settings.autoRefresh.title": "14fea77455", "settings.autoRefresh.desc": "7385dfb102", "settings.autoRefresh.minutes": "37e737d92c", + "settings.autoRefresh.smart": "46b885d1d7", + "settings.autoRefresh.smartHint": "72307b0b63", "settings.autoRefresh.hour": "f030c3d667", "settings.usageMode.title": "7b9bf50924", "settings.usageMode.desc": "ff82de9374", diff --git a/src/locales/en.ts b/src/locales/en.ts index 259e02c..f7778ab 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -41,6 +41,8 @@ export const en = { "settings.autoRefresh.title": "Auto refresh", "settings.autoRefresh.desc": "How often usage is fetched", "settings.autoRefresh.minutes": "{n} min", + "settings.autoRefresh.smart": "Smart", + "settings.autoRefresh.smartHint": "Checks every minute after usage changes. After 5 quiet minutes, slows to 2, 5, 10, then 15 minutes per account.", "settings.autoRefresh.hour": "1 hour", "settings.usageMode.title": "Usage mode", "settings.usageMode.desc": "Show what's left or what you've used", diff --git a/src/locales/es-419.ts b/src/locales/es-419.ts index d3c4dbe..227eca4 100644 --- a/src/locales/es-419.ts +++ b/src/locales/es-419.ts @@ -41,6 +41,8 @@ export const es419: Messages = { "settings.autoRefresh.title": "Actualización automática", "settings.autoRefresh.desc": "Frecuencia con la que se consulta el uso", "settings.autoRefresh.minutes": "{n} min", + "settings.autoRefresh.smart": "Inteligente", + "settings.autoRefresh.smartHint": "Consulta cada minuto tras un cambio de uso. Después de 5 minutos sin cambios, el intervalo aumenta a 2, 5, 10 y luego 15 minutos por cuenta.", "settings.autoRefresh.hour": "1 hora", "settings.usageMode.title": "Modo de uso", "settings.usageMode.desc": "Mostrar lo que queda o lo que usaste", diff --git a/src/locales/es-ES.ts b/src/locales/es-ES.ts index 9b9fe73..6359158 100644 --- a/src/locales/es-ES.ts +++ b/src/locales/es-ES.ts @@ -41,6 +41,8 @@ export const esES: Messages = { "settings.autoRefresh.title": "Actualización automática", "settings.autoRefresh.desc": "Frecuencia con la que se consulta el uso", "settings.autoRefresh.minutes": "{n} min", + "settings.autoRefresh.smart": "Inteligente", + "settings.autoRefresh.smartHint": "Consulta cada minuto tras un cambio de uso. Después de 5 minutos sin cambios, el intervalo aumenta a 2, 5, 10 y luego 15 minutos por cuenta.", "settings.autoRefresh.hour": "1 hora", "settings.usageMode.title": "Modo de uso", "settings.usageMode.desc": "Mostrar lo que queda o lo que has usado", diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 6af0f07..bc08d1e 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -41,6 +41,8 @@ export const fr: Messages = { "settings.autoRefresh.title": "Actualisation automatique", "settings.autoRefresh.desc": "Fréquence d'actualisation de l'utilisation", "settings.autoRefresh.minutes": "{n} min", + "settings.autoRefresh.smart": "Intelligent", + "settings.autoRefresh.smartHint": "Vérifie chaque minute après un changement d’utilisation. Après 5 minutes sans changement, l’intervalle passe à 2, 5, 10 puis 15 minutes par compte.", "settings.autoRefresh.hour": "1 heure", "settings.usageMode.title": "Affichage de l'utilisation", "settings.usageMode.desc": "Afficher ce qu'il reste ou ce qui a été utilisé", diff --git a/src/locales/it.ts b/src/locales/it.ts index ebef47b..355e19e 100644 --- a/src/locales/it.ts +++ b/src/locales/it.ts @@ -41,6 +41,8 @@ export const it: Messages = { "settings.autoRefresh.title": "Aggiornamento automatico", "settings.autoRefresh.desc": "Frequenza di aggiornamento dei dati di utilizzo", "settings.autoRefresh.minutes": "{n} min", + "settings.autoRefresh.smart": "Intelligente", + "settings.autoRefresh.smartHint": "Controlla ogni minuto dopo una variazione dell’utilizzo. Dopo 5 minuti senza variazioni, l’intervallo passa a 2, 5, 10 e infine 15 minuti per account.", "settings.autoRefresh.hour": "1 ora", "settings.usageMode.title": "Visualizzazione utilizzo", "settings.usageMode.desc": "Mostra la quota rimanente o quella utilizzata", diff --git a/src/locales/ja.ts b/src/locales/ja.ts index 6d6d45e..e069515 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -41,6 +41,8 @@ export const ja: Messages = { "settings.autoRefresh.title": "自動更新", "settings.autoRefresh.desc": "使用状況を取得する間隔", "settings.autoRefresh.minutes": "{n}分", + "settings.autoRefresh.smart": "スマート", + "settings.autoRefresh.smartHint": "使用量が変化すると毎分確認します。5分間変化がなければ、アカウントごとに確認間隔を2分、5分、10分、15分に延ばします。", "settings.autoRefresh.hour": "1時間", "settings.usageMode.title": "使用量の表示", "settings.usageMode.desc": "残量または使用量を表示", diff --git a/src/locales/ko.ts b/src/locales/ko.ts index 89ed7be..182bc90 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -41,6 +41,8 @@ export const ko: Messages = { "settings.autoRefresh.title": "자동 새로 고침", "settings.autoRefresh.desc": "사용량을 가져오는 주기", "settings.autoRefresh.minutes": "{n}분", + "settings.autoRefresh.smart": "스마트", + "settings.autoRefresh.smartHint": "사용량이 변경되면 매분 확인합니다. 5분 동안 변화가 없으면 계정별 확인 간격을 2분, 5분, 10분, 15분으로 늘립니다.", "settings.autoRefresh.hour": "1시간", "settings.usageMode.title": "사용량 표시", "settings.usageMode.desc": "남은 양 또는 사용한 양 표시", diff --git a/src/locales/pt-BR.ts b/src/locales/pt-BR.ts index 23a33ff..757425b 100644 --- a/src/locales/pt-BR.ts +++ b/src/locales/pt-BR.ts @@ -41,6 +41,8 @@ export const ptBR: Messages = { "settings.autoRefresh.title": "Atualização automática", "settings.autoRefresh.desc": "Com que frequência o uso é atualizado", "settings.autoRefresh.minutes": "{n} min", + "settings.autoRefresh.smart": "Inteligente", + "settings.autoRefresh.smartHint": "Verifica a cada minuto após mudanças no uso. Após 5 minutos sem mudanças, o intervalo passa para 2, 5, 10 e depois 15 minutos por conta.", "settings.autoRefresh.hour": "1 hora", "settings.usageMode.title": "Exibição de uso", "settings.usageMode.desc": "Mostre o que resta ou o que já foi usado", diff --git a/src/locales/tr.ts b/src/locales/tr.ts index 4d9c5b4..6aa6764 100644 --- a/src/locales/tr.ts +++ b/src/locales/tr.ts @@ -41,6 +41,8 @@ export const tr: Messages = { "settings.autoRefresh.title": "Otomatik yenileme", "settings.autoRefresh.desc": "Kullanım verilerinin alınma sıklığı", "settings.autoRefresh.minutes": "{n} dk", + "settings.autoRefresh.smart": "Akıllı", + "settings.autoRefresh.smartHint": "Kullanım değiştikten sonra her dakika kontrol eder. 5 dakika değişiklik olmazsa hesap başına kontrol aralığını 2, 5, 10 ve ardından 15 dakikaya çıkarır.", "settings.autoRefresh.hour": "1 saat", "settings.usageMode.title": "Kullanım modu", "settings.usageMode.desc": "Kalan veya kullanılan miktarı göster", diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index afbe1d5..111d56d 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -41,6 +41,8 @@ export const zhCN: Messages = { "settings.autoRefresh.title": "自动刷新", "settings.autoRefresh.desc": "获取用量的频率", "settings.autoRefresh.minutes": "{n}分钟", + "settings.autoRefresh.smart": "智能", + "settings.autoRefresh.smartHint": "用量变化后每分钟检查一次。连续5分钟没有变化后,每个账户的检查间隔逐步延长为2、5、10、15分钟。", "settings.autoRefresh.hour": "1小时", "settings.usageMode.title": "用量模式", "settings.usageMode.desc": "显示剩余量或已用量", diff --git a/src/pages/settings.test.tsx b/src/pages/settings.test.tsx index 47888a3..07a9bb6 100644 --- a/src/pages/settings.test.tsx +++ b/src/pages/settings.test.tsx @@ -303,4 +303,13 @@ describe("SettingsPage", () => { await userEvent.click(screen.getByText("Start on login")) expect(onStartOnLoginChange).toHaveBeenCalledWith(true) }) + + it("offers Smart refresh and explains its per-account schedule", async () => { + const onAutoUpdateIntervalChange = vi.fn() + render() + expect(screen.getByRole("radio", { name: "Smart" })).toHaveAttribute("aria-checked", "true") + expect(screen.getByText(/After 5 quiet minutes/)).toBeInTheDocument() + await userEvent.click(screen.getByRole("radio", { name: "15 min" })) + expect(onAutoUpdateIntervalChange).toHaveBeenCalledWith(15) + }) }) diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index 53fe46c..b84a30e 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -45,7 +45,7 @@ import { USAGE_ALERT_LEVELS, type AlertSettings, type AlertSound, - type AutoUpdateIntervalMinutes, + type AutoUpdateInterval, type DisplayMode, type GlobalShortcut, type MenubarIconStyle, @@ -470,8 +470,8 @@ interface SettingsPageProps { onReorder: (orderedIds: string[]) => void; onToggle: (id: string) => void; onAccountsChanged: (change: AccountsChange) => void; - autoUpdateInterval: AutoUpdateIntervalMinutes; - onAutoUpdateIntervalChange: (value: AutoUpdateIntervalMinutes) => void; + autoUpdateInterval: AutoUpdateInterval; + onAutoUpdateIntervalChange: (value: AutoUpdateInterval) => void; themeMode: ThemeMode; onThemeModeChange: (value: ThemeMode) => void; displayMode: DisplayMode; @@ -586,6 +586,9 @@ export function SettingsPage({ value={autoUpdateInterval} onChange={onAutoUpdateIntervalChange} /> + {autoUpdateInterval === "smart" && ( +

{t("settings.autoRefresh.smartHint")}

+ )} diff --git a/src/stores/app-preferences-store.ts b/src/stores/app-preferences-store.ts index 06c59a1..6541dbe 100644 --- a/src/stores/app-preferences-store.ts +++ b/src/stores/app-preferences-store.ts @@ -14,7 +14,7 @@ import { DEFAULT_TRAY_PERCENT_COLOR, DEFAULT_TRAY_PROVIDER, type AlertSettings, - type AutoUpdateIntervalMinutes, + type AutoUpdateInterval, type DisplayMode, type GlobalShortcut, type MenubarIconStyle, @@ -28,7 +28,7 @@ import { } from "@/lib/settings" type AppPreferencesStore = { - autoUpdateInterval: AutoUpdateIntervalMinutes + autoUpdateInterval: AutoUpdateInterval themeMode: ThemeMode displayMode: DisplayMode resetTimerDisplayMode: ResetTimerDisplayMode @@ -47,7 +47,7 @@ type AppPreferencesStore = { trayLogoColors: boolean taskbarStrip: boolean taskbarStripStyle: TaskbarStripStyle - setAutoUpdateInterval: (value: AutoUpdateIntervalMinutes) => void + setAutoUpdateInterval: (value: AutoUpdateInterval) => void setThemeMode: (value: ThemeMode) => void setDisplayMode: (value: DisplayMode) => void setResetTimerDisplayMode: (value: ResetTimerDisplayMode) => void From 09b3d6a690e2848516cf9fdbe46ee67a3b26c390 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Thu, 15 Oct 2026 16:41:28 -0400 Subject: [PATCH 2/3] fix: show refreshing while Smart checks are in flight --- src/components/app/app-shell.tsx | 1 + src/components/panel-footer.test.tsx | 8 ++++++++ src/components/panel-footer.tsx | 6 ++++-- src/locales/de.ts | 1 + src/locales/en.lock.json | 1 + src/locales/en.ts | 1 + src/locales/es-419.ts | 1 + src/locales/es-ES.ts | 1 + src/locales/fr.ts | 1 + src/locales/it.ts | 1 + src/locales/ja.ts | 1 + src/locales/ko.ts | 1 + src/locales/pt-BR.ts | 1 + src/locales/tr.ts | 1 + src/locales/zh-CN.ts | 1 + 15 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/components/app/app-shell.tsx b/src/components/app/app-shell.tsx index 2c40c2c..f6a20a5 100644 --- a/src/components/app/app-shell.tsx +++ b/src/components/app/app-shell.tsx @@ -168,6 +168,7 @@ export function AppShell({ /> plugin.loading)} version={appVersion} autoUpdateNextAt={autoUpdateNextAt} updateStatus={updateStatus} diff --git a/src/components/panel-footer.test.tsx b/src/components/panel-footer.test.tsx index af61831..7c4ec78 100644 --- a/src/components/panel-footer.test.tsx +++ b/src/components/panel-footer.test.tsx @@ -14,6 +14,14 @@ const noop = () => {} const footerProps = { showAbout: false, onShowAbout: noop, onCloseAbout: noop, onUpdateCheck: noop } describe("PanelFooter", () => { + it("shows Refreshing instead of Paused while waiting for checks to complete", () => { + render( + + ) + expect(screen.getByText("Refreshing…")).toBeInTheDocument() + expect(screen.queryByText("Paused")).not.toBeInTheDocument() + }) + it("shortens the countdown to just the time while an update is shown, keeping the full text in the tooltip", () => { render( (["downloading", "ready", "i interface PanelFooterProps { version: string; autoUpdateNextAt: number | null; + refreshing?: boolean; updateStatus: UpdateStatus; onUpdateInstall: () => void; onUpdateCheck: () => void; @@ -99,6 +100,7 @@ function VersionDisplay({ export function PanelFooter({ version, autoUpdateNextAt, + refreshing = false, updateStatus, onUpdateInstall, onUpdateCheck, @@ -117,7 +119,7 @@ export function PanelFooter({ const updateShown = UPDATE_SHOWN.has(updateStatus.status); const { countdownLabel, countdownFull } = useMemo(() => { if (!autoUpdateNextAt) { - const paused = t("footer.paused"); + const paused = t(refreshing ? "footer.refreshing" : "footer.paused"); return { countdownLabel: paused, countdownFull: paused }; } const remainingMs = Math.max(0, autoUpdateNextAt - now); @@ -127,7 +129,7 @@ export function PanelFooter({ : t("duration.s", { s: totalSeconds }); const full = t("footer.nextUpdate", { time }); return { countdownLabel: updateShown ? time : full, countdownFull: full }; - }, [autoUpdateNextAt, now, updateShown]); + }, [autoUpdateNextAt, now, updateShown, refreshing]); return ( <> diff --git a/src/locales/de.ts b/src/locales/de.ts index 2c22606..7dbe187 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -27,6 +27,7 @@ export const de: Messages = { "footer.updateCheckFailed": "Suche nach Updates fehlgeschlagen", "footer.updateDownloadFailed": "Update konnte nicht heruntergeladen werden", "footer.updateInstallFailed": "Update konnte nicht installiert werden", + "footer.refreshing": "Wird aktualisiert…", "footer.paused": "Angehalten", "footer.nextUpdate": "Aktualisierung in {time}", "footer.refreshNow": "Jetzt aktualisieren", diff --git a/src/locales/en.lock.json b/src/locales/en.lock.json index 8de8160..d25f340 100644 --- a/src/locales/en.lock.json +++ b/src/locales/en.lock.json @@ -23,6 +23,7 @@ "footer.updateCheckFailed": "1c6953c91c", "footer.updateDownloadFailed": "1f7d735f1c", "footer.updateInstallFailed": "b93eb8d97c", + "footer.refreshing": "96141178bb", "footer.paused": "c7dfb6f1d9", "footer.nextUpdate": "383672885a", "footer.refreshNow": "29664b3f23", diff --git a/src/locales/en.ts b/src/locales/en.ts index f7778ab..1300ec6 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -27,6 +27,7 @@ export const en = { "footer.updateCheckFailed": "Couldn't check for updates", "footer.updateDownloadFailed": "Couldn't download the update", "footer.updateInstallFailed": "Couldn't install the update", + "footer.refreshing": "Refreshing…", "footer.paused": "Paused", "footer.nextUpdate": "Next update in {time}", "footer.refreshNow": "Refresh now", diff --git a/src/locales/es-419.ts b/src/locales/es-419.ts index 227eca4..f696821 100644 --- a/src/locales/es-419.ts +++ b/src/locales/es-419.ts @@ -27,6 +27,7 @@ export const es419: Messages = { "footer.updateCheckFailed": "No se pudo buscar actualizaciones", "footer.updateDownloadFailed": "No se pudo descargar la actualización", "footer.updateInstallFailed": "No se pudo instalar la actualización", + "footer.refreshing": "Actualizando…", "footer.paused": "En pausa", "footer.nextUpdate": "Próxima actualización en {time}", "footer.refreshNow": "Actualizar ahora", diff --git a/src/locales/es-ES.ts b/src/locales/es-ES.ts index 6359158..ebcfde4 100644 --- a/src/locales/es-ES.ts +++ b/src/locales/es-ES.ts @@ -27,6 +27,7 @@ export const esES: Messages = { "footer.updateCheckFailed": "No se pudo buscar actualizaciones", "footer.updateDownloadFailed": "No se pudo descargar la actualización", "footer.updateInstallFailed": "No se pudo instalar la actualización", + "footer.refreshing": "Actualizando…", "footer.paused": "En pausa", "footer.nextUpdate": "Próxima actualización en {time}", "footer.refreshNow": "Actualizar ahora", diff --git a/src/locales/fr.ts b/src/locales/fr.ts index bc08d1e..9b4b0b5 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -27,6 +27,7 @@ export const fr: Messages = { "footer.updateCheckFailed": "Impossible de rechercher les mises à jour", "footer.updateDownloadFailed": "Impossible de télécharger la mise à jour", "footer.updateInstallFailed": "Impossible d'installer la mise à jour", + "footer.refreshing": "Actualisation…", "footer.paused": "En pause", "footer.nextUpdate": "Actualisation dans {time}", "footer.refreshNow": "Actualiser maintenant", diff --git a/src/locales/it.ts b/src/locales/it.ts index 355e19e..afd6f6f 100644 --- a/src/locales/it.ts +++ b/src/locales/it.ts @@ -27,6 +27,7 @@ export const it: Messages = { "footer.updateCheckFailed": "Impossibile verificare gli aggiornamenti", "footer.updateDownloadFailed": "Impossibile scaricare l'aggiornamento", "footer.updateInstallFailed": "Impossibile installare l'aggiornamento", + "footer.refreshing": "Aggiornamento…", "footer.paused": "In pausa", "footer.nextUpdate": "Aggiornamento tra {time}", "footer.refreshNow": "Aggiorna ora", diff --git a/src/locales/ja.ts b/src/locales/ja.ts index e069515..bc312d2 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -27,6 +27,7 @@ export const ja: Messages = { "footer.updateCheckFailed": "更新を確認できませんでした", "footer.updateDownloadFailed": "更新をダウンロードできませんでした", "footer.updateInstallFailed": "更新をインストールできませんでした", + "footer.refreshing": "更新中…", "footer.paused": "一時停止中", "footer.nextUpdate": "次の更新まで {time}", "footer.refreshNow": "今すぐ更新", diff --git a/src/locales/ko.ts b/src/locales/ko.ts index 182bc90..a9d7c81 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -27,6 +27,7 @@ export const ko: Messages = { "footer.updateCheckFailed": "업데이트를 확인할 수 없습니다", "footer.updateDownloadFailed": "업데이트를 다운로드할 수 없습니다", "footer.updateInstallFailed": "업데이트를 설치할 수 없습니다", + "footer.refreshing": "새로 고치는 중…", "footer.paused": "일시 중지됨", "footer.nextUpdate": "{time} 후 다음 업데이트", "footer.refreshNow": "지금 새로 고침", diff --git a/src/locales/pt-BR.ts b/src/locales/pt-BR.ts index 757425b..98e1ea7 100644 --- a/src/locales/pt-BR.ts +++ b/src/locales/pt-BR.ts @@ -27,6 +27,7 @@ export const ptBR: Messages = { "footer.updateCheckFailed": "Não foi possível verificar atualizações", "footer.updateDownloadFailed": "Não foi possível baixar a atualização", "footer.updateInstallFailed": "Não foi possível instalar a atualização", + "footer.refreshing": "Atualizando…", "footer.paused": "Pausado", "footer.nextUpdate": "Atualização em {time}", "footer.refreshNow": "Atualizar agora", diff --git a/src/locales/tr.ts b/src/locales/tr.ts index 6aa6764..b239870 100644 --- a/src/locales/tr.ts +++ b/src/locales/tr.ts @@ -27,6 +27,7 @@ export const tr: Messages = { "footer.updateCheckFailed": "Güncelleştirmeler denetlenemedi", "footer.updateDownloadFailed": "Güncelleştirme indirilemedi", "footer.updateInstallFailed": "Güncelleştirme yüklenemedi", + "footer.refreshing": "Yenileniyor…", "footer.paused": "Duraklatıldı", "footer.nextUpdate": "Sonraki yenileme: {time} sonra", "footer.refreshNow": "Şimdi yenile", diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index 111d56d..e89b549 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -27,6 +27,7 @@ export const zhCN: Messages = { "footer.updateCheckFailed": "无法检查更新", "footer.updateDownloadFailed": "无法下载更新", "footer.updateInstallFailed": "无法安装更新", + "footer.refreshing": "正在刷新…", "footer.paused": "已暂停", "footer.nextUpdate": "下次刷新:{time}后", "footer.refreshNow": "立即刷新", From 9a2cda3ea9aadb32a2328238119f9a863978f0cc Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Thu, 15 Oct 2026 16:44:17 -0400 Subject: [PATCH 3/3] docs: clarify Smart refresh local-history caching --- docs/plugins/api.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/plugins/api.md b/docs/plugins/api.md index d37bc11..0d1e672 100644 --- a/docs/plugins/api.md +++ b/docs/plugins/api.md @@ -551,6 +551,7 @@ Returns a status envelope: ### Behavior - **Runtime runners**: Executes pinned `ccusage@20.0.14` via fallback chain `bunx -> pnpm dlx -> yarn dlx -> npm exec -> npx` +- **Smart refresh**: Automatic Smart polls may reuse a successful result for up to 15 minutes, keyed by account and query options. Manual refreshes and fixed-interval polls bypass and update this cache. Failed queries are not cached. Provider quota requests remain live. - **Provider-aware**: Resolves provider from `opts.provider` or plugin id (`claude`/`codex`) - **Focused commands**: Uses `ccusage claude daily` or `ccusage codex daily`; it intentionally does not use `ccusage daily` because that aggregates all detected agents - **Legacy fallback**: If `ccusage@20.0.14` cannot run through the package manager release-age policy, retries with release-age-safe `ccusage@18.0.11` for Claude or `@ccusage/codex@18.0.11` for Codex