diff --git a/AGENTS.md b/AGENTS.md index b23b848b4f..7f7cc345ea 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ - Cargo workspace (root `Cargo.toml`): members `rust`, `apps/desktop-tauri/src-tauri`; **default-member** is the Tauri crate. - Path dependency: `codexbar-desktop-tauri` → `codexbar = { path = "../../../rust" }`. - Frontend: React 18 + Vite in `apps/desktop-tauri/src/`. Typed invoke bridge in `src/lib/tauri.ts`; DTOs in `src/types/bridge.ts`. -- Surfaces: the hidden `main` webview routes by window label / surface mode — TrayPanel, PopOut, Settings, FloatBar. Settings, float bar, and flyout use detached windows where needed. +- Surfaces: the hidden `main` webview routes by window label / surface mode — TrayPanel, Settings, FloatBar. Settings, float bar, and the tray-panel flyout use detached windows. The flyout's TrayPanel is the only dashboard layout; the legacy PopOut layout is retired (`SurfaceMode::PopOut` remains only as a data key). - **Provider refresh**: `codexbar::core::instantiate_provider` (`rust/src/core/provider_factory.rs`) → `Provider::fetch_usage` → shell `commands/providers.rs` (semaphore + timeout) → `AppState.provider_cache` → events → React `useProviders`. - **Settings**: `%config%/CodexBar/settings.json` via `Settings::load` / `save` and `secure_file` (DPAPI-capable on Windows). Frontend `updateSettings` patch → save → `codexbar:settings-updated` / float-bar config events. - **Tray**: `tray_bridge` + `tray_menu`. Icon pixels from shared `codexbar::tray::{render_bar_icon_rgba, render_percent_icon_rgba}`. diff --git a/README.es-MX.md b/README.es-MX.md index dbd389b54f..389d3186b8 100644 --- a/README.es-MX.md +++ b/README.es-MX.md @@ -102,7 +102,6 @@ Consulta el historial completo en [CHANGELOG.md](CHANGELOG.md). | Xiaomi MiMo | Cookies | Saldo, Plan de tokens | | Doubao | Clave API | Límites de solicitudes | | Command Code | Cookies | Créditos mensuales, Créditos comprados | -| Crof | Clave API | Créditos, Cuota de solicitudes | | StepFun | Token Oasis | 5h, Semanal, Refresco de token | | Venice | Clave API | Saldo USD / DIEM | | OpenAI | Admin API / Clave API | Uso, Solicitudes, Costo con ámbito de proyecto, Saldo de créditos | diff --git a/README.md b/README.md index 2e052ad2f9..ec974c6265 100755 --- a/README.md +++ b/README.md @@ -109,7 +109,6 @@ See the full history in [CHANGELOG.md](CHANGELOG.md). | Xiaomi MiMo | Cookies | Balance, Token Plan | | Doubao | API Key | Request Limits | | Command Code | Cookies | Monthly Credits, Purchased Credits | -| Crof | API Key | Credits, Request Quota | | StepFun | Oasis Token | 5h, Weekly, Token refresh | | Venice | API Key | USD / DIEM Balance | | OpenAI | Admin API / API Key | Usage, Requests, Project-scoped cost, Credit Balance | diff --git a/README.tr-TR.md b/README.tr-TR.md index 47ab7bb13b..0ae7410775 100644 --- a/README.tr-TR.md +++ b/README.tr-TR.md @@ -107,7 +107,6 @@ Tüm geçmişi [CHANGELOG.md](CHANGELOG.md) dosyasında görebilirsiniz. | Xiaomi MiMo | Çerezler | Bakiye, Token Planı | | Doubao | API Anahtarı | İstek Sınırları | | Command Code | Çerezler | Aylık Krediler, Satın Alınan Krediler | -| Crof | API Anahtarı | Krediler, İstek Kotası | | StepFun | Oasis Tokenı | 5 saatlik, Haftalık, Token yenileme | | Venice | API Anahtarı | USD / DIEM Bakiyesi | | OpenAI | Yönetici API'si / API Anahtarı | Kullanım, İstekler, Proje kapsamlı maliyet, Kredi Bakiyesi | diff --git a/README.zh-CN.md b/README.zh-CN.md index 2776c0fa95..55550f15e8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -14,7 +14,7 @@ ## 功能特性 -- **56 个 AI 服务商** — Codex、Claude、Cursor、Factory、Gemini、Copilot、Antigravity、z.ai、MiniMax、Kiro、Vertex AI、Augment、OpenCode、Kimi、Kimi K2、Amp、Warp、Ollama、Azure OpenAI、T3 Chat、OpenRouter、JetBrains AI、Alibaba、Alibaba Token Plan、NanoGPT、Infini、Perplexity、Abacus AI、Mistral、OpenCode Go、Kilo、AWS Bedrock、Codebuff、DeepSeek、Windsurf、Manus、小米 MiMo、Doubao、Command Code、Crof、StepFun、Venice、OpenAI、Grok、ElevenLabs、Deepgram、Groq、LLM Proxy、Chutes、LiteLLM、Poe、Devin、Zed、CrossModel、Qoder、Sakana AI +- **56 个 AI 服务商** — Codex、Claude、Cursor、Factory、Gemini、Copilot、Antigravity、z.ai、MiniMax、Kiro、Vertex AI、Augment、OpenCode、Kimi、Kimi K2、Amp、Warp、Ollama、Azure OpenAI、T3 Chat、OpenRouter、JetBrains AI、Alibaba、Alibaba Token Plan、NanoGPT、Infini、Perplexity、Abacus AI、Mistral、OpenCode Go、Kilo、AWS Bedrock、Codebuff、DeepSeek、Windsurf、Manus、小米 MiMo、Doubao、Command Code、StepFun、Venice、OpenAI、Grok、ElevenLabs、Deepgram、Groq、LLM Proxy、Chutes、LiteLLM、Poe、Devin、Zed、CrossModel、Qoder、Sakana AI - **系统托盘图标** — 动态双条进度显示会话与周用量 - **Floating Bar** — 可选的置顶透明用量条,支持方向、透明度和点击穿透控制 - **浏览器 Cookie 导入** — Chrome、Edge、Brave、Firefox(Windows DPAPI 解密) @@ -217,7 +217,6 @@ codexbar cost -p codex # 本地成本(JSONL 日志) | 小米 MiMo | Cookies | 余额、Token 套餐 | | Doubao | API Key | 请求限制 | | Command Code | Cookies | 月度 Credits、已购 Credits | -| Crof | API Key | Credits、请求配额 | | StepFun | Oasis Token | 5h、周用量 | | Venice | API Key | USD / DIEM 余额 | | OpenAI | Admin API / API Key | 用量、请求数、余额 | diff --git a/README.zh-TW.md b/README.zh-TW.md index cbda22a135..b2f3dfbe65 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -14,7 +14,7 @@ ## 功能特性 -- **56 個 AI 提供者** — Codex、Claude、Cursor、Factory、Gemini、Copilot、Antigravity、z.ai、MiniMax、Kiro、Vertex AI、Augment、OpenCode、Kimi、Kimi K2、Amp、Warp、Ollama、Azure OpenAI、T3 Chat、OpenRouter、JetBrains AI、Alibaba、Alibaba Token Plan、NanoGPT、Infini、Perplexity、Abacus AI、Mistral、OpenCode Go、Kilo、AWS Bedrock、Codebuff、DeepSeek、Windsurf、Manus、小米 MiMo、Doubao、Command Code、Crof、StepFun、Venice、OpenAI、Grok、ElevenLabs、Deepgram、Groq、LLM Proxy、Chutes、LiteLLM、Poe、Devin、Zed、CrossModel、Qoder、Sakana AI +- **56 個 AI 提供者** — Codex、Claude、Cursor、Factory、Gemini、Copilot、Antigravity、z.ai、MiniMax、Kiro、Vertex AI、Augment、OpenCode、Kimi、Kimi K2、Amp、Warp、Ollama、Azure OpenAI、T3 Chat、OpenRouter、JetBrains AI、Alibaba、Alibaba Token Plan、NanoGPT、Infini、Perplexity、Abacus AI、Mistral、OpenCode Go、Kilo、AWS Bedrock、Codebuff、DeepSeek、Windsurf、Manus、小米 MiMo、Doubao、Command Code、StepFun、Venice、OpenAI、Grok、ElevenLabs、Deepgram、Groq、LLM Proxy、Chutes、LiteLLM、Poe、Devin、Zed、CrossModel、Qoder、Sakana AI - **系統系統匣圖示** — 動態雙條進度顯示會話與周用量 - **Floating Bar** — 可選的置頂透明用量條,支援方向、透明度和點選穿透控制 - **瀏覽器 Cookie 匯入** — Chrome、Edge、Brave、Firefox(Windows DPAPI 解密) @@ -217,7 +217,6 @@ codexbar cost -p codex # 本機成本(JSONL 日誌) | 小米 MiMo | Cookies | 餘額、Token 套餐 | | Doubao | API Key | 請求限制 | | Command Code | Cookies | 月度 Credits、已購 Credits | -| Crof | API Key | Credits、請求配額 | | StepFun | Oasis Token | 5h、周用量 | | Venice | API Key | USD / DIEM 餘額 | | OpenAI | Admin API / API Key | 用量、請求數、餘額 | diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index ab5efbef8a..96955cf740 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -661,6 +661,8 @@ pub struct SettingsSnapshot { predictive_pace_warning_enabled: bool, show_pace: bool, tray_icon_mode: &'static str, + stacked_tray_top_provider: Option, + stacked_tray_bottom_provider: Option, switcher_shows_icons: bool, menu_bar_shows_highest_usage: bool, menu_bar_shows_percent: bool, @@ -783,6 +785,8 @@ impl From for SettingsSnapshot { predictive_pace_warning_enabled: settings.predictive_pace_warning_enabled, show_pace: settings.show_pace, tray_icon_mode: tray_icon_mode_label(settings.tray_icon_mode), + stacked_tray_top_provider: settings.stacked_tray_top_provider, + stacked_tray_bottom_provider: settings.stacked_tray_bottom_provider, switcher_shows_icons: settings.switcher_shows_icons, menu_bar_shows_highest_usage: settings.menu_bar_shows_highest_usage, menu_bar_shows_percent: settings.menu_bar_shows_percent, @@ -876,6 +880,7 @@ fn tray_icon_mode_label(mode: TrayIconMode) -> &'static str { match mode { TrayIconMode::Single => "single", TrayIconMode::PerProvider => "perProvider", + TrayIconMode::Stacked => "stacked", } } diff --git a/apps/desktop-tauri/src-tauri/src/commands/mod.rs b/apps/desktop-tauri/src-tauri/src/commands/mod.rs index fdfaeda967..d3aa838481 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/mod.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/mod.rs @@ -75,6 +75,8 @@ pub use surface::*; pub use system::*; pub(crate) use usage_items::*; +#[cfg(test)] +mod session_cookie_scope_tests; #[cfg(test)] mod tests; @@ -104,6 +106,25 @@ fn canonical_provider_arg(provider_id: &str) -> Result { Ok(parse_provider_arg(provider_id)?.cli_name().to_string()) } +fn provider_dashboard_url(id: ProviderId, settings: &Settings) -> Option { + match id { + ProviderId::MiniMax => Some( + codexbar::providers::MiniMaxProvider::dashboard_url_for_region(Some( + settings.api_region(id), + )), + ), + ProviderId::Kimi => Some( + codexbar::providers::KimiRegion::from_settings(Some(settings.api_region(id))) + .console_url() + .to_string(), + ), + _ => instantiate_provider(id) + .metadata() + .dashboard_url + .map(str::to_string), + } +} + fn validate_single_line_secret(value: &str, field: &str, max_len: usize) -> Result<(), String> { let trimmed = value.trim(); if trimmed.is_empty() { diff --git a/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs b/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs index 418bd5c146..6feeea4868 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs @@ -73,15 +73,7 @@ pub(crate) fn build_provider_detail( let provider = instantiate_provider(id); let metadata = provider.metadata(); let resume_supported = auto_resume_supported(id); - let dashboard_url = if id == codexbar::core::ProviderId::MiniMax { - Some( - codexbar::providers::MiniMaxProvider::dashboard_url_for_region(Some( - settings.api_region(id), - )), - ) - } else { - metadata.dashboard_url.map(|s| s.to_string()) - }; + let dashboard_url = provider_dashboard_url(id, &settings); let detail = ProviderDetail { id: id.cli_name().to_string(), diff --git a/apps/desktop-tauri/src-tauri/src/commands/provider_refresh.rs b/apps/desktop-tauri/src-tauri/src/commands/provider_refresh.rs index 80675e1eca..a92aa1bdc4 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_refresh.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_refresh.rs @@ -87,7 +87,7 @@ fn provider_cache_can_skip_refresh( .iter() .any(|snapshot| snapshot.provider_id == id.cli_name()) }); - if !force && crate::proof_harness::seed_usage_json_active() && cache_has_all { + if !force && state.provider_cache_seeded && cache_has_all { return true; } !force @@ -125,6 +125,38 @@ pub(super) fn complete_provider_refresh( mod tests { use super::*; + #[test] + fn validated_seed_pins_only_complete_nonforced_cache() { + let mut state = AppState::new(); + state.provider_cache.push( + crate::proof_harness::parse_seed_usage_snapshot( + r#"{"providerId":"codex","primary":{"usedPercent":25.0}}"#, + ) + .unwrap(), + ); + assert!(!provider_cache_can_skip_refresh( + &state, + false, + &[ProviderId::Codex] + )); + state.provider_cache_seeded = true; + assert!(provider_cache_can_skip_refresh( + &state, + false, + &[ProviderId::Codex] + )); + assert!(!provider_cache_can_skip_refresh( + &state, + true, + &[ProviderId::Codex] + )); + assert!(!provider_cache_can_skip_refresh( + &state, + false, + &[ProviderId::Codex, ProviderId::Claude] + )); + } + #[test] fn stale_inputs_cannot_reserve_a_new_generation_after_invalidation() { let mut state = AppState::new(); diff --git a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs index f9344fcfba..f2a3852773 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs @@ -226,6 +226,7 @@ fn cookie_source_provider(provider_id: &str) -> Option ProviderId::Replicate, "helmcode" => ProviderId::Helmcode, "typesafe" => ProviderId::TypeSafe, + "hyper" => ProviderId::Hyper, _ => return None, }) } @@ -272,6 +273,7 @@ fn region_provider(provider_id: &str) -> Option { "alibabatokenplan" => ProviderId::AlibabaTokenPlan, "zai" => ProviderId::Zai, "minimax" => ProviderId::MiniMax, + "kimi" => ProviderId::Kimi, _ => return None, }) } @@ -284,6 +286,10 @@ pub(crate) fn provider_region_lookup(settings: &Settings, provider_id: &str) -> )) .settings_value() .to_string() + } else if id == codexbar::core::ProviderId::Kimi { + codexbar::providers::KimiRegion::from_settings(Some(settings.api_region(id))) + .settings_value() + .to_string() } else { settings.api_region(id).to_string() } @@ -329,6 +335,7 @@ fn workspace_provider(provider_id: &str) -> Option { "xai" => ProviderId::Xai, "v0" => ProviderId::V0, "helmcode" => ProviderId::Helmcode, + "gitkraken" => ProviderId::GitKraken, _ => return None, }) } @@ -384,7 +391,7 @@ fn litellm_workspace_change_allowed( mod tests { use codexbar::core::ProviderId; - use super::{litellm_workspace_change_allowed, workspace_provider}; + use super::{gateway_provider, litellm_workspace_change_allowed, workspace_provider}; #[test] fn maps_opencode_go_workspace_provider() { @@ -394,6 +401,18 @@ mod tests { ); } + #[test] + fn maps_gitkraken_organization_provider() { + assert_eq!(workspace_provider("gitkraken"), Some(ProviderId::GitKraken)); + } + + #[test] + fn gateway_provider_exposes_wayfinder_and_bifrost_only() { + assert_eq!(gateway_provider("wayfinder"), Some(ProviderId::Wayfinder)); + assert_eq!(gateway_provider("bifrost"), Some(ProviderId::Bifrost)); + assert_eq!(gateway_provider("codex"), None); + } + #[test] fn litellm_endpoint_change_requires_reentering_saved_key() { assert!( @@ -445,7 +464,18 @@ pub fn get_provider_workspace_id(provider_id: String) -> Result, } fn gateway_provider(provider_id: &str) -> Option { - (provider_id == "wayfinder").then_some(codexbar::core::ProviderId::Wayfinder) + match provider_id { + "wayfinder" => Some(codexbar::core::ProviderId::Wayfinder), + "bifrost" => Some(codexbar::core::ProviderId::Bifrost), + _ => None, + } +} + +#[tauri::command] +pub fn get_provider_gateway_url(provider_id: String) -> Result { + let id = gateway_provider(&provider_id) + .ok_or_else(|| format!("Provider '{provider_id}' does not expose a gateway URL"))?; + Ok(Settings::load().gateway_url(id).to_string()) } #[tauri::command] @@ -453,8 +483,17 @@ pub fn set_provider_gateway_url(provider_id: String, gateway_url: String) -> Res let id = gateway_provider(&provider_id) .ok_or_else(|| format!("Provider '{provider_id}' does not expose a gateway URL"))?; let gateway_url = gateway_url.trim(); - codexbar::providers::wayfinder::parse_gateway_url(gateway_url) - .map_err(|error| error.to_string())?; + match id { + codexbar::core::ProviderId::Wayfinder => { + codexbar::providers::wayfinder::parse_gateway_url(gateway_url) + .map_err(|error| error.to_string())?; + } + codexbar::core::ProviderId::Bifrost => { + codexbar::providers::bifrost::validate_gateway_url(gateway_url) + .map_err(|error| error.to_string())?; + } + _ => unreachable!("gateway_provider only returns gateway providers"), + } let mut settings = Settings::load(); settings.set_gateway_url(id, gateway_url.to_string()); @@ -781,6 +820,31 @@ pub fn cookie_source_options_for(provider_id: &str, lang: Language) -> Vec vec![ + cookie_option( + lang, + "auto", + "Prefer a signed-in Hyper browser session, then fall back to an API key.", + "", + None, + ), + cookie_option( + lang, + "manual", + "", + "Paste a Cookie header from hyper.charm.land.", + None, + ), + cookie_option( + lang, + "off", + "", + "", + Some("Use only the configured API key."), + ), + ], _ => Vec::new(), } } @@ -820,6 +884,14 @@ pub fn region_options_for(provider_id: &str) -> Vec { .to_string(), }, ], + "kimi" => codexbar::providers::KimiRegion::ALL + .iter() + .copied() + .map(|region| RegionOption { + value: region.settings_value().to_string(), + label: region.display_name().to_string(), + }) + .collect(), "alibabatokenplan" => codexbar::providers::AlibabaTokenPlanRegion::ALL .iter() .copied() diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 103d47b869..2a487504e4 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -83,21 +83,20 @@ pub(crate) fn build_fetch_context( .and_then(|override_data| override_data.env_override.as_ref()); let active_token_api_key = active_token_env.and_then(|env| env.values().next().cloned()); let usage_source = SourceMode::parse(settings.usage_source(id)).unwrap_or_default(); + let token_account_kind = token_override.as_ref().map(|account| account.kind); // Selected token-account key overrides a stored provider apiKey (upstream #2271 / #1183). - let api_key = active_token_api_key.or(stored_api_key); + let api_key = active_token_api_key.clone().or(stored_api_key); let has_kimi_code_api_key = id == ProviderId::Kimi && api_key.as_deref().is_some_and(|key| !key.trim().is_empty()); let has_opencodego_api_key = id == ProviderId::OpenCodeGo && api_key.as_deref().is_some_and(|key| !key.trim().is_empty()); - let (mut source_mode, mut cookie_header, fails_closed_without_cookie) = - if id.cookie_domain().is_none() { - let source_mode = if active_token_env.is_some() { - SourceMode::OAuth - } else { - usage_source - }; - (source_mode, None, false) + let (mut source_mode, mut cookie_header, fails_closed_without_cookie) = if id + .cookie_domain() + .is_none() + { + let (source_mode, cookie_header, missing_cookie) = if active_token_env.is_some() { + (SourceMode::OAuth, None, false) } else { match cookie_source { // #433: an explicitly selected, non-empty Claude manual cookie is @@ -113,6 +112,24 @@ pub(crate) fn build_fetch_context( (SourceMode::Web, stored_cookie.clone(), false) } _ if active_token_env.is_some() => (SourceMode::OAuth, None, false), + // Charm Hyper: the cookie source only picks the session, and + // the usage source keeps routing. Off and an empty Manual + // source never import a browser session, while Auto keeps its + // API-key fallback. + "off" | "manual" if provider.cookie_source_scopes_session_only() => { + let cookie_header = if cookie_source == "manual" { + active_token_cookie.clone().or(stored_cookie) + } else { + None + }; + let source_mode = if provider.available_sources().contains(&usage_source) { + usage_source + } else { + SourceMode::Auto + }; + let cookie_missing = cookie_header.is_none(); + (source_mode, cookie_header, cookie_missing) + } "off" if provider_uses_oauth_without_cookies(id, usage_source) => { (SourceMode::OAuth, None, false) } @@ -127,7 +144,7 @@ pub(crate) fn build_fetch_context( "off" if id == ProviderId::Factory => (SourceMode::Cli, None, false), "off" => (SourceMode::Cli, None, false), "manual" => { - let cookie_header = active_token_cookie.or(stored_cookie); + let cookie_header = active_token_cookie.clone().or(stored_cookie); let fails_closed_without_cookie = cookie_header.is_none() && provider.manual_empty_cookie_policy() == ManualEmptyCookiePolicy::FailClosedWeb; @@ -159,28 +176,130 @@ pub(crate) fn build_fetch_context( "auto" | "browser" | "web" => { // Claude resolves its cached cookie and browser fallback inside // the provider; other providers retain the shell fallback. - let cookie_header = active_token_cookie.or(stored_cookie).or_else(|| { - if defer_provider_browser_cookie_lookup { - None - } else { - provider_cookie_domain(id, settings).and_then(|domain| { - codexbar::browser::cookies::get_cookie_header(domain) - .ok() - .filter(|h| !h.is_empty()) - }) - } - }); + let cookie_header = + active_token_cookie.clone().or(stored_cookie).or_else(|| { + if defer_provider_browser_cookie_lookup { + None + } else { + provider_cookie_domain(id, settings).and_then(|domain| { + codexbar::browser::cookies::get_cookie_header(domain) + .ok() + .filter(|h| !h.is_empty()) + }) + } + }); (usage_source, cookie_header, false) } _ => (usage_source, stored_cookie, false), } }; + (source_mode, cookie_header, missing_cookie) + } else { + match cookie_source { + // #433: an explicitly selected, non-empty Claude manual cookie is + // authoritative. Do not let an active OAuth token account silently + // replace it; this keeps tray refresh behavior aligned with diagnose, + // whose Claude Auto path tries the supplied Web cookie before OAuth. + "manual" + if provider.manual_cookie_precedes_token_account() + && stored_cookie + .as_deref() + .is_some_and(|cookie| !cookie.trim().is_empty()) => + { + (SourceMode::Web, stored_cookie.clone(), false) + } + _ if active_token_env.is_some() => (SourceMode::OAuth, None, false), + // Charm Hyper: the cookie source only picks the session, and + // the usage source keeps routing. Off and an empty Manual + // source never import a browser session, while Auto keeps its + // API-key fallback. + "off" | "manual" if provider.cookie_source_scopes_session_only() => { + let cookie_header = if cookie_source == "manual" { + active_token_cookie.clone().or(stored_cookie) + } else { + None + }; + let source_mode = if provider.available_sources().contains(&usage_source) { + usage_source + } else { + SourceMode::Auto + }; + let cookie_missing = cookie_header.is_none(); + (source_mode, cookie_header, cookie_missing) + } + "off" if provider_uses_oauth_without_cookies(id, usage_source) => { + (SourceMode::OAuth, None, false) + } + "off" + if (has_kimi_code_api_key || has_opencodego_api_key) + && usage_source == SourceMode::Auto => + { + (SourceMode::Auto, None, false) + } + // Droid/Factory: cookie-off must never scrape browser cookies. Map to + // Cli (API-only in the provider) so Auto does not fall through to web. + "off" if id == ProviderId::Factory => (SourceMode::Cli, None, false), + "off" => (SourceMode::Cli, None, false), + "manual" => { + let cookie_header = active_token_cookie.clone().or(stored_cookie); + let fails_closed_without_cookie = cookie_header.is_none() + && provider.manual_empty_cookie_policy() + == ManualEmptyCookiePolicy::FailClosedWeb; + let source_mode = if (has_kimi_code_api_key || has_opencodego_api_key) + && usage_source == SourceMode::Auto + { + SourceMode::Auto + } else if let Some(mode) = grok_source_mode_for_manual_cookie(id, usage_source) { + // Grok Switch writes ~/.grok/auth.json. Leftover grok.com + // cookies must not force Web, or Weekly/notifications keep + // showing the previous browser account. + mode + } else if cookie_header.is_some() { + SourceMode::Web + } else if fails_closed_without_cookie { + // The provider owns this policy; Web with no header means + // it fails closed instead of importing a browser account + // the user did not select. + SourceMode::Web + } else if provider_uses_oauth_without_cookies(id, usage_source) { + SourceMode::OAuth + } else { + SourceMode::Cli + }; + (source_mode, cookie_header, fails_closed_without_cookie) + } + // `browser` is accepted as a legacy alias from older settings. + "auto" | "browser" | "web" => { + // Claude resolves its cached cookie and browser fallback inside + // the provider; other providers retain the shell fallback. + let cookie_header = active_token_cookie.clone().or(stored_cookie).or_else(|| { + if defer_provider_browser_cookie_lookup { + None + } else { + provider_cookie_domain(id, settings).and_then(|domain| { + codexbar::browser::cookies::get_cookie_header(domain) + .ok() + .filter(|h| !h.is_empty()) + }) + } + }); + (usage_source, cookie_header, false) + } + _ => (usage_source, stored_cookie, false), + } + }; // Cookie-web providers (Cursor, OpenCode, …) reject SourceMode::Cli. The shell // historically mapped "manual + no cookie" to Cli, which surfaces as // "Source mode 'Cli' not supported". Remap to Web and try browser cookies - // unless the user explicitly disabled cookies ("off"). - if source_mode == SourceMode::Cli && cookie_source != "off" && !provider.supports_cli() { + // unless the user explicitly disabled cookies ("off"). Providers whose + // cookie source only scopes the session (Charm Hyper) own this contract in + // the provider, so the shell must not remap their source mode. + if source_mode == SourceMode::Cli + && cookie_source != "off" + && !provider.supports_cli() + && !provider.cookie_source_scopes_session_only() + { if cookie_header .as_deref() .map(str::trim) @@ -219,16 +338,51 @@ pub(crate) fn build_fetch_context( // token account or manual cookie source scopes the session to web creds. let auto_prefer_web = token_override.is_some() || cookie_source == "manual"; + // These upstream account types are explicit identity selections. Keep the + // provider's saved region/source settings intact, but project the selected + // credential into the route required by that account. + let (cookie_header, api_key) = match (id, token_account_kind, usage_source) { + (ProviderId::Kimi, Some(_), _) => (active_token_cookie.clone(), None), + (ProviderId::Doubao, Some(_), _) => (None, active_token_api_key.clone()), + ( + ProviderId::OpenCodeGo, + Some(codexbar::core::TokenAccountKind::ApiKey), + SourceMode::Auto, + ) => (None, active_token_api_key.clone()), + (ProviderId::OpenCodeGo, Some(codexbar::core::TokenAccountKind::ApiKey), _) => { + (cookie_header, api_key) + } + ( + ProviderId::OpenCodeGo, + Some(codexbar::core::TokenAccountKind::Cookie), + SourceMode::Auto, + ) => (active_token_cookie.clone(), api_key), + _ => (cookie_header, api_key), + }; + let source_mode = token_override + .as_ref() + .and_then(|account| account.effective_source_mode(usage_source)) + .unwrap_or(source_mode); + let token_account_isolated = token_override.is_some() + && matches!( + id, + ProviderId::Kimi | ProviderId::Doubao | ProviderId::OpenCodeGo + ); + FetchContext { source_mode, manual_cookie_header: cookie_header, manual_cookie_missing: fails_closed_without_cookie, api_key, + token_account_kind, + token_account_isolated, workspace_id: (!workspace_id.is_empty()).then_some(workspace_id), seat_credit_entitlement: settings.seat_credit_entitlement(id), api_region: (!api_region.is_empty()).then_some(api_region), gateway_url, - auto_prefer_web, + auto_prefer_web: auto_prefer_web + && !(id == ProviderId::OpenCodeGo + && token_account_kind == Some(codexbar::core::TokenAccountKind::ApiKey)), ..FetchContext::default() } } diff --git a/apps/desktop-tauri/src-tauri/src/commands/session_cookie_scope_tests.rs b/apps/desktop-tauri/src-tauri/src/commands/session_cookie_scope_tests.rs new file mode 100644 index 0000000000..d5cf6229d1 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/session_cookie_scope_tests.rs @@ -0,0 +1,120 @@ +//! Fetch-context routing for providers whose cookie source only scopes the +//! browser session (Charm Hyper): the usage source keeps routing, and off or +//! an empty manual source never hands the provider a browser session. + +use std::collections::HashMap; + +use codexbar::core::{FetchContext, ProviderId, SourceMode}; +use codexbar::settings::{ApiKeys, Language, ManualCookies, Settings}; + +fn hyper_context( + cookie_source: Option<&str>, + usage_source: &str, + cookie: Option<&str>, +) -> FetchContext { + let mut settings = Settings::default(); + if let Some(cookie_source) = cookie_source { + settings.set_cookie_source(ProviderId::Hyper, cookie_source); + } + settings.set_usage_source(ProviderId::Hyper, usage_source); + let mut cookies = ManualCookies::default(); + if let Some(cookie) = cookie { + cookies.set("hyper", cookie); + } + let mut api_keys = ApiKeys::default(); + api_keys.set("hyper", "fixture-key", None); + super::build_fetch_context( + ProviderId::Hyper, + &settings, + &cookies, + &api_keys, + &HashMap::new(), + ) +} + +#[test] +fn hyper_defaults_to_automatic_session_with_the_provider_owning_the_browser_read() { + // No stored cookie: the shell must not read the browser itself, so the + // API source never touches cookies and Auto/Web import inside the provider. + for (usage_source, expected) in [ + ("auto", SourceMode::Auto), + ("web", SourceMode::Web), + ("oauth", SourceMode::OAuth), + ] { + let ctx = hyper_context(None, usage_source, None); + assert_eq!(ctx.source_mode, expected, "{usage_source}"); + assert!(ctx.manual_cookie_header.is_none()); + assert!(!ctx.manual_cookie_missing); + assert_eq!(ctx.api_key.as_deref(), Some("fixture-key")); + } +} + +#[test] +fn hyper_manual_cookie_keeps_the_usage_source() { + for (usage_source, expected) in [ + ("auto", SourceMode::Auto), + ("web", SourceMode::Web), + ("oauth", SourceMode::OAuth), + ] { + let ctx = hyper_context(Some("manual"), usage_source, Some("session=fixture")); + assert_eq!(ctx.source_mode, expected, "{usage_source}"); + assert_eq!(ctx.manual_cookie_header.as_deref(), Some("session=fixture")); + assert!(!ctx.manual_cookie_missing); + } +} + +#[test] +fn hyper_empty_manual_source_falls_back_to_the_key_without_a_browser_session() { + let ctx = hyper_context(Some("manual"), "auto", None); + assert_eq!(ctx.source_mode, SourceMode::Auto); + assert!(ctx.manual_cookie_header.is_none()); + assert!(ctx.manual_cookie_missing); + assert_eq!(ctx.api_key.as_deref(), Some("fixture-key")); +} + +#[test] +fn hyper_cookie_off_ignores_a_stored_cookie_and_keeps_the_usage_source() { + for (usage_source, expected) in [ + ("auto", SourceMode::Auto), + ("web", SourceMode::Web), + ("oauth", SourceMode::OAuth), + ] { + let ctx = hyper_context(Some("off"), usage_source, Some("session=fixture")); + assert_eq!(ctx.source_mode, expected, "{usage_source}"); + assert!(ctx.manual_cookie_header.is_none()); + assert!(ctx.manual_cookie_missing); + } +} + +#[test] +fn hyper_unsupported_usage_source_routes_to_auto() { + for cookie_source in ["off", "manual"] { + let ctx = hyper_context(Some(cookie_source), "cli", None); + assert_eq!(ctx.source_mode, SourceMode::Auto, "{cookie_source}"); + assert!(ctx.manual_cookie_missing); + } +} + +#[test] +fn hyper_exposes_upstream_cookie_source_picker() { + let settings = Settings::default(); + assert_eq!( + super::provider_cookie_source_lookup(&settings, "hyper").as_deref(), + Some("auto") + ); + let options = super::cookie_source_options_for("hyper", Language::English); + let values: Vec<_> = options.iter().map(|option| option.value.as_str()).collect(); + assert_eq!(values, vec!["auto", "manual", "off"]); + let descriptions: Vec<_> = options + .iter() + .map(|option| option.description.as_deref()) + .collect(); + assert_eq!( + descriptions, + vec![ + Some("Prefer a signed-in Hyper browser session, then fall back to an API key."), + Some("Paste a Cookie header from hyper.charm.land."), + Some("Use only the configured API key."), + ] + ); +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/settings.rs b/apps/desktop-tauri/src-tauri/src/commands/settings.rs index df0e6621a9..b687c9fd48 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/settings.rs @@ -26,6 +26,8 @@ pub struct SettingsUpdate { pub predictive_pace_warning_enabled: Option, pub show_pace: Option, pub tray_icon_mode: Option, + pub stacked_tray_top_provider: Option, + pub stacked_tray_bottom_provider: Option, pub switcher_shows_icons: Option, pub menu_bar_shows_highest_usage: Option, pub menu_bar_shows_percent: Option, @@ -121,6 +123,8 @@ impl SettingsUpdate { fn refreshes_tray_presentation(&self) -> bool { self.tray_icon_mode.is_some() + || self.stacked_tray_top_provider.is_some() + || self.stacked_tray_bottom_provider.is_some() || self.switcher_shows_icons.is_some() || self.menu_bar_shows_highest_usage.is_some() || self.menu_bar_shows_percent.is_some() @@ -190,6 +194,12 @@ impl SettingsUpdate { { settings.tray_icon_mode = mode; } + if let Some(provider) = self.stacked_tray_top_provider.clone() { + settings.stacked_tray_top_provider = normalize_optional_provider_id(provider); + } + if let Some(provider) = self.stacked_tray_bottom_provider.clone() { + settings.stacked_tray_bottom_provider = normalize_optional_provider_id(provider); + } if let Some(v) = self.provider_metrics.clone() { apply_provider_metrics(settings, v); } @@ -482,10 +492,16 @@ fn parse_tray_icon_mode(s: &str) -> Option { match s { "single" => Some(TrayIconMode::Single), "perProvider" => Some(TrayIconMode::PerProvider), + "stacked" => Some(TrayIconMode::Stacked), _ => None, } } +fn normalize_optional_provider_id(value: String) -> Option { + let value = value.trim(); + (!value.is_empty()).then(|| value.to_string()) +} + fn parse_update_channel(s: &str) -> Option { match s { "stable" => Some(UpdateChannel::Stable), @@ -700,6 +716,36 @@ mod tests { } .refreshes_tray_presentation() ); + assert!( + SettingsUpdate { + stacked_tray_top_provider: Some("claude".to_string()), + ..Default::default() + } + .refreshes_tray_presentation() + ); + } + + #[test] + fn stacked_tray_update_accepts_mode_and_clears_automatic_provider() { + let mut settings = Settings { + stacked_tray_top_provider: Some("codex".to_string()), + ..Settings::default() + }; + + SettingsUpdate { + tray_icon_mode: Some("stacked".to_string()), + stacked_tray_top_provider: Some(String::new()), + stacked_tray_bottom_provider: Some("claude".to_string()), + ..Default::default() + } + .apply_provider_settings(&mut settings); + + assert_eq!(settings.tray_icon_mode, TrayIconMode::Stacked); + assert_eq!(settings.stacked_tray_top_provider, None); + assert_eq!( + settings.stacked_tray_bottom_provider.as_deref(), + Some("claude") + ); } #[test] diff --git a/apps/desktop-tauri/src-tauri/src/commands/surface.rs b/apps/desktop-tauri/src-tauri/src/commands/surface.rs index 6961359553..b2f4aeb4ee 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/surface.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/surface.rs @@ -59,13 +59,11 @@ pub async fn open_settings_window(app: tauri::AppHandle, tab: String) -> Result< crate::shell::settings_window::open_or_focus(&app, &tab) } -/// Open (or focus) the detached flyout ("Pop Out Dashboard") window. -/// -/// Used by `PopOutPanel`'s "back to tray" action, which previously called -/// `set_surface_mode("trayPanel", ...)` on the shared window — now that the -/// flyout is its own window, that action opens it directly instead. Same -/// `async` requirement as `open_settings_window`: `WebviewWindowBuilder::build` -/// deadlocks inside synchronous Tauri commands on Windows. +/// Open (or focus) the detached flyout ("Pop Out Dashboard") window, the +/// only dashboard layout. Used by the frontend global-shortcut fallback. +/// Same `async` requirement as `open_settings_window`: +/// `WebviewWindowBuilder::build` deadlocks inside synchronous Tauri commands +/// on Windows. #[tauri::command] pub async fn open_flyout_window(app: tauri::AppHandle) -> Result<(), String> { crate::shell::flyout_window::open_or_focus(&app, None) @@ -152,6 +150,12 @@ pub(crate) fn validate_surface_target( return Err("set_surface_mode only supports visible surfaces".into()); } + // The legacy PopOut layout on `main` is retired; the dashboard is the + // tray-panel flyout, opened with `open_flyout_window`. + if mode == SurfaceMode::PopOut { + return Err("the popOut surface is retired; use open_flyout_window".into()); + } + if target.mode() != mode { return Err(format!( "surface target '{}' is not valid for mode '{}'", diff --git a/apps/desktop-tauri/src-tauri/src/commands/system.rs b/apps/desktop-tauri/src-tauri/src/commands/system.rs index 9ae17f57ce..d7f56b124d 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/system.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/system.rs @@ -205,36 +205,9 @@ pub fn quit_app(app: tauri::AppHandle) { } fn dashboard_url_for_provider(provider_id: &str) -> Option { - if provider_id == ProviderId::MiniMax.cli_name() { - let settings = Settings::load(); - return Some( - codexbar::providers::MiniMaxProvider::dashboard_url_for_region(Some( - settings.api_region(ProviderId::MiniMax), - )), - ); - } - - // OpenRouter's Usage Dashboard is the Activity page. Resolve it from the - // provider metadata before the legacy API-key catalog entry, which still - // points at the credits settings page. - if provider_id == ProviderId::OpenRouter.cli_name() { - return instantiate_provider(ProviderId::OpenRouter) - .metadata() - .dashboard_url - .map(|s| s.to_string()); - } - - if let Some(url) = codexbar::settings::get_api_key_providers() - .into_iter() - .find(|p| p.id.cli_name() == provider_id) - .and_then(|p| p.dashboard_url.map(|s| s.to_string())) - { - return Some(url); - } - let id = ProviderId::from_cli_name(provider_id)?; - let provider = instantiate_provider(id); - provider.metadata().dashboard_url.map(|s| s.to_string()) + let settings = Settings::load(); + provider_dashboard_url(id, &settings) } fn status_page_url_for_provider(provider_id: &str) -> Option { diff --git a/apps/desktop-tauri/src-tauri/src/commands/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/tests.rs index d25a0a064a..c2ffe6fa65 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/tests.rs @@ -52,6 +52,19 @@ fn validate_surface_target_rejects_hidden_mode() { assert!(error.contains("only supports visible surfaces")); } +#[test] +fn validate_surface_target_rejects_retired_popout_mode() { + for target in [ + SurfaceTarget::Dashboard, + SurfaceTarget::Provider { + provider_id: "codex".into(), + }, + ] { + let error = validate_surface_target(SurfaceMode::PopOut, target).unwrap_err(); + assert!(error.contains("popOut surface is retired")); + } +} + #[test] fn external_url_validation_allows_only_http_urls() { assert_eq!( @@ -263,6 +276,20 @@ fn minimax_region_lookup_normalizes_legacy_china_value() { assert_eq!(provider_region_lookup(&s, "minimax").as_deref(), Some("cn")); } +#[test] +fn kimi_region_lookup_defaults_to_china_and_roundtrips_international() { + let mut settings = Settings::default(); + assert_eq!( + provider_region_lookup(&settings, "kimi").as_deref(), + Some("china") + ); + super::provider_region_set(&mut settings, "kimi", "international".to_string()).unwrap(); + assert_eq!( + provider_region_lookup(&settings, "kimi").as_deref(), + Some("international") + ); +} + #[test] fn minimax_cookie_domain_follows_selected_region() { let mut s = Settings::default(); @@ -469,6 +496,139 @@ fn fetch_context_opencode_empty_manual_remaps_to_web() { assert_eq!(ctx.source_mode, SourceMode::Web); } +#[test] +fn kimi_selected_account_forces_web_and_keeps_saved_region() { + let mut settings = Settings::default(); + settings.set_usage_source(ProviderId::Kimi, "oauth"); + settings.set_api_region(ProviderId::Kimi, "international"); + let mut accounts = HashMap::new(); + let mut data = ProviderAccountData::new(); + data.add_account(TokenAccount::new("Work", "selected-kimi-session")); + accounts.insert(ProviderId::Kimi, data); + + let ctx = super::build_fetch_context( + ProviderId::Kimi, + &settings, + &ManualCookies::default(), + &ApiKeys::default(), + &accounts, + ); + + assert_eq!(ctx.source_mode, SourceMode::Web); + assert_eq!( + ctx.manual_cookie_header.as_deref(), + Some("kimi-auth=selected-kimi-session") + ); + assert_eq!(ctx.api_key, None); + assert_eq!(ctx.api_region.as_deref(), Some("international")); + assert!(ctx.token_account_isolated); + assert_eq!(settings.usage_source(ProviderId::Kimi), "oauth"); + assert_eq!(settings.api_region(ProviderId::Kimi), "international"); +} + +#[test] +fn doubao_selected_account_forces_ark_api_and_ignores_saved_source() { + let mut settings = Settings::default(); + settings.set_usage_source(ProviderId::Doubao, "cli"); + let mut accounts = HashMap::new(); + let mut data = ProviderAccountData::new(); + data.add_account(TokenAccount::new("Work", "selected-ark-key")); + accounts.insert(ProviderId::Doubao, data); + + let ctx = super::build_fetch_context( + ProviderId::Doubao, + &settings, + &ManualCookies::default(), + &ApiKeys::default(), + &accounts, + ); + + assert_eq!(ctx.source_mode, SourceMode::OAuth); + assert_eq!(ctx.api_key.as_deref(), Some("selected-ark-key")); + assert!(ctx.token_account_isolated); +} + +#[test] +fn opencodego_selected_api_account_overrides_global_key_without_changing_explicit_source() { + let mut settings = Settings::default(); + settings.set_usage_source(ProviderId::OpenCodeGo, "auto"); + let mut keys = ApiKeys::default(); + keys.set("opencodego", "global-key", None); + let mut accounts = HashMap::new(); + let mut data = ProviderAccountData::new(); + data.add_account(TokenAccount::new("Work", "selected-account-key")); + accounts.insert(ProviderId::OpenCodeGo, data); + + let ctx = super::build_fetch_context( + ProviderId::OpenCodeGo, + &settings, + &ManualCookies::default(), + &keys, + &accounts, + ); + + assert_eq!(ctx.source_mode, SourceMode::Auto); + assert_eq!(ctx.api_key.as_deref(), Some("selected-account-key")); + assert!(!ctx.auto_prefer_web); + assert!(ctx.token_account_isolated); + + for cookie_source in ["off", "manual"] { + settings.set_cookie_source(ProviderId::OpenCodeGo, cookie_source); + settings.set_usage_source(ProviderId::OpenCodeGo, "auto"); + let auto_ctx = super::build_fetch_context( + ProviderId::OpenCodeGo, + &settings, + &ManualCookies::default(), + &keys, + &accounts, + ); + assert_eq!(auto_ctx.source_mode, SourceMode::Auto); + assert_eq!(auto_ctx.api_key.as_deref(), Some("selected-account-key")); + assert!(auto_ctx.manual_cookie_header.is_none()); + } + + for (saved_source, expected_source) in [("web", SourceMode::Web), ("cli", SourceMode::Cli)] { + settings.set_cookie_source(ProviderId::OpenCodeGo, "off"); + settings.set_usage_source(ProviderId::OpenCodeGo, saved_source); + let explicit_ctx = super::build_fetch_context( + ProviderId::OpenCodeGo, + &settings, + &ManualCookies::default(), + &keys, + &accounts, + ); + assert_eq!(explicit_ctx.source_mode, expected_source); + } +} + +#[test] +fn opencodego_selected_cookie_account_uses_web_route() { + let settings = Settings::default(); + let mut accounts = HashMap::new(); + let mut data = ProviderAccountData::new(); + data.add_account(TokenAccount::new("Web", "Cookie: session=selected-session")); + accounts.insert(ProviderId::OpenCodeGo, data); + + let ctx = super::build_fetch_context( + ProviderId::OpenCodeGo, + &settings, + &ManualCookies::default(), + &ApiKeys::default(), + &accounts, + ); + + assert_eq!(ctx.source_mode, SourceMode::Web); + assert_eq!( + ctx.manual_cookie_header.as_deref(), + Some("Cookie: session=selected-session") + ); + assert_eq!( + ctx.token_account_kind, + Some(codexbar::core::TokenAccountKind::Cookie) + ); + assert!(ctx.token_account_isolated); +} + #[test] fn fetch_context_replicate_empty_manual_fails_closed_without_browser_import() { let settings = Settings::default(); @@ -690,6 +850,40 @@ fn fetch_context_includes_minimax_region() { assert_eq!(ctx.api_region.as_deref(), Some("cn")); } +#[test] +fn provider_dashboard_url_uses_selected_regional_console() { + let mut settings = Settings::default(); + settings.set_api_region(ProviderId::MiniMax, "cn"); + settings.set_api_region(ProviderId::Kimi, "international"); + + assert_eq!( + super::provider_dashboard_url(ProviderId::MiniMax, &settings).as_deref(), + Some("https://platform.minimaxi.com/user-center/payment/coding-plan?cycle_type=3") + ); + assert_eq!( + super::provider_dashboard_url(ProviderId::Kimi, &settings).as_deref(), + Some("https://www.kimi.ai/code/console") + ); +} + +/// Provider metadata is the only source of the provider dashboard link; the +/// API-key catalog URL is the key-management link shown next to the key field. +/// A provider that only has a catalog URL must get a metadata URL instead of +/// silently borrowing the key page. +#[test] +fn api_key_catalog_providers_have_metadata_dashboard_urls() { + let settings = Settings::default(); + for provider in codexbar::settings::get_api_key_providers() { + if provider.dashboard_url.is_some() { + assert!( + super::provider_dashboard_url(provider.id, &settings).is_some(), + "{:?} has an API-key page but no metadata dashboard URL", + provider.id + ); + } + } +} + #[test] fn fetch_context_token_account_uses_web_cookie_header() { let settings = Settings::default(); @@ -1824,6 +2018,13 @@ fn minimax_region_options_match_upstream_hosts() { ); } +#[test] +fn kimi_region_options_match_regional_hosts() { + let opts = super::region_options_for("kimi"); + let values: Vec<_> = opts.iter().map(|option| option.value.as_str()).collect(); + assert_eq!(values, vec!["china", "international"]); +} + #[test] fn region_options_empty_for_non_regional_provider() { assert!(super::region_options_for("claude").is_empty()); diff --git a/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs b/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs index 01f562e111..ee7ae528c8 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs @@ -26,6 +26,10 @@ pub struct UsageSpendRow { pub display_name: String, pub seven_day: Option, pub thirty_day: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub seven_day_estimate: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub thirty_day_estimate: Option, pub seven_day_tokens: Option, pub thirty_day_tokens: Option, pub currency: String, @@ -496,6 +500,7 @@ fn build_usage_spend_summary( }) .unwrap_or_else(|| provider_id.clone()); + let mut local_cost_estimates = None; let spend = match provider_id.as_str() { "codex" => SpendValues { seven_day: codex_7_contract.known_cost_usd, @@ -586,17 +591,11 @@ fn build_usage_spend_summary( spend } "antigravity" => { - use codexbar::providers::antigravity::local_sessions::LocalHistoryCoverage; let seven = codexbar::providers::antigravity::local_sessions::summarize(7); let thirty = codexbar::providers::antigravity::local_sessions::summarize(30); - let mut spend = cached_spend(cached_snapshot); - spend.seven_day_tokens = matches!(seven.coverage, LocalHistoryCoverage::Complete) - .then_some(seven.total_tokens); - spend.thirty_day_tokens = matches!(thirty.coverage, LocalHistoryCoverage::Complete) - .then_some(thirty.total_tokens); - if matches!(thirty.coverage, LocalHistoryCoverage::Complete) { - spend.source = "local Antigravity history".to_string(); - } + let spend = + antigravity_spend_values(cached_spend(cached_snapshot), &seven, &thirty); + local_cost_estimates = Some((seven.cost_estimate, thirty.cost_estimate)); spend } _ => cached_spend(cached_snapshot), @@ -618,11 +617,16 @@ fn build_usage_spend_summary( .collect() }) .unwrap_or_default(); + let (seven_day_estimate, thirty_day_estimate) = local_cost_estimates + .map(|(seven, thirty)| (Some(seven), Some(thirty))) + .unwrap_or((None, None)); rows.push(UsageSpendRow { provider_id: provider_id.clone(), display_name, seven_day: spend.seven_day, thirty_day: spend.thirty_day, + seven_day_estimate, + thirty_day_estimate, seven_day_tokens: spend.seven_day_tokens, thirty_day_tokens: spend.thirty_day_tokens, currency, @@ -701,6 +705,29 @@ fn total_token_mix(mix: &codexbar::spend_contract::SpendTokenMix) -> Option saw.then_some(total) } +fn antigravity_spend_values( + mut spend: SpendValues, + seven: &codexbar::spend_contract::LocalTokenHistorySummary, + thirty: &codexbar::spend_contract::LocalTokenHistorySummary, +) -> SpendValues { + use codexbar::spend_contract::LocalHistoryCoverage; + + spend.seven_day = seven.total_usd(); + spend.thirty_day = thirty.total_usd(); + spend.seven_day_tokens = + (seven.coverage == LocalHistoryCoverage::Complete).then_some(seven.total_tokens); + spend.thirty_day_tokens = + (thirty.coverage == LocalHistoryCoverage::Complete).then_some(thirty.total_tokens); + if spend.thirty_day.is_some() { + spend.source = "local Antigravity history · API list-price estimate".to_string(); + } else if thirty.cost_estimate.known_subtotal_usd.is_some() { + spend.source = "local Antigravity history · known API list-price subtotal".to_string(); + } else if thirty.coverage == LocalHistoryCoverage::Complete { + spend.source = "local Antigravity history · unpriced".to_string(); + } + spend +} + fn cached_spend(snapshot: Option<&ProviderUsageSnapshot>) -> SpendValues { let Some(snapshot) = snapshot else { return SpendValues { @@ -778,6 +805,27 @@ fn cached_spend(snapshot: Option<&ProviderUsageSnapshot>) -> SpendValues { mod cache_key_tests { use super::*; + fn local_history( + total_tokens: u64, + coverage: codexbar::spend_contract::LocalHistoryCoverage, + known_subtotal_usd: Option, + unpriced: u32, + ) -> codexbar::spend_contract::LocalTokenHistorySummary { + codexbar::spend_contract::LocalTokenHistorySummary { + total_tokens, + session_count: if total_tokens > 0 { 1 } else { 0 }, + coverage, + cost_estimate: codexbar::spend_contract::LocalCostEstimate { + known_subtotal_usd, + coverage: codexbar::spend_contract::CostCoverageCounts { + estimated: if known_subtotal_usd.is_some() { 1 } else { 0 }, + unpriced, + ..Default::default() + }, + }, + } + } + #[test] fn invalidated_owner_clears_orphaned_indexing_activity() { let mut coordinator = UsageSpendCoordinator::default(); @@ -834,4 +882,34 @@ mod cache_key_tests { assert!(include_in_shared_overview("claude", false, true)); assert!(!include_in_shared_overview("codex", false, false)); } + + #[test] + fn antigravity_partial_history_exposes_only_the_known_subtotal() { + use codexbar::spend_contract::LocalHistoryCoverage; + + let seven = local_history(100, LocalHistoryCoverage::Partial, Some(1.25), 0); + let thirty = local_history(200, LocalHistoryCoverage::Partial, Some(2.50), 0); + let spend = antigravity_spend_values(cached_spend(None), &seven, &thirty); + + assert_eq!(spend.seven_day, None); + assert_eq!(spend.thirty_day, None); + assert_eq!(spend.seven_day_tokens, None); + assert_eq!(spend.thirty_day_tokens, None); + assert!(spend.source.contains("known API list-price subtotal")); + } + + #[test] + fn antigravity_complete_empty_history_is_a_known_zero() { + use codexbar::spend_contract::LocalHistoryCoverage; + + let seven = local_history(0, LocalHistoryCoverage::Complete, None, 0); + let thirty = local_history(0, LocalHistoryCoverage::Complete, None, 0); + let spend = antigravity_spend_values(cached_spend(None), &seven, &thirty); + + assert_eq!(spend.seven_day, Some(0.0)); + assert_eq!(spend.thirty_day, Some(0.0)); + assert_eq!(spend.seven_day_tokens, Some(0)); + assert_eq!(spend.thirty_day_tokens, Some(0)); + assert!(spend.source.contains("API list-price estimate")); + } } diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index 02f48672b6..aa501c7bef 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -19,6 +19,7 @@ mod surface_target; mod tray_accounts; mod tray_bridge; mod tray_menu; +mod tray_presentation; mod tray_visibility; mod usage_metric; mod window_positioner; @@ -27,7 +28,6 @@ use std::sync::Mutex; use state::AppState; use surface::SurfaceMode; -use surface_target::SurfaceTarget; use tauri::Manager; const PROOF_ACTIVATION_DELAY: Duration = Duration::from_millis(0); @@ -46,12 +46,22 @@ fn should_hide_close_request(mode: SurfaceMode) -> bool { ) } -fn primary_window_request() -> shell::ShellTransitionRequest { - shell::ShellTransitionRequest { - mode: SurfaceMode::PopOut, - target: SurfaceTarget::Dashboard, - position: None, - } +/// Open the primary window: the tray-panel flyout, the only dashboard +/// layout. The legacy PopOut layout on `main` is retired, so launches and +/// relaunches land on the same panel as a tray left-click. +/// +/// Spawned because building the flyout window synchronously can deadlock on +/// Windows (see `shell::flyout_window::open_or_focus`). +fn open_primary_window(app: &tauri::AppHandle, delay: Duration) { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + if !delay.is_zero() { + tokio::time::sleep(delay).await; + } + if let Err(error) = shell::flyout_window::open_or_focus(&app, None) { + tracing::warn!(%error, "failed to open the tray panel window"); + } + }); } fn should_open_primary_window_from_args(args: I) -> bool @@ -131,17 +141,27 @@ fn main() { let mut initial_state = AppState::new(); initial_state.proof_config = proof_config; - // Proof-harness seed: CODEXBAR_SEED_USAGE_JSON plants one synthetic Codex - // ProviderUsageSnapshot before the event loop and any WebView read. The - // cache timestamp makes the seeded cache count as fresh so the first - // frontend refresh-if-stale call does not evict the synthetic data. - if let Some(snapshot) = proof_harness::seed_usage_snapshot_from_env() { - tracing::info!( - "proof-harness: seeded provider snapshot for '{}'", - snapshot.provider_id - ); - initial_state.provider_cache.push(snapshot); - initial_state.provider_cache_updated_at = Some(std::time::Instant::now()); + // Validate the complete proof seed before installing any snapshots, so an + // invalid multi-provider fixture cannot leave a partial cache behind. + if let Some(snapshots) = + proof_harness::seed_usage_snapshots_from_env(initial_state.proof_config.as_ref()) + { + let seeded_at = std::time::Instant::now(); + for snapshot in &snapshots { + tracing::info!( + "proof-harness: seeded provider snapshot for '{}'", + snapshot.provider_id + ); + if let Some(provider) = codexbar::core::ProviderId::from_cli_name(&snapshot.provider_id) + { + initial_state + .provider_cache_updated_at_by_provider + .insert(provider, seeded_at); + } + } + initial_state.provider_cache.extend(snapshots); + initial_state.provider_cache_seeded = true; + initial_state.provider_cache_updated_at = Some(seeded_at); } tauri::Builder::default() @@ -150,9 +170,7 @@ fn main() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { if should_reopen_primary_window_from_instance_args(args.iter().skip(1)) { - let request = primary_window_request(); - let _ = - shell::reopen_to_target(app, request.mode, request.target, request.position); + open_primary_window(app, Duration::ZERO); } })) .invoke_handler(tauri::generate_handler![ @@ -246,6 +264,7 @@ fn main() { commands::get_provider_region_options, commands::set_provider_workspace_id, commands::set_provider_gateway_url, + commands::get_provider_gateway_url, commands::get_provider_workspace_id, commands::get_gemini_cli_signed_in, commands::get_vertexai_status, @@ -302,17 +321,10 @@ fn main() { proof_harness::activate(&app_handle); }); } else if launch.open_primary_window_at_start { - let app = app.handle().clone(); - tauri::async_runtime::spawn(async move { - tokio::time::sleep(VISIBLE_START_ACTIVATION_DELAY).await; - let request = primary_window_request(); - let _ = shell::reopen_to_target( - &app, - request.mode, - request.target, - request.position, - ); - }); + if launch.suppress_blur_dismiss { + shell::flyout_window::keep_open_on_blur(); + } + open_primary_window(app.handle(), VISIBLE_START_ACTIVATION_DELAY); } Ok(()) @@ -435,14 +447,6 @@ mod tests { assert!(!should_hide_close_request(SurfaceMode::Hidden)); } - #[test] - fn primary_window_request_targets_popout_dashboard() { - let request = primary_window_request(); - assert_eq!(request.mode, SurfaceMode::PopOut); - assert_eq!(request.target, SurfaceTarget::Dashboard); - assert_eq!(request.position, None); - } - #[test] fn menubar_launch_arg_opens_primary_window() { assert!(should_open_primary_window_from_args(["menubar"])); diff --git a/apps/desktop-tauri/src-tauri/src/proof_harness.rs b/apps/desktop-tauri/src-tauri/src/proof_harness.rs index 0830f8569f..8cc2979a87 100644 --- a/apps/desktop-tauri/src-tauri/src/proof_harness.rs +++ b/apps/desktop-tauri/src-tauri/src/proof_harness.rs @@ -4,9 +4,10 @@ //! specifies a target surface and optional settings tab to display on //! startup, e.g.: //! -//! - `trayPanel` — show the tray panel -//! - `popOut` — show the pop-out dashboard -//! - `popOut:provider:codex` — show a provider pop-out +//! - `trayPanel` — show the tray panel on the `main` window +//! - `popOut` — open the tray-panel flyout window, the same +//! window tray left-click and "Pop Out Dashboard" open (the legacy +//! PopOut layout is retired, so `popOut:` payloads are rejected) //! - `settings` — show settings (General tab) //! - `settings:menuBar` — show settings on the Menu Bar tab //! - `settings:usageSpend` — show settings on the Usage & Spend tab @@ -16,14 +17,17 @@ //! and suppresses blur-dismiss so the window stays visible for automated //! screenshot capture. //! -//! `CODEXBAR_SEED_USAGE_JSON=` additionally seeds one synthetic, -//! bridge-shaped Codex [`ProviderUsageSnapshot`] into the provider cache at -//! launch (before the first event/WebView read) and pins it against refresh -//! eviction for the run. Malformed files log a warning and the shell -//! continues without seeding — proof runs must never crash on the seed. - +//! `CODEXBAR_SEED_USAGE_JSON=` additionally seeds either the legacy +//! synthetic Codex [`ProviderUsageSnapshot`] object or, in valid proof mode, +//! a nonempty array of unique supported provider snapshots. The validated set +//! is installed before the first event/WebView read and pinned against refresh +//! eviction. Malformed files log a warning and the shell continues without +//! seeding — proof runs must never crash on the seed. + +use std::collections::HashSet; use std::sync::Mutex; +use codexbar::core::ProviderId; use serde::Serialize; use tauri::{AppHandle, Manager}; @@ -31,7 +35,7 @@ use crate::commands::{CostSnapshotBridge, ProviderUsageSnapshot, RateWindowSnaps use crate::shell; use crate::state::AppState; use crate::surface::SurfaceMode; -use crate::surface_target::{SurfaceTarget, is_supported_provider_id, is_supported_settings_tab}; +use crate::surface_target::{SurfaceTarget, is_supported_settings_tab}; /// Proof configuration parsed from `CODEXBAR_PROOF_MODE`. #[derive(Debug, Clone, Serialize)] @@ -41,8 +45,8 @@ pub struct ProofConfig { pub target_surface: String, /// Optional settings tab id (e.g. `"menuBar"`, `"usageSpend"`). pub settings_tab: Option, - /// Optional target payload for richer proof routing, such as - /// `"provider:codex"` for pop-out provider views. + /// Optional target payload for richer proof routing (currently only the + /// settings tab id). pub target_payload: Option, } @@ -90,12 +94,7 @@ impl ProofConfig { pub fn surface_target(&self) -> SurfaceTarget { match self.surface_mode() { SurfaceMode::Hidden | SurfaceMode::TrayPanel => SurfaceTarget::Summary, - SurfaceMode::PopOut => self - .target_payload - .as_deref() - .and_then(SurfaceTarget::parse) - .filter(|target| target.mode() == SurfaceMode::PopOut) - .unwrap_or(SurfaceTarget::Dashboard), + SurfaceMode::PopOut => SurfaceTarget::Dashboard, SurfaceMode::Settings => SurfaceTarget::Settings { tab: self .settings_tab @@ -117,6 +116,14 @@ pub fn activate(app: &AppHandle) { let Some(config) = config else { return }; let target = config.surface_mode(); + if target == SurfaceMode::PopOut { + tracing::info!("proof-harness: opening the tray-panel flyout window"); + // Called from the async setup task, so building the window is safe. + if let Err(err) = shell::flyout_window::open_or_focus(app, None) { + tracing::error!("proof-harness: flyout open FAILED: {err}"); + } + return; + } let position = match target { // Detached surfaces are larger than tray panels. Let their normal // positioning paths center/clamp them instead of reusing tray coords. @@ -136,6 +143,13 @@ pub fn activate(app: &AppHandle) { } } +/// Seeded snapshots must use an exact bridge provider id, not a CLI alias. +fn is_seedable_provider_id(provider_id: &str) -> bool { + ProviderId::all() + .iter() + .any(|provider| provider.cli_name() == provider_id) +} + /// Bottom inset (physical px) kept between the proof panel's bottom edge and /// the monitor work-area bottom (#265). const PROOF_BOTTOM_INSET_PX: i32 = 8; @@ -256,22 +270,17 @@ pub fn is_proof_mode(app: &AppHandle) -> bool { // ── Provider-usage seed (CODEXBAR_SEED_USAGE_JSON) ─────────────────── /// Environment variable pointing at a JSON file with one synthetic, -/// bridge-shaped `ProviderUsageSnapshot` for the codex provider. +/// bridge-shaped Codex snapshot or a proof-only array of provider snapshots. pub const SEED_USAGE_ENV_VAR: &str = "CODEXBAR_SEED_USAGE_JSON"; -/// Whether a seed path was configured at launch. While set, the provider -/// cache is pinned fresh so the synthetic snapshot is never evicted by an -/// automatic refresh during a proof/capture run. -pub fn seed_usage_json_active() -> bool { - std::env::var_os(SEED_USAGE_ENV_VAR).is_some() -} - /// Read and validate the seed file referenced by `CODEXBAR_SEED_USAGE_JSON`. /// /// Returns `None` (with a warn, never a crash) when the variable is unset, -/// the file is unreadable, the JSON is malformed, or the snapshot is not -/// for the `codex` provider. -pub fn seed_usage_snapshot_from_env() -> Option { +/// the file is unreadable, or the seed does not satisfy the selected legacy +/// object or proof-only array contract. +pub fn seed_usage_snapshots_from_env( + proof_config: Option<&ProofConfig>, +) -> Option> { let path = std::env::var_os(SEED_USAGE_ENV_VAR)?; let path = std::path::PathBuf::from(path); let raw = match std::fs::read_to_string(&path) { @@ -284,8 +293,8 @@ pub fn seed_usage_snapshot_from_env() -> Option { return None; } }; - match parse_seed_usage_snapshot(&raw) { - Ok(snapshot) => Some(snapshot), + match parse_seed_usage_snapshots(&raw, proof_config) { + Ok(snapshots) => Some(snapshots), Err(msg) => { tracing::warn!("{SEED_USAGE_ENV_VAR}: {msg} in {}", path.display()); None @@ -315,6 +324,51 @@ pub fn parse_seed_usage_snapshot(json: &str) -> Result, +) -> Result, String> { + if !json.trim_start().starts_with('[') { + return parse_seed_usage_snapshot(json).map(|snapshot| vec![snapshot]); + } + if !proof_config.is_some_and(is_valid_proof_config) { + return Err("provider snapshot arrays require valid proof mode".into()); + } + + let mut snapshots: Vec = + serde_json::from_str(json).map_err(|e| format!("malformed JSON: {e}"))?; + if snapshots.is_empty() { + return Err("provider snapshot array must not be empty".into()); + } + + let mut providers = HashSet::with_capacity(snapshots.len()); + for snapshot in &mut snapshots { + if !is_seedable_provider_id(&snapshot.provider_id) { + return Err(format!( + "unsupported snapshot providerId '{}', ignoring", + snapshot.provider_id + )); + } + if !providers.insert(snapshot.provider_id.clone()) { + return Err(format!( + "duplicate snapshot providerId '{}', ignoring", + snapshot.provider_id + )); + } + normalize_seed_snapshot(snapshot); + } + Ok(snapshots) +} + +fn normalize_seed_snapshot(snapshot: &mut ProviderUsageSnapshot) { normalize_rate_window(&mut snapshot.primary); snapshot.secondary.as_mut().map(normalize_rate_window); snapshot.model_specific.as_mut().map(normalize_rate_window); @@ -327,8 +381,11 @@ pub fn parse_seed_usage_snapshot(json: &str) -> Result bool { + SurfaceMode::parse(&config.target_surface) + .is_some_and(|mode| proof_payload_is_supported(mode, config.target_payload.as_deref())) } /// Recompute `remaining_percent` from `used_percent` (matching the canonical @@ -352,17 +409,7 @@ fn proof_payload_is_supported(surface_mode: SurfaceMode, payload: Option<&str>) (SurfaceMode::Settings, None) => true, (SurfaceMode::Settings, Some(tab)) => is_supported_settings_tab(tab), (SurfaceMode::PopOut, None) => true, - (SurfaceMode::PopOut, Some(raw_target)) => { - let Some(target) = SurfaceTarget::parse(raw_target) else { - return false; - }; - - match target { - SurfaceTarget::Dashboard => true, - SurfaceTarget::Provider { provider_id } => is_supported_provider_id(&provider_id), - _ => false, - } - } + (SurfaceMode::PopOut, Some(_)) => false, } } @@ -455,18 +502,14 @@ mod tests { } #[test] - fn parse_provider_popout_proof_target() { - with_proof_mode_env(Some("popOut:provider:codex"), || { - let cfg = ProofConfig::from_env().unwrap(); - assert_eq!(cfg.target_surface, "popOut"); - assert_eq!(cfg.target_payload.as_deref(), Some("provider:codex")); - assert_eq!( - cfg.surface_target(), - SurfaceTarget::Provider { - provider_id: "codex".into() - } - ); - }); + fn retired_popout_provider_proof_targets_are_rejected() { + // The legacy PopOut layout (with provider deep links) is retired; + // `popOut` only opens the tray-panel flyout. + for raw in ["popOut:provider:codex", "popOut:dashboard"] { + with_proof_mode_env(Some(raw), || { + assert!(ProofConfig::from_env().is_none(), "{raw}"); + }); + } } #[test] @@ -594,4 +637,72 @@ mod tests { assert_eq!(cost.period, "month"); assert_eq!(cost.formatted_used, "$12.50"); } + + fn valid_proof_config() -> ProofConfig { + ProofConfig { + target_surface: "trayPanel".into(), + settings_tab: None, + target_payload: None, + } + } + + fn seed_snapshot(provider_id: &str, used_percent: f64) -> serde_json::Value { + serde_json::json!({ + "providerId": provider_id, + "primary": { "usedPercent": used_percent, "windowMinutes": 300 } + }) + } + + #[test] + fn seed_snapshot_arrays_preserve_legacy_codex_object_behavior() { + let json = seed_snapshot("codex", 61.0).to_string(); + let snapshots = parse_seed_usage_snapshots(&json, None).expect("legacy seed parses"); + assert_eq!(snapshots.len(), 1); + assert_eq!(snapshots[0].provider_id, "codex"); + assert_eq!(snapshots[0].primary.remaining_percent, 39.0); + } + + #[test] + fn seed_snapshot_array_normalizes_multiple_supported_providers() { + let json = serde_json::json!([seed_snapshot("codex", 61.0), seed_snapshot("claude", 24.0)]) + .to_string(); + let snapshots = parse_seed_usage_snapshots(&json, Some(&valid_proof_config())) + .expect("supported snapshots parse in proof mode"); + + assert_eq!(snapshots.len(), 2); + assert_eq!(snapshots[0].provider_id, "codex"); + assert_eq!(snapshots[0].primary.remaining_percent, 39.0); + assert_eq!(snapshots[1].provider_id, "claude"); + assert_eq!(snapshots[1].primary.remaining_percent, 76.0); + assert!( + snapshots + .iter() + .all(|snapshot| !snapshot.updated_at.is_empty()) + ); + } + + #[test] + fn seed_snapshot_arrays_reject_empty_unknown_duplicate_and_non_finite_values() { + let proof = valid_proof_config(); + for json in [ + "[]".to_string(), + serde_json::json!([seed_snapshot("unknown-provider", 1.0)]).to_string(), + // CLI aliases are not bridge provider ids. + serde_json::json!([seed_snapshot("openai", 1.0)]).to_string(), + serde_json::json!([seed_snapshot("codex", 1.0), seed_snapshot("codex", 2.0)]) + .to_string(), + r#"[{"providerId":"codex","primary":{"usedPercent":1e400}}]"#.to_string(), + ] { + assert!( + parse_seed_usage_snapshots(&json, Some(&proof)).is_err(), + "{json}" + ); + } + } + + #[test] + fn seed_snapshot_arrays_are_rejected_without_valid_proof_config() { + let json = serde_json::json!([seed_snapshot("codex", 10.0)]).to_string(); + assert!(parse_seed_usage_snapshots(&json, None).is_err()); + } } diff --git a/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs b/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs index 79dad5680a..40fbd16b6a 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs @@ -2,9 +2,10 @@ //! panel with optional always-on-top behavior that auto-hides on click-outside. //! //! Runs as an auxiliary Tauri window labeled `flyout`, independent of the -//! `main` window's surface state machine — it coexists with "Show Window" -//! (`SurfaceMode::PopOut`, which stays on `main`) instead of being a -//! mutually-exclusive state of the same window. +//! `main` window's surface state machine. It is the only dashboard layout: +//! tray left-click, "Pop Out Dashboard", the global shortcut, app launch and +//! single-instance relaunch all open it. The legacy PopOut layout on `main` +//! is retired. //! //! Structurally modeled on `crate::floatbar` (self-contained module owning //! its window + a `handle_window_event` hook dispatched from `main.rs` @@ -13,6 +14,7 @@ //! pass, `WebviewUrl::App` with a `?window=` query marker). use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; use codexbar::settings::Settings; @@ -42,6 +44,15 @@ const BLUR_DISMISS_CLICK_WINDOW: Duration = Duration::from_millis(250); /// `was_tray_panel_recently_shown` guard for the old shared window. const RECENTLY_SHOWN_GRACE: Duration = Duration::from_millis(500); +/// Set once at startup by `CODEXBAR_START_VISIBLE`: keeps the flyout open +/// when it loses focus, for automation flows that need it to stay visible. +static KEEP_OPEN_ON_BLUR: AtomicBool = AtomicBool::new(false); + +/// Keep the flyout open on focus loss for the rest of this process. +pub fn keep_open_on_blur() { + KEEP_OPEN_ON_BLUR.store(true, Ordering::Relaxed); +} + /// Read the remembered flyout size, if any (migrating a legacy /// `"trayPanel"`-keyed size on first read — see `geometry_store::load_size`). pub fn stored_size() -> Option<(u32, u32)> { @@ -259,7 +270,8 @@ pub fn handle_window_event(window: &tauri::Window, event: &tauri::WindowEvent) - let _ = window.set_always_on_top(true); return true; } - if crate::proof_harness::is_proof_mode(app) { + if crate::proof_harness::is_proof_mode(app) || KEEP_OPEN_ON_BLUR.load(Ordering::Relaxed) + { return true; } let Some(st) = app.try_state::>() else { diff --git a/apps/desktop-tauri/src-tauri/src/shell/mod.rs b/apps/desktop-tauri/src-tauri/src/shell/mod.rs index 6e77233d57..a692281f62 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/mod.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/mod.rs @@ -19,7 +19,7 @@ mod tests; pub(crate) use position::inferred_tray_panel_position_for_monitor_size; pub use position::{remember_current_geometry_if_eligible, tray_panel_position}; -pub use transition::{reopen_to_target, transition_to_target}; +pub use transition::transition_to_target; pub use window::hide_to_tray_if_current; #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/apps/desktop-tauri/src-tauri/src/shell/transition.rs b/apps/desktop-tauri/src-tauri/src/shell/transition.rs index fa57b8380c..0d2b3784bb 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/transition.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/transition.rs @@ -92,23 +92,6 @@ pub fn transition_to_target( ) } -pub fn reopen_to_target( - app: &AppHandle, - mode: SurfaceMode, - target: SurfaceTarget, - position: Option<(i32, i32)>, -) -> Result { - apply_transition_request_with_strategy( - app, - ShellTransitionRequest { - mode, - target, - position, - }, - true, - ) -} - fn apply_transition_request_with_strategy( app: &AppHandle, request: ShellTransitionRequest, diff --git a/apps/desktop-tauri/src-tauri/src/state.rs b/apps/desktop-tauri/src-tauri/src/state.rs index 10d626ec43..174c13d113 100644 --- a/apps/desktop-tauri/src-tauri/src/state.rs +++ b/apps/desktop-tauri/src-tauri/src/state.rs @@ -143,6 +143,8 @@ pub struct AppState { pub installer_path: Option, /// Proof-harness configuration (set when `CODEXBAR_PROOF_MODE` is active). pub proof_config: Option, + /// True only after a validated proof seed was installed at startup. + pub provider_cache_seeded: bool, /// Persistent notification manager — tracks which alerts have fired to prevent spam. pub notification_manager: codexbar::notifications::NotificationManager, /// Instant when the tray panel was last shown — used to suppress @@ -204,6 +206,7 @@ impl AppState { last_update_check_ms: None, installer_path: None, proof_config: None, + provider_cache_seeded: false, notification_manager: codexbar::notifications::NotificationManager::new(), last_shown_at: None, last_blur_dismissed_at: None, diff --git a/apps/desktop-tauri/src-tauri/src/surface_target.rs b/apps/desktop-tauri/src-tauri/src/surface_target.rs index 58fd895f9b..b80325bd3a 100644 --- a/apps/desktop-tauri/src-tauri/src/surface_target.rs +++ b/apps/desktop-tauri/src-tauri/src/surface_target.rs @@ -1,7 +1,5 @@ use serde::{Deserialize, Serialize}; -use codexbar::core::ProviderId; - use crate::surface::SurfaceMode; // Must mirror the frontend `SettingsTabId` union @@ -83,19 +81,13 @@ impl SurfaceTarget { } } -pub fn is_supported_provider_id(provider_id: &str) -> bool { - ProviderId::all() - .iter() - .any(|provider| provider.cli_name() == provider_id) -} - pub fn is_supported_settings_tab(tab: &str) -> bool { SETTINGS_TAB_IDS.contains(&tab) } #[cfg(test)] mod tests { - use super::{SurfaceTarget, is_supported_provider_id, is_supported_settings_tab}; + use super::{SurfaceTarget, is_supported_settings_tab}; use serde_json::json; #[test] @@ -169,13 +161,6 @@ mod tests { ); } - #[test] - fn supported_provider_ids_match_catalog() { - assert!(is_supported_provider_id("codex")); - assert!(is_supported_provider_id("bedrock")); - assert!(!is_supported_provider_id("not-a-provider")); - } - #[test] fn supported_settings_tabs_match_shell_tabs() { assert!(is_supported_settings_tab("menuBar")); diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index aebb7c2115..40b038547a 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -3,24 +3,18 @@ use std::sync::Mutex; use crate::commands::ProviderCatalogEntry; -#[cfg(test)] -use codexbar::core::ProviderId; -use codexbar::settings::MetricPreference; -use codexbar::settings::{Settings, TrayIconMode}; +use codexbar::settings::Settings; use tauri::image::Image; use tauri::menu::{CheckMenuItemBuilder, IsMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu}; use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; use tauri::{AppHandle, Manager}; -use codexbar::tray::{render_bar_icon_rgba, render_percent_icon_rgba}; - use crate::shell; use crate::state::{AppState, TrayAnchor}; -use crate::surface::SurfaceMode; -use crate::surface_target::SurfaceTarget; #[cfg(test)] use crate::tray_menu::build_tray_menu; use crate::tray_menu::{TrayMenuEntry, build_tray_menu_with}; +use crate::tray_presentation::{TrayPresentationPlan, headline_window}; #[derive(Debug, Clone, Copy)] struct MonitorScaleInfo { @@ -150,31 +144,7 @@ fn build_native_tray_menu( Menu::with_items(app, &item_refs) } -fn resolve_menu_target(id: &str) -> Option { - match id { - // "Show Window" — the full draggable window (PopOut mode), unchanged. - "show_panel" => Some(shell::ShellTransitionRequest { - mode: SurfaceMode::PopOut, - target: SurfaceTarget::Dashboard, - position: None, - }), - // NOTE: "pop_out" ("Pop Out Dashboard") is NOT handled here — it opens - // the dedicated flyout window (MenuAction::OpenFlyout in - // resolve_menu_action below), not a `shell::ShellTransitionRequest` - // against the `main`-window surface-mode machine. `SurfaceMode::TrayPanel` - // remains as a data key (geometry-key / window_properties source / - // panel-size reference) but `main` no longer transitions into it. - _ if id.starts_with("provider:") => Some(shell::ShellTransitionRequest { - mode: SurfaceMode::PopOut, - target: SurfaceTarget::parse(id)?, - position: None, - }), - _ => None, - } -} - enum MenuAction { - Transition(shell::ShellTransitionRequest), /// Open Settings/About in a detached window. OpenSettings(String), /// Open (or focus) the dedicated flyout ("Pop Out Dashboard") window. @@ -189,11 +159,6 @@ enum MenuAction { Quit, } -enum MenuTransitionDispatch { - Transition(shell::ShellTransitionRequest), - Reopen(shell::ShellTransitionRequest), -} - fn resolve_menu_action(id: &str) -> Option { if let Some(action) = crate::tray_accounts::resolve_action(id) { return Some(MenuAction::Account(action)); @@ -210,22 +175,7 @@ fn resolve_menu_action(id: &str) -> Option { let provider_id = id["toggle_provider:".len()..].to_string(); Some(MenuAction::ToggleProvider(provider_id)) } - _ => resolve_menu_target(id).map(MenuAction::Transition), - } -} - -fn resolve_menu_transition_dispatch( - id: &str, - request: shell::ShellTransitionRequest, -) -> MenuTransitionDispatch { - if id == "show_panel" { - MenuTransitionDispatch::Reopen(shell::ShellTransitionRequest { - mode: request.mode, - target: request.target, - position: None, - }) - } else { - MenuTransitionDispatch::Transition(request) + _ => None, } } @@ -277,13 +227,11 @@ pub fn setup(app: &mut tauri::App) -> Result<(), Box> { let app = tray.app_handle(); if button == MouseButton::Left && button_state == MouseButtonState::Up { store_anchor(app, &rect, position); - // Left-click toggles the dedicated flyout window (Pop Out + // Left-click toggles the tray-panel flyout window (Pop Out // Dashboard): open it, or cleanly close it when this same // click already blur-dismissed it (no open→close flicker). - // The full window stays available via "Show Window" - // (SurfaceMode::PopOut on `main`) — the two now coexist as - // separate OS windows instead of mutually-exclusive states - // of one window. Called directly (not spawned): native + // The flyout is the only dashboard layout; the legacy + // PopOut layout on `main` is retired. Called directly (not spawned): native // tray-icon event callbacks run on the same main-thread // event-loop context as `on_menu_event` below, where // `settings_window::open_or_focus` is also called @@ -334,29 +282,6 @@ fn schedule_tray_promotion_retries(app_handle: AppHandle) { fn handle_menu_event(app: &AppHandle, id: &str) { match resolve_menu_action(id) { Some(MenuAction::Account(action)) => crate::tray_accounts::handle_action(app, action), - 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. - MenuTransitionDispatch::Reopen(request) => { - let _ = shell::reopen_to_target( - app, - request.mode, - request.target, - request.position, - ); - } - MenuTransitionDispatch::Transition(request) => { - let _ = shell::transition_to_target( - app, - request.mode, - request.target, - request.position, - ); - } - } - } Some(MenuAction::OpenSettings(tab)) => { let _ = shell::settings_window::open_or_focus(app, &tab); } @@ -408,7 +333,8 @@ pub(crate) fn rebuild_tray_menu(app: &AppHandle) { let settings = Settings::load(); let status_labels = if let Some(st) = app.try_state::>() { let guard = st.lock().unwrap(); - status_labels_for_settings(&settings, &guard.provider_cache, settings.ui_language) + TrayPresentationPlan::resolve(&settings, &guard.provider_cache) + .status_labels(settings.ui_language) } else { vec![] }; @@ -426,7 +352,8 @@ pub fn update_tray_status_items( ) { let catalog = crate::commands::get_provider_catalog(); let settings = Settings::load(); - let status_labels = status_labels_for_settings(&settings, snapshots, settings.ui_language); + let status_labels = + TrayPresentationPlan::resolve(&settings, snapshots).status_labels(settings.ui_language); if let Ok(menu) = build_native_tray_menu(app, &catalog, &status_labels) && let Some(tray) = app.tray_by_id("codexbar-main") @@ -464,228 +391,16 @@ pub fn update_tray_icon_and_tooltip( return; }; - // ── Icon ───────────────────────────────────────────────────────────── let settings = Settings::load(); - let snapshots = snapshots.to_vec(); - let ordered_snapshots = ordered_snapshot_refs(&settings, &snapshots); - let ok_snapshots: Vec<_> = ordered_snapshots - .iter() - .copied() - .filter(|s| s.error.is_none()) - .collect(); - let all_error = ok_snapshots.is_empty() && !snapshots.is_empty(); - - let prefer_highest = settings.menu_bar_shows_highest_usage - || settings.menu_bar_display_mode.as_str() == "minimal"; - - let picked = pick_tray_provider(&ok_snapshots, prefer_highest); - - let (session_pct, weekly_pct) = match picked { - Some(s) => selected_tray_percents(s, &settings), - None => ( - ok_snapshots - .iter() - .map(|s| selected_tray_percents(s, &settings).0) - .fold(0.0_f64, f64::max), - None, - ), - }; - - let (rgba, w, h) = render_tray_icon_for_settings(&settings, session_pct, weekly_pct, all_error); + let plan = TrayPresentationPlan::resolve(&settings, snapshots); + let (rgba, w, h) = plan.render_icon(); let icon = Image::new_owned(rgba, w, h); let _ = tray.set_icon(Some(icon)); - // ── Tooltip ─────────────────────────────────────────────────────────── - let tooltip = build_tooltip(&snapshots, settings.ui_language); + let tooltip = build_tooltip(snapshots, settings.ui_language); let _ = tray.set_tooltip(Some(tooltip)); } -fn status_labels_for_settings( - settings: &Settings, - snapshots: &[crate::commands::ProviderUsageSnapshot], - lang: codexbar::settings::Language, -) -> Vec<(String, String)> { - let ordered_snapshots = ordered_snapshot_refs(settings, snapshots); - let healthy: Vec<_> = ordered_snapshots - .into_iter() - .filter(|s| s.error.is_none()) - .collect(); - if settings.tray_icon_mode == TrayIconMode::PerProvider { - return healthy - .into_iter() - .map(|s| provider_status_label(s, lang)) - .collect::>(); - } - - let Some(selected) = pick_tray_provider( - &healthy, - settings.menu_bar_shows_highest_usage || settings.menu_bar_display_mode == "minimal", - ) else { - return vec![]; - }; - - let (_, label) = provider_status_label(selected, lang); - vec![("status_summary".to_string(), label)] -} - -fn ordered_snapshot_refs<'a>( - settings: &Settings, - snapshots: &'a [crate::commands::ProviderUsageSnapshot], -) -> Vec<&'a crate::commands::ProviderUsageSnapshot> { - let order = settings - .provider_display_order_names() - .into_iter() - .enumerate() - .map(|(index, provider_id)| (provider_id, index)) - .collect::>(); - let mut ordered = snapshots.iter().collect::>(); - ordered.sort_by(|a, b| { - let a_order = order.get(&a.provider_id); - let b_order = order.get(&b.provider_id); - match (a_order, b_order) { - (Some(a_order), Some(b_order)) if a_order != b_order => a_order.cmp(b_order), - (Some(_), None) => std::cmp::Ordering::Less, - (None, Some(_)) => std::cmp::Ordering::Greater, - _ => a.display_name.cmp(&b.display_name), - } - }); - ordered -} - -fn provider_status_label( - snapshot: &crate::commands::ProviderUsageSnapshot, - lang: codexbar::settings::Language, -) -> (String, String) { - // MonthlyPlan metric (PAYG spend, e.g. Mistral): show formatted cost. - let provider = codexbar::core::ProviderId::from_cli_name(&snapshot.provider_id); - let preference = provider - .map(|id| Settings::load().get_provider_metric(id)) - .unwrap_or_default(); - if preference == MetricPreference::MonthlyPlan - && let Some(cost) = snapshot.cost.as_ref() - { - let amount = if !cost.formatted_used.is_empty() { - cost.formatted_used.clone() - } else { - crate::commands::format_cost_amount(cost) - }; - return ( - snapshot.provider_id.clone(), - format!("{} {}", snapshot.display_name, amount), - ); - } - - let label = crate::commands::compact_tray_status_label(headline_window(snapshot), lang); - ( - snapshot.provider_id.clone(), - format!("{} {}", snapshot.display_name, label), - ) -} - -/// Window that headline tray surfaces should label for a provider. -/// -/// F5 (upstream 0.48.0): for Codex, prefer the first non-informational lane so -/// a monthly-only plan shows the monthly window with its reset countdown -/// instead of the informational "No active 5h session" placeholder. -/// -/// Shared by the tray menu rows (`provider_status_label`) and the tray tooltip -/// (`build_tooltip`) so the two cannot drift apart. -fn headline_window( - snapshot: &crate::commands::ProviderUsageSnapshot, -) -> &crate::commands::RateWindowSnapshot { - if snapshot.provider_id == "codex" { - codex_lane_headline_window(snapshot) - } else { - &snapshot.primary - } -} - -/// F5 (upstream 0.48.0): pick the first non-informational Codex lane in -/// session → weekly → monthly order. When all lanes are informational -/// (no active session at all), fall back to the primary for the -/// "No active 5h session" placeholder. -pub(crate) fn codex_lane_headline_window( - snapshot: &crate::commands::ProviderUsageSnapshot, -) -> &crate::commands::RateWindowSnapshot { - if !snapshot.primary.is_informational { - return &snapshot.primary; - } - if let Some(ref secondary) = snapshot.secondary - && !secondary.is_informational - { - return secondary; - } - if let Some(ref tertiary) = snapshot.tertiary - && !tertiary.is_informational - { - return tertiary; - } - &snapshot.primary -} - -fn render_tray_icon_for_settings( - settings: &Settings, - session_pct: f64, - weekly_pct: Option, - all_error: bool, -) -> (Vec, u32, u32) { - if settings.menu_bar_shows_percent { - render_percent_icon_rgba(session_pct, all_error) - } else { - render_bar_icon_rgba(session_pct, weekly_pct, all_error) - } -} - -/// Pick the provider whose usage the tray icon should render. -/// -/// Exposed so that the unit tests can exercise both `highest` and `first` -/// paths without needing a live Tauri app handle. -fn pick_tray_provider<'a>( - ok_snapshots: &'a [&'a crate::commands::ProviderUsageSnapshot], - prefer_highest: bool, -) -> Option<&'a crate::commands::ProviderUsageSnapshot> { - if ok_snapshots.is_empty() { - return None; - } - if prefer_highest { - ok_snapshots.iter().copied().max_by(|a, b| { - a.primary - .used_percent - .partial_cmp(&b.primary.used_percent) - .unwrap_or(std::cmp::Ordering::Equal) - }) - } else { - Some(ok_snapshots[0]) - } -} - -fn selected_tray_percents( - snapshot: &crate::commands::ProviderUsageSnapshot, - settings: &Settings, -) -> (f64, Option) { - let (selected, companion) = - crate::usage_metric::selected_usage_icon_windows(snapshot, settings); - ( - display_metric_percent(&selected, settings.show_as_used), - companion - .as_ref() - .map(|window| display_metric_percent(window, settings.show_as_used)), - ) -} - -fn display_metric_percent(window: &crate::commands::RateWindowSnapshot, show_as_used: bool) -> f64 { - if window.is_informational { - return 0.0; - } - if window.is_exhausted || window.used_percent >= 100.0 { - return if show_as_used { 100.0 } else { 0.0 }; - } - - let used_percent = window.used_percent; - let used = used_percent.clamp(0.0, 100.0); - if show_as_used { used } else { 100.0 - used } -} - /// Build a compact multi-line tooltip string from provider snapshots. fn build_tooltip( snapshots: &[crate::commands::ProviderUsageSnapshot], @@ -803,6 +518,7 @@ fn build_native_menu_entry( #[cfg(test)] mod tests { use super::*; + use crate::surface::SurfaceMode; fn sample_provider_catalog() -> Vec { vec![ @@ -854,35 +570,13 @@ mod tests { } } - #[test] - fn provider_menu_routes_to_provider_popout_target() { - let action = resolve_menu_target("provider:codex").expect("provider target"); - assert_eq!(action.mode, SurfaceMode::PopOut); - assert_eq!( - action.target, - SurfaceTarget::Provider { - provider_id: "codex".into() - } - ); - } - #[test] fn pop_out_menu_routes_to_open_flyout_action() { - // "Pop Out Dashboard" opens the dedicated flyout window — not a - // `shell::ShellTransitionRequest` against the `main`-window surface - // machine — which is what lets it coexist with "Show Window" - // (SurfaceMode::PopOut, which stays on `main`) instead of the two - // being mutually-exclusive states of one window. + // "Pop Out Dashboard" opens the tray-panel flyout window, the only + // dashboard layout. let action = resolve_menu_action("pop_out").expect("pop_out action"); assert!(matches!(action, MenuAction::OpenFlyout)); - // resolve_menu_target no longer resolves "pop_out" at all — it is - // intercepted earlier in resolve_menu_action. - assert!(resolve_menu_target("pop_out").is_none()); - - let show_window = resolve_menu_target("show_panel").expect("show_panel target"); - assert_eq!(show_window.mode, SurfaceMode::PopOut); - // SurfaceMode::TrayPanel is retained purely as a data key (geometry // key / window_properties source / panel-size reference) for the // flyout window's builder — the properties themselves are unchanged. @@ -891,65 +585,11 @@ mod tests { } #[test] - fn show_panel_menu_reopens_popout_dashboard_with_default_position_chain() { - let request = resolve_menu_target("show_panel").expect("show_panel target"); - assert_eq!(request.mode, SurfaceMode::PopOut); - assert_eq!(request.target, SurfaceTarget::Dashboard); - - let dispatch = resolve_menu_transition_dispatch( - "show_panel", - shell::ShellTransitionRequest { - mode: SurfaceMode::PopOut, - target: SurfaceTarget::Dashboard, - position: Some((320, 240)), - }, - ); - - match dispatch { - MenuTransitionDispatch::Reopen(request) => { - assert_eq!(request.mode, SurfaceMode::PopOut); - assert_eq!(request.target, SurfaceTarget::Dashboard); - assert_eq!(request.position, None); - } - MenuTransitionDispatch::Transition(_) => { - panic!("show_panel should reopen via default PopOut positioning") - } - } - } - - #[test] - fn non_show_panel_menu_keeps_explicit_position() { - // "pop_out" no longer reaches resolve_menu_transition_dispatch at all - // (it's intercepted as MenuAction::OpenFlyout in resolve_menu_action - // before falling through to resolve_menu_target); a provider deep - // link is the realistic surviving non-"show_panel" caller of this - // dispatch function today. - let dispatch = resolve_menu_transition_dispatch( - "provider:codex", - shell::ShellTransitionRequest { - mode: SurfaceMode::PopOut, - target: SurfaceTarget::Provider { - provider_id: "codex".into(), - }, - position: Some((320, 240)), - }, - ); - - match dispatch { - MenuTransitionDispatch::Transition(request) => { - assert_eq!(request.mode, SurfaceMode::PopOut); - assert_eq!( - request.target, - SurfaceTarget::Provider { - provider_id: "codex".into() - } - ); - assert_eq!(request.position, Some((320, 240))); - } - MenuTransitionDispatch::Reopen(_) => { - panic!("non-show-panel actions should use direct transitions") - } - } + fn legacy_popout_menu_ids_no_longer_route_anywhere() { + // "show_panel" ("Show Window") and "provider:" used to open the + // retired PopOut layout on `main`. + assert!(resolve_menu_action("show_panel").is_none()); + assert!(resolve_menu_action("provider:codex").is_none()); } #[test] @@ -1110,131 +750,6 @@ mod tests { fake_snapshot_with(id, display, used_percent, None, None, None) } - fn fake_extra_window(percent: f64) -> crate::commands::NamedRateWindowSnapshot { - crate::commands::NamedRateWindowSnapshot { - id: "additional_budget".to_string(), - title: "Additional Budget".to_string(), - fallback_lane: false, - window: crate::commands::RateWindowSnapshot { - used_percent: percent, - remaining_percent: 100.0 - percent, - window_minutes: None, - resets_at: None, - reset_description: None, - is_exhausted: false, - is_informational: false, - reserve_percent: None, - reserve_description: None, - reserve_will_last_to_reset: false, - reserve_eta_seconds: None, - }, - } - } - - #[test] - fn pick_tray_provider_highest_picks_max_primary() { - let a = fake_snapshot("codex", "Codex", 30.0); - let b = fake_snapshot("claude", "Claude", 72.5); - let c = fake_snapshot("gemini", "Gemini", 50.0); - let refs: Vec<&crate::commands::ProviderUsageSnapshot> = vec![&a, &b, &c]; - - let picked = pick_tray_provider(&refs, /* prefer_highest = */ true) - .expect("highest mode should pick a provider"); - assert_eq!(picked.provider_id, "claude"); - } - - #[test] - fn pick_tray_provider_first_preserves_catalog_order() { - let a = fake_snapshot("codex", "Codex", 30.0); - let b = fake_snapshot("claude", "Claude", 72.5); - let refs: Vec<&crate::commands::ProviderUsageSnapshot> = vec![&a, &b]; - - let picked = pick_tray_provider(&refs, /* prefer_highest = */ false) - .expect("non-highest mode should still pick the first entry"); - assert_eq!(picked.provider_id, "codex"); - } - - #[test] - fn pick_tray_provider_none_when_empty() { - let refs: Vec<&crate::commands::ProviderUsageSnapshot> = vec![]; - assert!(pick_tray_provider(&refs, true).is_none()); - assert!(pick_tray_provider(&refs, false).is_none()); - } - - #[test] - fn status_labels_per_provider_mode_lists_each_healthy_provider() { - let settings = Settings { - tray_icon_mode: TrayIconMode::PerProvider, - provider_order: codexbar::settings::normalize_provider_order(&[ - "claude".to_string(), - "codex".to_string(), - ]), - ..Settings::default() - }; - let snapshots = vec![ - fake_snapshot("codex", "Codex", 30.0), - fake_snapshot("claude", "Claude", 72.0), - ]; - - let labels = status_labels_for_settings( - &settings, - &snapshots, - codexbar::settings::Language::English, - ); - - assert_eq!( - labels, - vec![ - ("claude".to_string(), "Claude 72%".to_string()), - ("codex".to_string(), "Codex 30%".to_string()), - ] - ); - } - - #[test] - fn status_labels_single_mode_collapses_to_selected_provider() { - let settings = Settings { - tray_icon_mode: TrayIconMode::Single, - menu_bar_shows_highest_usage: true, - ..Settings::default() - }; - let snapshots = vec![ - fake_snapshot("codex", "Codex", 30.0), - fake_snapshot("claude", "Claude", 72.0), - ]; - - let labels = status_labels_for_settings( - &settings, - &snapshots, - codexbar::settings::Language::English, - ); - - assert_eq!( - labels, - vec![("status_summary".to_string(), "Claude 72%".to_string())] - ); - } - - #[test] - fn tray_icon_renderer_uses_percent_mode_when_enabled() { - let bar_settings = Settings { - menu_bar_shows_percent: false, - ..Settings::default() - }; - let percent_settings = Settings { - menu_bar_shows_percent: true, - ..Settings::default() - }; - - let (bar, bar_w, bar_h) = - render_tray_icon_for_settings(&bar_settings, 72.0, Some(40.0), false); - let (percent, pct_w, pct_h) = - render_tray_icon_for_settings(&percent_settings, 72.0, Some(40.0), false); - - assert_eq!((bar_w, bar_h), (pct_w, pct_h)); - assert_ne!(bar, percent); - } - #[test] fn tooltip_uses_compact_status_labels() { let mut claude = fake_snapshot("claude", "Claude", 13.0); @@ -1311,319 +826,16 @@ mod tests { "{japanese_tooltip}" ); - let (_, english_label) = - provider_status_label(&claude, codexbar::settings::Language::English); - let (_, japanese_label) = - provider_status_label(&claude, codexbar::settings::Language::Japanese); + let settings = Settings::default(); + let snapshots = vec![claude]; + let plan = TrayPresentationPlan::resolve(&settings, &snapshots); + let english_label = plan.status_labels(codexbar::settings::Language::English)[0] + .1 + .clone(); + let japanese_label = plan.status_labels(codexbar::settings::Language::Japanese)[0] + .1 + .clone(); assert!(english_label.contains("Resets in"), "{english_label}"); assert!(japanese_label.contains("リセットまで"), "{japanese_label}"); } - - #[test] - fn selected_tray_percent_uses_cursor_extra_usage_cost() { - let mut settings = Settings::default(); - settings.set_provider_metric(ProviderId::Cursor, MetricPreference::ExtraUsage); - let snapshot = fake_snapshot_with( - "cursor", - "Cursor", - 10.0, - Some(20.0), - Some(72.0), - Some((15.0, 100.0)), - ); - - let (primary, secondary) = selected_tray_percents(&snapshot, &settings); - - assert_eq!(primary, 15.0); - assert_eq!(secondary, Some(20.0)); - } - - #[test] - fn selected_tray_percent_tracks_extra_rate_window() { - let mut settings = Settings::default(); - settings.set_provider_metric(ProviderId::Copilot, MetricPreference::ExtraUsage); - let mut snapshot = fake_snapshot("copilot", "Copilot", 20.0); - snapshot.extra_rate_windows.push(fake_extra_window(42.0)); - - let (primary, secondary) = selected_tray_percents(&snapshot, &settings); - - assert_eq!(primary, 42.0); - assert_eq!(secondary, None); - } - - #[test] - fn copilot_automatic_tracks_highest_extra_rate_window() { - let settings = Settings::default(); - let mut snapshot = fake_snapshot("copilot", "Copilot", 20.0); - snapshot.extra_rate_windows.push(fake_extra_window(42.0)); - - let (primary, _) = selected_tray_percents(&snapshot, &settings); - - assert_eq!(primary, 42.0); - } - - #[test] - fn selected_tray_percent_respects_remaining_display_mode() { - let mut settings = Settings { - show_as_used: false, - ..Settings::default() - }; - settings.set_provider_metric(ProviderId::Cursor, MetricPreference::ExtraUsage); - let snapshot = fake_snapshot_with( - "cursor", - "Cursor", - 10.0, - Some(20.0), - Some(72.0), - Some((15.0, 100.0)), - ); - - let (primary, secondary) = selected_tray_percents(&snapshot, &settings); - - assert_eq!(primary, 85.0); - assert_eq!(secondary, Some(80.0)); - } - - #[test] - fn exhausted_automatic_window_never_renders_as_remaining_progress() { - let mut settings = Settings { - show_as_used: false, - ..Settings::default() - }; - let mut snapshot = fake_snapshot_with( - "opencodego", - "OpenCode Go", - 20.0, - Some(60.0), - Some(40.0), - None, - ); - snapshot - .tertiary - .as_mut() - .expect("monthly quota") - .is_exhausted = true; - - let (remaining, _) = selected_tray_percents(&snapshot, &settings); - assert_eq!(remaining, 0.0); - - settings.show_as_used = true; - let (used, _) = selected_tray_percents(&snapshot, &settings); - assert_eq!(used, 100.0); - } - - #[test] - fn full_automatic_window_without_exhausted_flag_has_zero_remaining_progress() { - let mut settings = Settings { - show_as_used: false, - ..Settings::default() - }; - let mut snapshot = fake_snapshot_with( - "opencodego", - "OpenCode Go", - 20.0, - Some(60.0), - Some(100.0), - None, - ); - snapshot - .tertiary - .as_mut() - .expect("monthly quota") - .is_exhausted = false; - - let (remaining, _) = selected_tray_percents(&snapshot, &settings); - assert_eq!(remaining, 0.0); - - settings.show_as_used = true; - let (used, _) = selected_tray_percents(&snapshot, &settings); - assert_eq!(used, 100.0); - } - - #[test] - fn missing_automatic_window_does_not_look_like_available_remaining_progress() { - let settings = Settings { - show_as_used: false, - ..Settings::default() - }; - let mut snapshot = fake_snapshot_with("opencodego", "OpenCode Go", 0.0, None, None, None); - snapshot.primary.is_informational = true; - - let (remaining, _) = selected_tray_percents(&snapshot, &settings); - - assert_eq!(remaining, 0.0); - } - - #[test] - fn selected_tray_percent_falls_back_when_extra_usage_missing() { - let mut settings = Settings::default(); - settings.set_provider_metric(ProviderId::Cursor, MetricPreference::ExtraUsage); - let snapshot = fake_snapshot_with("cursor", "Cursor", 10.0, Some(72.0), None, None); - - let (primary, _) = selected_tray_percents(&snapshot, &settings); - - assert_eq!(primary, 72.0); - } - - #[test] - fn single_meaningful_secondary_quota_uses_full_single_meter() { - let settings = Settings::default(); - let mut snapshot = fake_snapshot_with("claude", "Claude", 0.0, Some(42.0), None, None); - snapshot.primary.is_informational = true; - - let (primary, secondary) = selected_tray_percents(&snapshot, &settings); - - assert_eq!(primary, 42.0); - assert_eq!(secondary, None); - } - - #[test] - fn selected_secondary_quota_is_not_duplicated_when_tertiary_is_meaningful() { - let settings = Settings::default(); - let mut snapshot = - fake_snapshot_with("claude", "Claude", 0.0, Some(42.0), Some(30.0), None); - snapshot.primary.is_informational = true; - - let (primary, secondary) = selected_tray_percents(&snapshot, &settings); - - assert_eq!(primary, 42.0); - assert_eq!(secondary, Some(30.0)); - } - - #[test] - fn two_meaningful_quotas_keep_two_meter_layout() { - let mut settings = Settings::default(); - settings.set_provider_metric(ProviderId::Cursor, MetricPreference::Session); - let snapshot = fake_snapshot_with("cursor", "Cursor", 15.0, Some(40.0), None, None); - - let (primary, secondary) = selected_tray_percents(&snapshot, &settings); - - assert_eq!(primary, 15.0); - assert_eq!(secondary, Some(40.0)); - } - - #[test] - fn informational_primary_skips_session_and_automatic_phantom_zero() { - let mut settings = Settings::default(); - settings.set_provider_metric(ProviderId::Claude, MetricPreference::Session); - let mut snapshot = fake_snapshot_with("claude", "Claude", 0.0, Some(42.0), None, None); - snapshot.primary.is_informational = true; - - // Session preference must not paint the synthetic 0% primary; - // it falls through to Automatic which prefers weekly (42%). - let (primary, _) = selected_tray_percents(&snapshot, &settings); - assert_eq!(primary, 42.0); - assert_ne!(primary, 0.0); - - // Automatic also prefers weekly over informational primary. - settings.set_provider_metric(ProviderId::Claude, MetricPreference::Automatic); - let (primary, _) = selected_tray_percents(&snapshot, &settings); - assert_eq!(primary, 42.0); - } - - #[test] - fn claude_automatic_prefers_weekly_when_model_exhausted() { - let settings = Settings::default(); - let mut snapshot = fake_snapshot_with("claude", "Claude", 40.0, Some(22.0), None, None); - snapshot.model_specific = Some(crate::commands::RateWindowSnapshot { - used_percent: 100.0, - remaining_percent: 0.0, - window_minutes: Some(10080), - resets_at: None, - reset_description: None, - is_exhausted: true, - is_informational: false, - reserve_percent: None, - reserve_description: None, - reserve_will_last_to_reset: false, - reserve_eta_seconds: None, - }); - - let (primary, _) = selected_tray_percents(&snapshot, &settings); - assert_eq!(primary, 22.0); - - // Explicit model override is untouched. - let mut overridden = settings.clone(); - overridden.set_provider_metric(ProviderId::Claude, MetricPreference::Model); - let (primary, _) = selected_tray_percents(&snapshot, &overridden); - assert_eq!(primary, 100.0); - } - - #[test] - fn automatic_prefers_exhausted_weekly_over_low_session() { - let settings = Settings::default(); - let snapshot = fake_snapshot_with("codex", "Codex", 20.0, Some(100.0), None, None); - - let (primary, _) = selected_tray_percents(&snapshot, &settings); - assert_eq!(primary, 100.0); - - // Explicit session override still wins. - let mut overridden = settings.clone(); - overridden.set_provider_metric(ProviderId::Codex, MetricPreference::Session); - let (primary, _) = selected_tray_percents(&snapshot, &overridden); - assert_eq!(primary, 20.0); - } - - #[test] - fn automatic_picks_highest_among_model_and_extra_windows() { - let settings = Settings::default(); - let mut snapshot = - fake_snapshot_with("gemini", "Gemini", 10.0, Some(30.0), Some(40.0), None); - snapshot.model_specific = Some(crate::commands::RateWindowSnapshot { - used_percent: 55.0, - remaining_percent: 45.0, - window_minutes: None, - resets_at: None, - reset_description: None, - is_exhausted: false, - is_informational: false, - reserve_percent: None, - reserve_description: None, - reserve_will_last_to_reset: false, - reserve_eta_seconds: None, - }); - snapshot.extra_rate_windows.push(fake_extra_window(90.0)); - - let (primary, _) = selected_tray_percents(&snapshot, &settings); - assert_eq!(primary, 90.0); - } - - #[test] - fn f5_headline_prefers_non_informational_primary() { - let snapshot = fake_snapshot_with("codex", "Codex", 50.0, Some(20.0), Some(30.0), None); - let headline = codex_lane_headline_window(&snapshot); - assert!((headline.used_percent - 50.0).abs() < f64::EPSILON); - } - - #[test] - fn f5_headline_falls_back_to_secondary_when_primary_informational() { - let mut snapshot = fake_snapshot_with("codex", "Codex", 0.0, Some(25.0), Some(30.0), None); - snapshot.primary.is_informational = true; - let headline = codex_lane_headline_window(&snapshot); - assert!((headline.used_percent - 25.0).abs() < f64::EPSILON); - } - - #[test] - fn f5_headline_falls_back_to_tertiary_when_primary_and_secondary_informational() { - let mut snapshot = fake_snapshot_with("codex", "Codex", 0.0, Some(0.0), Some(35.0), None); - snapshot.primary.is_informational = true; - snapshot.secondary.as_mut().unwrap().is_informational = true; - let headline = codex_lane_headline_window(&snapshot); - assert!((headline.used_percent - 35.0).abs() < f64::EPSILON); - } - - #[test] - fn f5_headline_returns_primary_when_all_informational() { - let mut snapshot = fake_snapshot_with("codex", "Codex", 0.0, Some(0.0), Some(0.0), None); - snapshot.primary.is_informational = true; - if let Some(sec) = &mut snapshot.secondary { - sec.is_informational = true; - } - if let Some(ter) = &mut snapshot.tertiary { - ter.is_informational = true; - } - let headline = codex_lane_headline_window(&snapshot); - // Falls back to primary (the placeholder) when all are informational. - assert!(headline.is_informational); - } } diff --git a/apps/desktop-tauri/src-tauri/src/tray_menu.rs b/apps/desktop-tauri/src-tauri/src/tray_menu.rs index 688e56b42b..3474c92d6b 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_menu.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_menu.rs @@ -123,10 +123,6 @@ pub(crate) fn build_tray_menu_with( "pop_out", text(LocaleKey::TrayPopOutDashboard), )); - menu.push(TrayMenuEntry::item( - "show_panel", - text(LocaleKey::TrayShowWindow), - )); menu.push(TrayMenuEntry::check_item( "toggle_float_bar", text(LocaleKey::TrayShowFloatBar), @@ -206,6 +202,16 @@ mod tests { assert!(menu_contains(&menu, "check_for_updates")); } + #[test] + fn tray_menu_offers_only_the_tray_panel_window_entry() { + // The legacy "Show Window" entry opened the retired PopOut layout on + // `main`; "Pop Out Dashboard" (the tray-panel flyout) is the only + // window entry now. + let menu = build_tray_menu(&sample_provider_catalog(), &[], &both_enabled()); + assert!(menu_contains(&menu, "pop_out")); + assert!(!menu_contains(&menu, "show_panel")); + } + #[test] fn provider_check_items_reflect_enabled_state() { let menu = build_tray_menu( @@ -280,7 +286,7 @@ mod tests { } assert_eq!(label_for(&menu, "refresh"), "すべて更新"); - assert_eq!(label_for(&menu, "show_panel"), "ウィンドウを表示"); + assert_eq!(label_for(&menu, "pop_out"), "ダッシュボードを開く"); assert_eq!(label_for(&menu, "settings"), "設定..."); assert_eq!(label_for(&menu, "quit"), "終了"); diff --git a/apps/desktop-tauri/src-tauri/src/tray_presentation.rs b/apps/desktop-tauri/src-tauri/src/tray_presentation.rs new file mode 100644 index 0000000000..6292a2c1fa --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/tray_presentation.rs @@ -0,0 +1,370 @@ +//! Pure tray presentation policy shared by the native tray surfaces. + +use crate::commands::{ProviderUsageSnapshot, RateWindowSnapshot}; +use codexbar::settings::{Language, MetricPreference, Settings, TrayIconMode}; +use codexbar::tray::{ + render_bar_icon_rgba, render_percent_icon_rgba, render_stacked_bar_icon_rgba, +}; + +#[derive(Debug, Clone, Copy, PartialEq)] +enum TrayIconPlan { + Bars { + primary_percent: f64, + secondary_percent: Option, + has_error: bool, + }, + Percent { + percent: f64, + has_error: bool, + }, + Stacked { + top_percent: f64, + bottom_percent: f64, + has_error: bool, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TrayStatusKey { + Summary, + Provider, +} + +#[derive(Debug, Clone, Copy)] +struct TrayStatusRow<'a> { + key: TrayStatusKey, + snapshot: &'a ProviderUsageSnapshot, +} + +/// Fully resolved tray presentation, independent of Tauri and operating-system state. +/// +/// The plan is the single policy boundary for provider ordering, mode-specific +/// selection, metric selection, status rows, and icon renderer choice. +pub(crate) struct TrayPresentationPlan<'a> { + settings: &'a Settings, + icon: TrayIconPlan, + status_rows: Vec>, +} + +impl<'a> TrayPresentationPlan<'a> { + pub(crate) fn resolve(settings: &'a Settings, snapshots: &'a [ProviderUsageSnapshot]) -> Self { + let ordered = ordered_snapshot_refs(settings, snapshots); + let healthy = ordered + .into_iter() + .filter(|snapshot| snapshot.error.is_none()) + .collect::>(); + let has_error = healthy.is_empty() && !snapshots.is_empty(); + let prefer_highest = + settings.menu_bar_shows_highest_usage || settings.menu_bar_display_mode == "minimal"; + let selected = pick_tray_provider(&healthy, prefer_highest); + + let (icon, status_rows) = match settings.tray_icon_mode { + TrayIconMode::Stacked => { + if let Some((top, bottom)) = pick_stacked_tray_providers(&healthy, settings) { + ( + TrayIconPlan::Stacked { + top_percent: selected_tray_percents(top, settings).0, + bottom_percent: selected_tray_percents(bottom, settings).0, + has_error, + }, + vec![ + TrayStatusRow { + key: TrayStatusKey::Provider, + snapshot: top, + }, + TrayStatusRow { + key: TrayStatusKey::Provider, + snapshot: bottom, + }, + ], + ) + } else { + let percents = selected + .map(|snapshot| selected_tray_percents(snapshot, settings)) + .unwrap_or((0.0, None)); + let rows = healthy + .first() + .map(|snapshot| TrayStatusRow { + key: TrayStatusKey::Provider, + snapshot, + }) + .into_iter() + .collect(); + ( + resolve_single_provider_icon_plan( + settings, percents.0, percents.1, has_error, + ), + rows, + ) + } + } + TrayIconMode::PerProvider => { + let percents = selected + .map(|snapshot| selected_tray_percents(snapshot, settings)) + .unwrap_or_else(|| fallback_percents(&healthy, settings)); + let rows = healthy + .iter() + .copied() + .map(|snapshot| TrayStatusRow { + key: TrayStatusKey::Provider, + snapshot, + }) + .collect(); + ( + resolve_single_provider_icon_plan(settings, percents.0, percents.1, has_error), + rows, + ) + } + TrayIconMode::Single => { + let percents = selected + .map(|snapshot| selected_tray_percents(snapshot, settings)) + .unwrap_or_else(|| fallback_percents(&healthy, settings)); + let rows = selected + .map(|snapshot| TrayStatusRow { + key: TrayStatusKey::Summary, + snapshot, + }) + .into_iter() + .collect(); + ( + resolve_single_provider_icon_plan(settings, percents.0, percents.1, has_error), + rows, + ) + } + }; + + Self { + settings, + icon, + status_rows, + } + } + + pub(crate) fn render_icon(&self) -> (Vec, u32, u32) { + match self.icon { + TrayIconPlan::Bars { + primary_percent, + secondary_percent, + has_error, + } => render_bar_icon_rgba(primary_percent, secondary_percent, has_error), + TrayIconPlan::Percent { percent, has_error } => { + render_percent_icon_rgba(percent, has_error) + } + TrayIconPlan::Stacked { + top_percent, + bottom_percent, + has_error, + } => render_stacked_bar_icon_rgba(top_percent, bottom_percent, has_error), + } + } + + pub(crate) fn status_labels(&self, language: Language) -> Vec<(String, String)> { + self.status_rows + .iter() + .map(|row| { + let (_, label) = provider_status_label(row.snapshot, self.settings, language); + let key = match row.key { + TrayStatusKey::Summary => "status_summary".to_string(), + TrayStatusKey::Provider => row.snapshot.provider_id.clone(), + }; + (key, label) + }) + .collect() + } +} + +fn resolve_single_provider_icon_plan( + settings: &Settings, + primary_percent: f64, + secondary_percent: Option, + has_error: bool, +) -> TrayIconPlan { + if settings.menu_bar_shows_percent { + TrayIconPlan::Percent { + percent: primary_percent, + has_error, + } + } else { + TrayIconPlan::Bars { + primary_percent, + secondary_percent, + has_error, + } + } +} + +fn fallback_percents( + healthy: &[&ProviderUsageSnapshot], + settings: &Settings, +) -> (f64, Option) { + ( + healthy + .iter() + .map(|snapshot| selected_tray_percents(snapshot, settings).0) + .fold(0.0_f64, f64::max), + None, + ) +} + +fn ordered_snapshot_refs<'a>( + settings: &Settings, + snapshots: &'a [ProviderUsageSnapshot], +) -> Vec<&'a ProviderUsageSnapshot> { + let order = settings + .provider_display_order_names() + .into_iter() + .enumerate() + .map(|(index, provider_id)| (provider_id, index)) + .collect::>(); + let mut ordered = snapshots.iter().collect::>(); + ordered.sort_by(|a, b| { + let a_order = order.get(&a.provider_id); + let b_order = order.get(&b.provider_id); + match (a_order, b_order) { + (Some(a_order), Some(b_order)) if a_order != b_order => a_order.cmp(b_order), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + _ => a.display_name.cmp(&b.display_name), + } + }); + ordered +} + +fn provider_status_label( + snapshot: &ProviderUsageSnapshot, + settings: &Settings, + language: Language, +) -> (String, String) { + let provider = codexbar::core::ProviderId::from_cli_name(&snapshot.provider_id); + let preference = provider + .map(|id| settings.get_provider_metric(id)) + .unwrap_or_default(); + if preference == MetricPreference::MonthlyPlan + && let Some(cost) = snapshot.cost.as_ref() + { + let amount = if !cost.formatted_used.is_empty() { + cost.formatted_used.clone() + } else { + crate::commands::format_cost_amount(cost) + }; + return ( + snapshot.provider_id.clone(), + format!("{} {}", snapshot.display_name, amount), + ); + } + + let label = crate::commands::compact_tray_status_label(headline_window(snapshot), language); + ( + snapshot.provider_id.clone(), + format!("{} {}", snapshot.display_name, label), + ) +} + +/// Window that headline tray surfaces should label for a provider. +pub(crate) fn headline_window(snapshot: &ProviderUsageSnapshot) -> &RateWindowSnapshot { + if snapshot.provider_id == "codex" { + codex_lane_headline_window(snapshot) + } else { + &snapshot.primary + } +} + +/// Pick the first non-informational Codex lane in session, weekly, monthly order. +pub(crate) fn codex_lane_headline_window(snapshot: &ProviderUsageSnapshot) -> &RateWindowSnapshot { + if !snapshot.primary.is_informational { + return &snapshot.primary; + } + if let Some(ref secondary) = snapshot.secondary + && !secondary.is_informational + { + return secondary; + } + if let Some(ref tertiary) = snapshot.tertiary + && !tertiary.is_informational + { + return tertiary; + } + &snapshot.primary +} + +/// Resolve a stable top/bottom pair while retaining stale saved preferences. +fn pick_stacked_tray_providers<'a>( + healthy: &[&'a ProviderUsageSnapshot], + settings: &Settings, +) -> Option<(&'a ProviderUsageSnapshot, &'a ProviderUsageSnapshot)> { + if healthy.len() < 2 { + return None; + } + + let preferred = |provider_id: Option<&str>| { + provider_id.and_then(|id| { + healthy + .iter() + .copied() + .find(|snapshot| snapshot.provider_id == id) + }) + }; + let preferred_bottom = preferred(settings.stacked_tray_bottom_provider.as_deref()); + let top = preferred(settings.stacked_tray_top_provider.as_deref()).or_else(|| { + healthy.iter().copied().find(|snapshot| { + preferred_bottom.map(|bottom| bottom.provider_id.as_str()) + != Some(snapshot.provider_id.as_str()) + }) + })?; + let bottom = preferred_bottom + .filter(|snapshot| snapshot.provider_id != top.provider_id) + .or_else(|| { + healthy + .iter() + .copied() + .find(|snapshot| snapshot.provider_id != top.provider_id) + })?; + + Some((top, bottom)) +} + +fn pick_tray_provider<'a>( + healthy: &[&'a ProviderUsageSnapshot], + prefer_highest: bool, +) -> Option<&'a ProviderUsageSnapshot> { + if prefer_highest { + healthy.iter().copied().max_by(|a, b| { + a.primary + .used_percent + .partial_cmp(&b.primary.used_percent) + .unwrap_or(std::cmp::Ordering::Equal) + }) + } else { + healthy.first().copied() + } +} + +fn selected_tray_percents( + snapshot: &ProviderUsageSnapshot, + settings: &Settings, +) -> (f64, Option) { + let (selected, companion) = + crate::usage_metric::selected_usage_icon_windows(snapshot, settings); + ( + display_metric_percent(&selected, settings.show_as_used), + companion + .as_ref() + .map(|window| display_metric_percent(window, settings.show_as_used)), + ) +} + +fn display_metric_percent(window: &RateWindowSnapshot, show_as_used: bool) -> f64 { + if window.is_informational { + return 0.0; + } + if window.is_exhausted || window.used_percent >= 100.0 { + return if show_as_used { 100.0 } else { 0.0 }; + } + + let used = window.used_percent.clamp(0.0, 100.0); + if show_as_used { used } else { 100.0 - used } +} + +#[cfg(test)] +#[path = "tray_presentation_tests.rs"] +mod tests; diff --git a/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs b/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs new file mode 100644 index 0000000000..3fce3f03d7 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs @@ -0,0 +1,686 @@ +use super::*; +use codexbar::core::{ProviderId, ProviderStateKind}; + +fn fake_snapshot(id: &str, display_name: &str, used_percent: f64) -> ProviderUsageSnapshot { + fake_snapshot_with(id, display_name, used_percent, None, None, None) +} + +fn fake_snapshot_with( + id: &str, + display_name: &str, + used_percent: f64, + secondary_percent: Option, + tertiary_percent: Option, + cost: Option<(f64, f64)>, +) -> ProviderUsageSnapshot { + let window = |percent: f64| RateWindowSnapshot { + used_percent: percent, + remaining_percent: 100.0 - percent, + window_minutes: None, + resets_at: None, + reset_description: None, + is_exhausted: false, + is_informational: false, + reserve_percent: None, + reserve_description: None, + reserve_will_last_to_reset: false, + reserve_eta_seconds: None, + }; + + ProviderUsageSnapshot { + provider_id: id.into(), + display_name: display_name.into(), + primary: window(used_percent), + primary_label: None, + secondary: secondary_percent.map(window), + secondary_label: None, + model_specific: None, + tertiary: tertiary_percent.map(window), + tertiary_label: None, + extra_rate_windows: Vec::new(), + inventory: Vec::new(), + display_details: Vec::new(), + cost: cost.map(|(used, limit)| crate::commands::CostSnapshotBridge { + used, + limit: Some(limit), + remaining: Some((limit - used).max(0.0)), + currency_code: "USD".to_string(), + currency_symbol: None, + period: "monthly".to_string(), + resets_at: None, + formatted_used: format!("${used:.2}"), + formatted_limit: Some(format!("${limit:.2}")), + balance: None, + formatted_balance: None, + balance_updated_at: None, + account_id: None, + daily: Vec::new(), + always_visible: false, + }), + plan_name: None, + account_email: None, + subscription: None, + source_label: String::new(), + has_successful_claude_cli_quota: false, + updated_at: "2025-01-01T00:00:00Z".into(), + error: None, + error_state: ProviderStateKind::Ready, + pace: None, + account_organization: None, + tray_status_label: None, + fetch_duration_ms: None, + wayfinder_usage: None, + session_equivalent_forecast: None, + } +} + +#[test] +fn single_plan_uses_highest_provider_for_icon_and_summary() { + let settings = Settings { + tray_icon_mode: TrayIconMode::Single, + menu_bar_shows_highest_usage: true, + ..Settings::default() + }; + let snapshots = vec![ + fake_snapshot("codex", "Codex", 30.0), + fake_snapshot("claude", "Claude", 72.0), + ]; + + let plan = TrayPresentationPlan::resolve(&settings, &snapshots); + + assert_eq!( + plan.icon, + TrayIconPlan::Bars { + primary_percent: 72.0, + secondary_percent: None, + has_error: false, + } + ); + assert_eq!( + plan.status_labels(Language::English), + vec![("status_summary".to_string(), "Claude 72%".to_string())] + ); +} + +#[test] +fn single_plan_borrows_selected_snapshot_from_stable_input() { + let settings = Settings { + tray_icon_mode: TrayIconMode::Single, + menu_bar_shows_highest_usage: true, + ..Settings::default() + }; + let snapshots = vec![ + fake_snapshot("codex", "Codex", 30.0), + fake_snapshot("claude", "Claude", 72.0), + ]; + + // `resolve` drops its temporary ordered/healthy vectors before returning. + let plan = TrayPresentationPlan::resolve(&settings, &snapshots); + + assert!(std::ptr::eq(plan.status_rows[0].snapshot, &snapshots[1])); +} + +#[test] +fn per_provider_plan_preserves_configured_order_for_status_rows() { + let settings = Settings { + tray_icon_mode: TrayIconMode::PerProvider, + provider_order: codexbar::settings::normalize_provider_order(&[ + "claude".to_string(), + "codex".to_string(), + ]), + ..Settings::default() + }; + let snapshots = vec![ + fake_snapshot("codex", "Codex", 30.0), + fake_snapshot("claude", "Claude", 72.0), + ]; + + let labels = + TrayPresentationPlan::resolve(&settings, &snapshots).status_labels(Language::English); + + assert_eq!( + labels, + vec![ + ("claude".to_string(), "Claude 72%".to_string()), + ("codex".to_string(), "Codex 30%".to_string()), + ] + ); +} + +#[test] +fn stacked_plan_resolves_distinct_preferences_once() { + let settings = Settings { + tray_icon_mode: TrayIconMode::Stacked, + stacked_tray_top_provider: Some("claude".to_string()), + stacked_tray_bottom_provider: Some("codex".to_string()), + ..Settings::default() + }; + let snapshots = vec![ + fake_snapshot("codex", "Codex", 30.0), + fake_snapshot("claude", "Claude", 72.0), + fake_snapshot("gemini", "Gemini", 44.0), + ]; + + let plan = TrayPresentationPlan::resolve(&settings, &snapshots); + + assert_eq!( + plan.icon, + TrayIconPlan::Stacked { + top_percent: 72.0, + bottom_percent: 30.0, + has_error: false, + } + ); + assert_eq!( + plan.status_labels(Language::English), + vec![ + ("claude".to_string(), "Claude 72%".to_string()), + ("codex".to_string(), "Codex 30%".to_string()), + ] + ); +} + +#[test] +fn stacked_plan_borrows_both_snapshots_from_stable_input() { + let settings = Settings { + tray_icon_mode: TrayIconMode::Stacked, + stacked_tray_top_provider: Some("claude".to_string()), + stacked_tray_bottom_provider: Some("codex".to_string()), + ..Settings::default() + }; + let snapshots = vec![ + fake_snapshot("codex", "Codex", 30.0), + fake_snapshot("claude", "Claude", 72.0), + ]; + + // The plan retains references to the caller-owned snapshots, not the + // temporary vector of references used during selection. + let plan = TrayPresentationPlan::resolve(&settings, &snapshots); + + assert!(std::ptr::eq(plan.status_rows[0].snapshot, &snapshots[1])); + assert!(std::ptr::eq(plan.status_rows[1].snapshot, &snapshots[0])); +} + +#[test] +fn stacked_plan_falls_back_around_stale_and_duplicate_preferences() { + let settings = Settings { + tray_icon_mode: TrayIconMode::Stacked, + stacked_tray_top_provider: Some("missing".to_string()), + stacked_tray_bottom_provider: Some("claude".to_string()), + ..Settings::default() + }; + let snapshots = vec![ + fake_snapshot("codex", "Codex", 30.0), + fake_snapshot("claude", "Claude", 72.0), + ]; + + let plan = TrayPresentationPlan::resolve(&settings, &snapshots); + + assert_eq!( + plan.icon, + TrayIconPlan::Stacked { + top_percent: 30.0, + bottom_percent: 72.0, + has_error: false, + } + ); + assert_eq!(plan.status_rows[0].snapshot.provider_id, "codex"); + assert_eq!(plan.status_rows[1].snapshot.provider_id, "claude"); +} + +#[test] +fn one_provider_stacked_mode_falls_back_to_single_provider_bars() { + let settings = Settings { + tray_icon_mode: TrayIconMode::Stacked, + ..Settings::default() + }; + let snapshots = vec![fake_snapshot_with( + "codex", + "Codex", + 30.0, + Some(65.0), + None, + None, + )]; + + let plan = TrayPresentationPlan::resolve(&settings, &snapshots); + + assert_eq!( + plan.icon, + TrayIconPlan::Bars { + primary_percent: 65.0, + secondary_percent: Some(30.0), + has_error: false, + } + ); + assert_eq!(plan.status_rows.len(), 1); +} + +#[test] +fn one_healthy_provider_never_uses_stacked_renderer() { + let settings = Settings { + tray_icon_mode: TrayIconMode::Stacked, + menu_bar_shows_percent: true, + ..Settings::default() + }; + let healthy = fake_snapshot("codex", "Codex", 30.0); + let mut failed = fake_snapshot("claude", "Claude", 72.0); + failed.error = Some("offline".to_string()); + let snapshots = vec![healthy, failed]; + + let plan = TrayPresentationPlan::resolve(&settings, &snapshots); + + assert_eq!( + plan.icon, + TrayIconPlan::Percent { + percent: 30.0, + has_error: false, + } + ); + assert_eq!(plan.status_rows.len(), 1); + assert_eq!(plan.status_rows[0].snapshot.provider_id, "codex"); +} + +#[test] +fn all_errors_produce_error_styled_zero_percent_plan() { + let settings = Settings { + menu_bar_shows_percent: true, + ..Settings::default() + }; + let mut snapshot = fake_snapshot("codex", "Codex", 30.0); + snapshot.error = Some("offline".to_string()); + let snapshots = vec![snapshot]; + + let plan = TrayPresentationPlan::resolve(&settings, &snapshots); + + assert_eq!( + plan.icon, + TrayIconPlan::Percent { + percent: 0.0, + has_error: true, + } + ); + assert!(plan.status_rows.is_empty()); +} + +#[test] +fn plan_uses_selected_metric_and_remaining_display_mode() { + let mut settings = Settings { + show_as_used: false, + ..Settings::default() + }; + settings.set_provider_metric(ProviderId::Cursor, MetricPreference::ExtraUsage); + let snapshots = vec![fake_snapshot_with( + "cursor", + "Cursor", + 10.0, + Some(20.0), + Some(72.0), + Some((15.0, 100.0)), + )]; + + let plan = TrayPresentationPlan::resolve(&settings, &snapshots); + + assert_eq!( + plan.icon, + TrayIconPlan::Bars { + primary_percent: 85.0, + secondary_percent: Some(80.0), + has_error: false, + } + ); +} + +#[test] +fn render_icon_delegates_to_resolved_stacked_renderer() { + let settings = Settings { + tray_icon_mode: TrayIconMode::Stacked, + stacked_tray_top_provider: Some("claude".to_string()), + stacked_tray_bottom_provider: Some("codex".to_string()), + ..Settings::default() + }; + let snapshots = vec![ + fake_snapshot("codex", "Codex", 40.0), + fake_snapshot("claude", "Claude", 72.0), + ]; + let plan = TrayPresentationPlan::resolve(&settings, &snapshots); + + assert_eq!( + plan.render_icon(), + render_stacked_bar_icon_rgba(72.0, 40.0, false) + ); +} + +#[test] +fn codex_headline_skips_informational_primary() { + let mut snapshot = fake_snapshot_with("codex", "Codex", 0.0, Some(25.0), Some(30.0), None); + snapshot.primary.is_informational = true; + + assert_eq!(codex_lane_headline_window(&snapshot).used_percent, 25.0); +} +fn fake_extra_window(percent: f64) -> crate::commands::NamedRateWindowSnapshot { + crate::commands::NamedRateWindowSnapshot { + id: "additional_budget".to_string(), + title: "Additional Budget".to_string(), + fallback_lane: false, + window: crate::commands::RateWindowSnapshot { + used_percent: percent, + remaining_percent: 100.0 - percent, + window_minutes: None, + resets_at: None, + reset_description: None, + is_exhausted: false, + is_informational: false, + reserve_percent: None, + reserve_description: None, + reserve_will_last_to_reset: false, + reserve_eta_seconds: None, + }, + } +} + +#[test] +fn selected_tray_percent_uses_cursor_extra_usage_cost() { + let mut settings = Settings::default(); + settings.set_provider_metric(ProviderId::Cursor, MetricPreference::ExtraUsage); + let snapshot = fake_snapshot_with( + "cursor", + "Cursor", + 10.0, + Some(20.0), + Some(72.0), + Some((15.0, 100.0)), + ); + + let (primary, secondary) = selected_tray_percents(&snapshot, &settings); + + assert_eq!(primary, 15.0); + assert_eq!(secondary, Some(20.0)); +} + +#[test] +fn selected_tray_percent_tracks_extra_rate_window() { + let mut settings = Settings::default(); + settings.set_provider_metric(ProviderId::Copilot, MetricPreference::ExtraUsage); + let mut snapshot = fake_snapshot("copilot", "Copilot", 20.0); + snapshot.extra_rate_windows.push(fake_extra_window(42.0)); + + let (primary, secondary) = selected_tray_percents(&snapshot, &settings); + + assert_eq!(primary, 42.0); + assert_eq!(secondary, None); +} + +#[test] +fn copilot_automatic_tracks_highest_extra_rate_window() { + let settings = Settings::default(); + let mut snapshot = fake_snapshot("copilot", "Copilot", 20.0); + snapshot.extra_rate_windows.push(fake_extra_window(42.0)); + + let (primary, _) = selected_tray_percents(&snapshot, &settings); + + assert_eq!(primary, 42.0); +} + +#[test] +fn selected_tray_percent_respects_remaining_display_mode() { + let mut settings = Settings { + show_as_used: false, + ..Settings::default() + }; + settings.set_provider_metric(ProviderId::Cursor, MetricPreference::ExtraUsage); + let snapshot = fake_snapshot_with( + "cursor", + "Cursor", + 10.0, + Some(20.0), + Some(72.0), + Some((15.0, 100.0)), + ); + + let (primary, secondary) = selected_tray_percents(&snapshot, &settings); + + assert_eq!(primary, 85.0); + assert_eq!(secondary, Some(80.0)); +} + +#[test] +fn exhausted_automatic_window_never_renders_as_remaining_progress() { + let mut settings = Settings { + show_as_used: false, + ..Settings::default() + }; + let mut snapshot = fake_snapshot_with( + "opencodego", + "OpenCode Go", + 20.0, + Some(60.0), + Some(40.0), + None, + ); + snapshot + .tertiary + .as_mut() + .expect("monthly quota") + .is_exhausted = true; + + let (remaining, _) = selected_tray_percents(&snapshot, &settings); + assert_eq!(remaining, 0.0); + + settings.show_as_used = true; + let (used, _) = selected_tray_percents(&snapshot, &settings); + assert_eq!(used, 100.0); +} + +#[test] +fn full_automatic_window_without_exhausted_flag_has_zero_remaining_progress() { + let mut settings = Settings { + show_as_used: false, + ..Settings::default() + }; + let mut snapshot = fake_snapshot_with( + "opencodego", + "OpenCode Go", + 20.0, + Some(60.0), + Some(100.0), + None, + ); + snapshot + .tertiary + .as_mut() + .expect("monthly quota") + .is_exhausted = false; + + let (remaining, _) = selected_tray_percents(&snapshot, &settings); + assert_eq!(remaining, 0.0); + + settings.show_as_used = true; + let (used, _) = selected_tray_percents(&snapshot, &settings); + assert_eq!(used, 100.0); +} + +#[test] +fn missing_automatic_window_does_not_look_like_available_remaining_progress() { + let settings = Settings { + show_as_used: false, + ..Settings::default() + }; + let mut snapshot = fake_snapshot_with("opencodego", "OpenCode Go", 0.0, None, None, None); + snapshot.primary.is_informational = true; + + let (remaining, _) = selected_tray_percents(&snapshot, &settings); + + assert_eq!(remaining, 0.0); +} + +#[test] +fn selected_tray_percent_falls_back_when_extra_usage_missing() { + let mut settings = Settings::default(); + settings.set_provider_metric(ProviderId::Cursor, MetricPreference::ExtraUsage); + let snapshot = fake_snapshot_with("cursor", "Cursor", 10.0, Some(72.0), None, None); + + let (primary, _) = selected_tray_percents(&snapshot, &settings); + + assert_eq!(primary, 72.0); +} + +#[test] +fn single_meaningful_secondary_quota_uses_full_single_meter() { + let settings = Settings::default(); + let mut snapshot = fake_snapshot_with("claude", "Claude", 0.0, Some(42.0), None, None); + snapshot.primary.is_informational = true; + + let (primary, secondary) = selected_tray_percents(&snapshot, &settings); + + assert_eq!(primary, 42.0); + assert_eq!(secondary, None); +} + +#[test] +fn selected_secondary_quota_is_not_duplicated_when_tertiary_is_meaningful() { + let settings = Settings::default(); + let mut snapshot = fake_snapshot_with("claude", "Claude", 0.0, Some(42.0), Some(30.0), None); + snapshot.primary.is_informational = true; + + let (primary, secondary) = selected_tray_percents(&snapshot, &settings); + + assert_eq!(primary, 42.0); + assert_eq!(secondary, Some(30.0)); +} + +#[test] +fn two_meaningful_quotas_keep_two_meter_layout() { + let mut settings = Settings::default(); + settings.set_provider_metric(ProviderId::Cursor, MetricPreference::Session); + let snapshot = fake_snapshot_with("cursor", "Cursor", 15.0, Some(40.0), None, None); + + let (primary, secondary) = selected_tray_percents(&snapshot, &settings); + + assert_eq!(primary, 15.0); + assert_eq!(secondary, Some(40.0)); +} + +#[test] +fn informational_primary_skips_session_and_automatic_phantom_zero() { + let mut settings = Settings::default(); + settings.set_provider_metric(ProviderId::Claude, MetricPreference::Session); + let mut snapshot = fake_snapshot_with("claude", "Claude", 0.0, Some(42.0), None, None); + snapshot.primary.is_informational = true; + + // Session preference must not paint the synthetic 0% primary; + // it falls through to Automatic which prefers weekly (42%). + let (primary, _) = selected_tray_percents(&snapshot, &settings); + assert_eq!(primary, 42.0); + assert_ne!(primary, 0.0); + + // Automatic also prefers weekly over informational primary. + settings.set_provider_metric(ProviderId::Claude, MetricPreference::Automatic); + let (primary, _) = selected_tray_percents(&snapshot, &settings); + assert_eq!(primary, 42.0); +} + +#[test] +fn claude_automatic_prefers_weekly_when_model_exhausted() { + let settings = Settings::default(); + let mut snapshot = fake_snapshot_with("claude", "Claude", 40.0, Some(22.0), None, None); + snapshot.model_specific = Some(crate::commands::RateWindowSnapshot { + used_percent: 100.0, + remaining_percent: 0.0, + window_minutes: Some(10080), + resets_at: None, + reset_description: None, + is_exhausted: true, + is_informational: false, + reserve_percent: None, + reserve_description: None, + reserve_will_last_to_reset: false, + reserve_eta_seconds: None, + }); + + let (primary, _) = selected_tray_percents(&snapshot, &settings); + assert_eq!(primary, 22.0); + + // Explicit model override is untouched. + let mut overridden = settings.clone(); + overridden.set_provider_metric(ProviderId::Claude, MetricPreference::Model); + let (primary, _) = selected_tray_percents(&snapshot, &overridden); + assert_eq!(primary, 100.0); +} + +#[test] +fn automatic_prefers_exhausted_weekly_over_low_session() { + let settings = Settings::default(); + let snapshot = fake_snapshot_with("codex", "Codex", 20.0, Some(100.0), None, None); + + let (primary, _) = selected_tray_percents(&snapshot, &settings); + assert_eq!(primary, 100.0); + + // Explicit session override still wins. + let mut overridden = settings.clone(); + overridden.set_provider_metric(ProviderId::Codex, MetricPreference::Session); + let (primary, _) = selected_tray_percents(&snapshot, &overridden); + assert_eq!(primary, 20.0); +} + +#[test] +fn automatic_picks_highest_among_model_and_extra_windows() { + let settings = Settings::default(); + let mut snapshot = fake_snapshot_with("gemini", "Gemini", 10.0, Some(30.0), Some(40.0), None); + snapshot.model_specific = Some(crate::commands::RateWindowSnapshot { + used_percent: 55.0, + remaining_percent: 45.0, + window_minutes: None, + resets_at: None, + reset_description: None, + is_exhausted: false, + is_informational: false, + reserve_percent: None, + reserve_description: None, + reserve_will_last_to_reset: false, + reserve_eta_seconds: None, + }); + snapshot.extra_rate_windows.push(fake_extra_window(90.0)); + + let (primary, _) = selected_tray_percents(&snapshot, &settings); + assert_eq!(primary, 90.0); +} + +#[test] +fn f5_headline_prefers_non_informational_primary() { + let snapshot = fake_snapshot_with("codex", "Codex", 50.0, Some(20.0), Some(30.0), None); + let headline = codex_lane_headline_window(&snapshot); + assert!((headline.used_percent - 50.0).abs() < f64::EPSILON); +} + +#[test] +fn f5_headline_falls_back_to_secondary_when_primary_informational() { + let mut snapshot = fake_snapshot_with("codex", "Codex", 0.0, Some(25.0), Some(30.0), None); + snapshot.primary.is_informational = true; + let headline = codex_lane_headline_window(&snapshot); + assert!((headline.used_percent - 25.0).abs() < f64::EPSILON); +} + +#[test] +fn f5_headline_falls_back_to_tertiary_when_primary_and_secondary_informational() { + let mut snapshot = fake_snapshot_with("codex", "Codex", 0.0, Some(0.0), Some(35.0), None); + snapshot.primary.is_informational = true; + snapshot.secondary.as_mut().unwrap().is_informational = true; + let headline = codex_lane_headline_window(&snapshot); + assert!((headline.used_percent - 35.0).abs() < f64::EPSILON); +} + +#[test] +fn f5_headline_returns_primary_when_all_informational() { + let mut snapshot = fake_snapshot_with("codex", "Codex", 0.0, Some(0.0), Some(0.0), None); + snapshot.primary.is_informational = true; + if let Some(sec) = &mut snapshot.secondary { + sec.is_informational = true; + } + if let Some(ter) = &mut snapshot.tertiary { + ter.is_informational = true; + } + let headline = codex_lane_headline_window(&snapshot); + // Falls back to primary (the placeholder) when all are informational. + assert!(headline.is_informational); +} diff --git a/apps/desktop-tauri/src/App.test.tsx b/apps/desktop-tauri/src/App.test.tsx index 42d55a7c27..10e46694eb 100644 --- a/apps/desktop-tauri/src/App.test.tsx +++ b/apps/desktop-tauri/src/App.test.tsx @@ -11,6 +11,13 @@ const webviewWindowMocks = vi.hoisted(() => ({ label: "main", })); +const surfaceMocks = vi.hoisted(() => ({ + snapshot: { + mode: "hidden", + target: { kind: "summary" }, + } as { mode: string; target: Record }, +})); + vi.mock("@tauri-apps/api/webviewWindow", () => ({ getCurrentWebviewWindow: () => ({ label: webviewWindowMocks.label }), })); @@ -39,9 +46,6 @@ vi.mock("@tauri-apps/api/event", () => eventMocks); vi.mock("./surfaces/TrayPanel", () => ({ default: () =>
, })); -vi.mock("./surfaces/PopOutPanel", () => ({ - default: () =>
, -})); vi.mock("./surfaces/Settings", () => ({ default: () =>
, })); @@ -50,10 +54,7 @@ vi.mock("./floatbar/FloatBar", () => ({ })); vi.mock("./hooks/useSurfaceSnapshot", () => ({ - useSurfaceSnapshot: () => ({ - mode: "hidden", - target: { kind: "summary" }, - }), + useSurfaceSnapshot: () => surfaceMocks.snapshot, })); import App from "./App"; @@ -145,6 +146,7 @@ describe("App window-label routing", () => { beforeEach(() => { vi.clearAllMocks(); webviewWindowMocks.label = "main"; + surfaceMocks.snapshot = { mode: "hidden", target: { kind: "summary" } }; tauriMocks.getBootstrapState.mockResolvedValue(bootstrap()); tauriMocks.getSettingsSnapshot.mockResolvedValue(settings()); tauriMocks.checkForUpdates.mockResolvedValue({ @@ -173,7 +175,6 @@ describe("App window-label routing", () => { await waitFor(() => { expect(queryByTestId("surface-tray-panel")).not.toBeNull(); }); - expect(queryByTestId("surface-pop-out-panel")).toBeNull(); expect(queryByTestId("surface-settings")).toBeNull(); expect(queryByTestId("surface-float-bar")).toBeNull(); }); @@ -201,8 +202,8 @@ describe("App window-label routing", () => { }); it("does not route the shared main window to TrayPanel while hidden", async () => { - // main's surface-mode machine only ever holds Hidden/PopOut/Settings - // post-refactor — it can never report "trayPanel" — so the + // main's surface-mode machine only holds Hidden/Settings outside proof + // mode — it never reports "trayPanel" — so the // isFlyoutWindow()/isSettingsWindow()/isFloatBarWindow() checks all miss // and control falls through to SurfaceRouter, which renders nothing for // "hidden". @@ -226,4 +227,33 @@ describe("App window-label routing", () => { expect(queryByTestId("surface-tray-panel")).toBeNull(); expect(container.firstChild).toBeNull(); }); + + it("renders the tray panel, never a legacy layout, for a stale popOut mode", async () => { + // The legacy PopOut layout is retired: the backend rejects popOut, and + // the router falls back to the tray panel if a stale snapshot reports it. + webviewWindowMocks.label = "main"; + surfaceMocks.snapshot = { mode: "popOut", target: { kind: "dashboard" } }; + + const { queryByTestId } = render(); + + await waitFor(() => { + expect(queryByTestId("surface-tray-panel")).not.toBeNull(); + }); + expect(queryByTestId("surface-settings")).toBeNull(); + }); + + it("does not open a second window from the global-shortcut event", async () => { + // shortcut_bridge::plugin's native handler toggles the tray-panel flyout + // for every registered shortcut; the frontend no longer listens. + webviewWindowMocks.label = "main"; + + render(); + + await waitFor(() => { + expect(tauriMocks.getBootstrapState).toHaveBeenCalled(); + }); + const events = eventMocks.listen.mock.calls.map(([event]) => event); + expect(events).not.toContain("global-shortcut-triggered"); + expect(tauriMocks.setSurfaceMode).not.toHaveBeenCalled(); + }); }); diff --git a/apps/desktop-tauri/src/App.tsx b/apps/desktop-tauri/src/App.tsx index aa775058ad..4b39c0286f 100644 --- a/apps/desktop-tauri/src/App.tsx +++ b/apps/desktop-tauri/src/App.tsx @@ -6,7 +6,6 @@ import { downloadUpdate, getBootstrapState, getSettingsSnapshot, - setSurfaceMode, } from "./lib/tauri"; import { useSurfaceSnapshot } from "./hooks/useSurfaceSnapshot"; import { useTheme } from "./hooks/useTheme"; @@ -19,7 +18,6 @@ import type { SurfaceSnapshot } from "./hooks/useSurfaceSnapshot"; import { useDeepSeekPricingStatus } from "./hooks/useDeepSeekPricingStatus"; const Settings = lazy(() => import("./surfaces/Settings")); -const PopOutPanel = lazy(() => import("./surfaces/PopOutPanel")); const FloatBar = lazy(() => import("./floatbar/FloatBar")); function SurfaceFallback() { @@ -100,13 +98,10 @@ function AppInner() { .catch(() => {}); }, 2_000); - // Listen for user-registered global shortcut events from the - // `register_global_shortcut` command. The persistent shortcut (bound via - // shortcut_bridge::plugin) already opens the PopOut dashboard natively; - // this listener is the fallback for ad-hoc capture-mode registrations. - const unlistenPromise = listen("global-shortcut-triggered", () => { - void setSurfaceMode("popOut", { kind: "dashboard" }).catch(() => {}); - }); + // Global shortcuts (the persisted one and ad-hoc capture registrations) + // are handled natively: shortcut_bridge::plugin's handler toggles the + // tray-panel flyout for every registered shortcut, so no frontend + // listener opens a second window here. const unlistenSettingsChangePromise = isSettingsWindow() ? listen("settings-change-tab", () => { @@ -137,7 +132,6 @@ function AppInner() { return () => { cancelled = true; - void unlistenPromise.then((unlisten) => unlisten()).catch(() => {}); void unlistenSettingsChangePromise .then((unlisten) => unlisten?.()) .catch(() => {}); @@ -183,6 +177,7 @@ function AppInner() { } // Detached flyout ("Pop Out Dashboard") window — render TrayPanel directly. + // This is the only dashboard layout; the legacy PopOut layout is retired. // TrayPanel is statically imported (not lazy), so no Suspense boundary is // needed here, unlike the other detached-window branches above. if (isFlyoutWindow()) { @@ -202,19 +197,6 @@ function SurfaceRouter({ switch (surface.mode) { case "hidden": return null; - case "trayPanel": - return ; - case "popOut": { - const providerId = - surface.target.kind === "provider" - ? surface.target.providerId - : undefined; - return ( - }> - - - ); - } case "settings": return ( }> diff --git a/apps/desktop-tauri/src/components/MenuSurface.tsx b/apps/desktop-tauri/src/components/MenuSurface.tsx index b725a4bb44..f9b08c48b0 100644 --- a/apps/desktop-tauri/src/components/MenuSurface.tsx +++ b/apps/desktop-tauri/src/components/MenuSurface.tsx @@ -1,12 +1,6 @@ import type { CSSProperties, ReactNode } from "react"; import { useLocale } from "../hooks/useLocale"; -export interface MenuSurfaceAction { - icon: string; - title: string; - onClick: () => void; -} - export interface MenuFooterRow { icon: string; label: string; @@ -15,13 +9,6 @@ export interface MenuFooterRow { } interface MenuSurfaceProps { - variant: "tray" | "popout"; - /** Optional window chrome (e.g. the PopOut title bar) rendered flush at the - * top. A slot keeps this shared content container free of window APIs. */ - titleBar?: ReactNode; - onRefresh: () => void; - isRefreshing: boolean; - actions: MenuSurfaceAction[]; summary?: ReactNode; banner?: ReactNode; /** Non-button content rendered in the footer nav BEFORE the mapped @@ -36,7 +23,8 @@ interface MenuSurfaceProps { } /** - * Flush, compact container that both `TrayPanel` and `PopOutPanel` consume. + * Flush, compact container for the tray panel (`TrayPanel`), the only + * dashboard layout. It renders in the tray-panel flyout window. * * Mirrors the upstream macOS `MenuContent`: a narrow VStack(spacing: 8) * inside an NSMenu-like popover (310pt wide, vertical 6 / horizontal 10 @@ -44,11 +32,6 @@ interface MenuSurfaceProps { * cards (`MenuCard`) — one per enabled provider — exactly like upstream. */ export default function MenuSurface({ - variant, - titleBar, - onRefresh, - isRefreshing, - actions, summary, banner, footerLead, @@ -58,8 +41,7 @@ export default function MenuSurface({ }: MenuSurfaceProps) { const { t } = useLocale(); return ( -
- {titleBar} +
{banner} {summary}
{children}
diff --git a/apps/desktop-tauri/src/components/PopOutTitleBar.test.tsx b/apps/desktop-tauri/src/components/PopOutTitleBar.test.tsx deleted file mode 100644 index c78d765606..0000000000 --- a/apps/desktop-tauri/src/components/PopOutTitleBar.test.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const windowMocks = vi.hoisted(() => { - const minimize = vi.fn().mockResolvedValue(undefined); - const toggleMaximize = vi.fn().mockResolvedValue(undefined); - const close = vi.fn().mockResolvedValue(undefined); - const isMaximized = vi.fn().mockResolvedValue(false); - const onResized = vi.fn().mockResolvedValue(() => {}); - return { - minimize, - toggleMaximize, - close, - isMaximized, - onResized, - getCurrentWindow: vi.fn(() => ({ - minimize, - toggleMaximize, - close, - isMaximized, - onResized, - })), - }; -}); - -vi.mock("@tauri-apps/api/window", () => windowMocks); -// Bypass the LocaleProvider: t(key) returns the key, so aria-labels are the -// key names (e.g. "WindowMinimize"). -vi.mock("../hooks/useLocale", () => ({ - useLocale: () => ({ t: (key: string) => key, language: "english" }), -})); - -import PopOutTitleBar from "./PopOutTitleBar"; - -describe("PopOutTitleBar", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("wires the minimize, maximize and close controls to the native window", async () => { - render(); - - fireEvent.click(await screen.findByRole("button", { name: "WindowMinimize" })); - fireEvent.click(screen.getByRole("button", { name: "WindowMaximize" })); - fireEvent.click(screen.getByRole("button", { name: "WindowClose" })); - - expect(windowMocks.minimize).toHaveBeenCalledTimes(1); - expect(windowMocks.toggleMaximize).toHaveBeenCalledTimes(1); - expect(windowMocks.close).toHaveBeenCalledTimes(1); - }); - - it("announces Restore on the middle control once the window is maximized", async () => { - windowMocks.isMaximized.mockResolvedValueOnce(true); - render(); - - expect( - await screen.findByRole("button", { name: "WindowRestore" }), - ).toBeTruthy(); - }); - - it("toggles maximize when the title bar is double-clicked", () => { - const { container } = render(); - const titleBar = container.querySelector(".popout-titlebar") as HTMLElement; - - fireEvent.doubleClick(titleBar); - - expect(windowMocks.toggleMaximize).toHaveBeenCalledTimes(1); - }); -}); diff --git a/apps/desktop-tauri/src/components/PopOutTitleBar.tsx b/apps/desktop-tauri/src/components/PopOutTitleBar.tsx deleted file mode 100644 index 37d7ac629e..0000000000 --- a/apps/desktop-tauri/src/components/PopOutTitleBar.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { useEffect, useState } from "react"; -import { getCurrentWindow } from "@tauri-apps/api/window"; -import { useLocale } from "../hooks/useLocale"; - -/** - * Draggable title bar for the PopOut window mode. The app runs borderless (no - * native caption), so the window is moved via this frontend drag region, - * mirroring the detached Settings window. Controls map to the native window; - * close routes through Rust's CloseRequested handler, which hides the window - * back to the tray instead of quitting. - * - * This owns the desktop window-chrome concerns (Tauri window APIs, min/max/ - * close, drag region) so the shared `MenuSurface` content container stays a - * pure presentation component with no window dependency. - */ -export default function PopOutTitleBar() { - const { t } = useLocale(); - const [maximized, setMaximized] = useState(false); - - // Track the maximized state so the middle control (and the title-bar - // double-click) toggle between Maximize and Restore, and so the button's - // label/glyph announce the correct action. - useEffect(() => { - const win = getCurrentWindow(); - let active = true; - let unlisten: (() => void) | undefined; - const sync = () => { - win - .isMaximized() - .then((value) => { - if (active) setMaximized(value); - }) - .catch(() => {}); - }; - sync(); - win - .onResized(sync) - .then((fn) => { - if (active) { - unlisten = fn; - } else { - fn(); - } - }) - .catch(() => {}); - return () => { - active = false; - unlisten?.(); - }; - }, []); - - const maximizeLabel = maximized ? t("WindowRestore") : t("WindowMaximize"); - - return ( -
{ - // Double-clicking the title bar toggles maximize/restore, like a native - // caption — but not when the double-click lands on a window control. - if ( - (event.target as HTMLElement).closest(".popout-titlebar__controls") - ) { - return; - } - void getCurrentWindow().toggleMaximize(); - }} - > - - {t("AppName")} - -
- - -
-
- ); -} diff --git a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-crof.svg b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-crof.svg deleted file mode 100644 index fdde018b8f..0000000000 --- a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-crof.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.test.ts b/apps/desktop-tauri/src/components/providers/providerIcons.test.ts index 27c677024b..a204e1155a 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.test.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.test.ts @@ -8,4 +8,8 @@ describe("provider icon registry", () => { expect(PROVIDER_ICON_REGISTRY[id], id).toBeDefined(); } }); + + it("does not expose the retired Crof provider", () => { + expect(PROVIDER_ICON_REGISTRY).not.toHaveProperty("crof"); + }); }); diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.ts b/apps/desktop-tauri/src/components/providers/providerIcons.ts index 2fdc4380d7..7c65e9922e 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.ts @@ -14,7 +14,6 @@ import coderabbit from "./icons/ProviderIcon-coderabbit.svg?raw"; import codex from "./icons/ProviderIcon-codex.svg?raw"; import commandcode from "./icons/ProviderIcon-commandcode.svg?raw"; import copilot from "./icons/ProviderIcon-copilot.svg?raw"; -import crof from "./icons/ProviderIcon-crof.svg?raw"; import crossmodel from "./icons/ProviderIcon-crossmodel.svg?raw"; import cursor from "./icons/ProviderIcon-cursor.svg?raw"; import deepgram from "./icons/ProviderIcon-deepgram.svg?raw"; @@ -102,7 +101,6 @@ const RAW: Record = { codex: tint(codex), commandcode: tint(commandcode), copilot: tint(copilot), - crof: tint(crof), crossmodel: tint(crossmodel), cursor: tint(cursor), deepgram: tint(deepgram), @@ -187,7 +185,10 @@ export const PROVIDER_ICON_REGISTRY: Record = { gemini: { id: "gemini", brandColor: "#ab87ea", fallbackLetter: "✦", svgPath: RAW.gemini }, grok: { id: "grok", brandColor: "#111827", fallbackLetter: "G", svgPath: RAW.grok }, groq: { id: "groq", brandColor: "#f55036", fallbackLetter: "G", svgPath: RAW.groq }, + bifrost: { id: "bifrost", brandColor: "#33c09e", fallbackLetter: "B" }, + gitkraken: { id: "gitkraken", brandColor: "#179287", fallbackLetter: "G" }, huggingface: { id: "huggingface", brandColor: "#ffd21e", fallbackLetter: "H", svgPath: RAW.huggingface }, + hyper: { id: "hyper", brandColor: "#ff60ff", fallbackLetter: "H" }, helmcode: { id: "helmcode", brandColor: "#4f46e5", fallbackLetter: "H" }, v0: { id: "v0", brandColor: "#111827", fallbackLetter: "V" }, typesafe: { id: "typesafe", brandColor: "#2563eb", fallbackLetter: "T" }, @@ -221,7 +222,6 @@ export const PROVIDER_ICON_REGISTRY: Record = { mimo: { id: "mimo", brandColor: "#ff6900", fallbackLetter: "M", svgPath: RAW.mimo }, doubao: { id: "doubao", brandColor: "#2563eb", fallbackLetter: "D", svgPath: RAW.doubao }, commandcode: { id: "commandcode", brandColor: "#44ff00", fallbackLetter: "C", svgPath: RAW.commandcode }, - crof: { id: "crof", brandColor: "#7c3aed", fallbackLetter: "C", svgPath: RAW.crof }, crossmodel: { id: "crossmodel", brandColor: "#c084fc", fallbackLetter: "X", svgPath: RAW.crossmodel }, qoder: { id: "qoder", brandColor: "#2563eb", fallbackLetter: "Q", svgPath: RAW.qoder }, replicate: { id: "replicate", brandColor: "#000000", fallbackLetter: "R", svgPath: RAW.replicate }, diff --git a/apps/desktop-tauri/src/hooks/useSettings.ts b/apps/desktop-tauri/src/hooks/useSettings.ts index cadeddb20f..84244d4fa5 100644 --- a/apps/desktop-tauri/src/hooks/useSettings.ts +++ b/apps/desktop-tauri/src/hooks/useSettings.ts @@ -40,10 +40,10 @@ export function useSettings(initial: SettingsSnapshot): UseSettingsReturn { }, [initial]); // Live-sync when settings change in ANOTHER window. The detached Settings - // window and the main/PopOut window are separate webviews with separate + // window and the tray-panel flyout are separate webviews with separate // React state, so the in-window CustomEvent below never reaches them. Rust // broadcasts "settings-changed" after every persisted update; re-fetch the - // snapshot so this surface (e.g. the PopOut window scale) re-renders live. + // snapshot so this surface (e.g. the tray zoom) re-renders live. useEffect(() => { let active = true; let unlisten: (() => void) | undefined; diff --git a/apps/desktop-tauri/src/hooks/useTrayPanelController.ts b/apps/desktop-tauri/src/hooks/useTrayPanelController.ts index c47a506e32..35d4260449 100644 --- a/apps/desktop-tauri/src/hooks/useTrayPanelController.ts +++ b/apps/desktop-tauri/src/hooks/useTrayPanelController.ts @@ -10,7 +10,6 @@ import { quitApp as quitApplication, reorderProviders, setFlyoutSize, - setSurfaceMode, updateSettings, } from "../lib/tauri"; import { useProviders } from "./useProviders"; @@ -30,9 +29,7 @@ const TRAY_INITIAL_REFRESH_DELAY_MS = 250; const DENSE_OVERVIEW_THRESHOLD = 32; // ── Tray flyout zoom (footer slider, above Refresh) ─────────────────── -// PopOut window mode has its own independent windowScalePercent (webview -// setZoom) — this is a separate setting/control for the tray flyout only, -// applied via CSS `zoom` on the MenuSurface root (see TrayPanel render). +// Applied via CSS `zoom` on the MenuSurface root (see TrayPanel render). export const TRAY_SCALE_MIN = 100; export const TRAY_SCALE_MAX = 200; export const TRAY_SCALE_STEP = 5; @@ -248,7 +245,7 @@ export function useTrayPanelController(state: BootstrapState) { // window (see App.tsx's isFlyoutWindow() routing) — it is no longer a // state of the shared `main` window's surface-mode machine. The old // `useSurfaceMode() === "trayPanel"` check would be permanently false - // here (that machine now only tracks Hidden/PopOut/Settings on `main`), + // here (outside proof mode that machine only tracks Hidden/Settings), // which would silently gate off the fixed-size restore + reveal below // (useTrayPanelLayout's `isOpen` gate) — a user-resized flyout would never // reveal itself. Hardcoded true: being mounted IS "the flyout is open". @@ -282,9 +279,6 @@ export function useTrayPanelController(state: BootstrapState) { void getCurrentWindow().close(); }); }, []); - const openPopOut = useCallback(() => { - setSurfaceMode("popOut", { kind: "dashboard" }); - }, []); const openAbout = useCallback(() => { void openSettingsWindow("about").finally(() => { void getCurrentWindow().close(); @@ -294,10 +288,6 @@ export function useTrayPanelController(state: BootstrapState) { void quitApplication(); }, []); - const headerActions = [ - { icon: "⧉", title: t("TooltipPopOut"), onClick: openPopOut }, - ]; - const footerRows: MenuFooterRow[] = [ { icon: "↻", label: t("ActionRefresh"), shortcut: "Ctrl+R", onClick: refresh }, { icon: "⚙", label: t("MenuSettings"), shortcut: "Ctrl+,", onClick: openSettings }, @@ -379,7 +369,6 @@ export function useTrayPanelController(state: BootstrapState) { useWideColumns, layoutReady, requestLayout, - headerActions, footerRows, updateState, checkNow, diff --git a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx index 7dbf6d6f7d..1e70c015ff 100644 --- a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx +++ b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx @@ -258,7 +258,7 @@ describe("useTrayPanelLayout sizing", () => { await nudgePass(result, 417, "421px"); // → 421 → 526 phys expect(lastResize()).toEqual({ width: 328, height: 421 }); expect(surface.style.maxHeight).toBe("421px"); - }); + }, 30_000); // 8 bounded 3s settling passes + 3s readiness can exceed Vitest's 5s default. it("reconciles to the applied physical frame after an OS snap (no churn, no cycle)", async () => { // Deliberate 5-physical snap: requesting 539 logical (→674 phys) yields an diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 195620e3bf..0ad26af95b 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -224,7 +224,6 @@ export const ALL_LOCALE_KEYS = [ "StatusLabel", "TrayOpenCodexBar", "TrayPopOutDashboard", - "TrayShowWindow", "TrayShowFloatBar", "TrayRefreshAll", "TrayProviders", @@ -416,6 +415,7 @@ export const ALL_LOCALE_KEYS = [ "UsageSpendHourlyActivity", "UsageSpendRequests", "UsageSpendTokens", + "UsageSpendKnownSubtotal", "UsageSpendAllTimeHistory", "UsageSpendCustomPricing", "OverviewSpendTitle", @@ -541,6 +541,9 @@ export const ALL_LOCALE_KEYS = [ "TrayIconModeHelper", "TrayIconModeSingle", "TrayIconModePerProvider", + "TrayIconModeStacked", + "StackedTrayTopProvider", + "StackedTrayBottomProvider", "ShowProviderIcons", "ShowProviderIconsHelper", "PreferHighestUsage", @@ -552,13 +555,9 @@ export const ALL_LOCALE_KEYS = [ "DisplayModeDetailed", "DisplayModeCompact", "DisplayModeMinimal", - "WindowScaleLabel", - "WindowScaleHelper", - "WindowScaleAriaLabel", "TrayPanelAlwaysOnTopLabel", "TrayPanelAlwaysOnTopHelper", "WindowMinimize", - "WindowMaximize", "WindowRestore", "WindowClose", "ShowAsUsedLabel", @@ -594,9 +593,6 @@ export const ALL_LOCALE_KEYS = [ "EnableProvidersHint", "OpenSettingsButton", "TooltipRefresh", - "TooltipSettings", - "TooltipPopOut", - "TooltipBackToTray", "TrayCardErrorBadge", "SummaryProvidersLabel", "SummaryRefreshing", diff --git a/apps/desktop-tauri/src/lib/tauri.ts b/apps/desktop-tauri/src/lib/tauri.ts index 6ccd63c0de..57f6ce274d 100644 --- a/apps/desktop-tauri/src/lib/tauri.ts +++ b/apps/desktop-tauri/src/lib/tauri.ts @@ -450,6 +450,10 @@ export function setProviderGatewayUrl( return invoke("set_provider_gateway_url", { providerId, gatewayUrl }); } +export function getProviderGatewayUrl(providerId: string): Promise { + return invoke("get_provider_gateway_url", { providerId }); +} + export function getProviderAzureApiVersion(providerId: string): Promise { return invoke("get_provider_azure_api_version", { providerId }); } diff --git a/apps/desktop-tauri/src/lib/usageSpendSharing.test.ts b/apps/desktop-tauri/src/lib/usageSpendSharing.test.ts index bf693ce7b8..13d30f1fdf 100644 --- a/apps/desktop-tauri/src/lib/usageSpendSharing.test.ts +++ b/apps/desktop-tauri/src/lib/usageSpendSharing.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { formatUsageSpendReportingDay, + formatSpendMetric, + formatUsd, filterUsageSpendSummaryForOverview, renderUsageSpendSharePng, usageSpendShareFooter, @@ -10,6 +12,24 @@ import { import type { SpendContract, UsageSpendRow, UsageSpendSummary } from "../types/bridge"; describe("usage spend sharing", () => { + it("labels a mixed-pricing subtotal without presenting it as a total", () => { + expect(formatSpendMetric(null, 1_500, "USD", "tokens", 0.0125)).toMatch(/^≥.* known/); + }); + + it("uses the caller's localized subtotal label", () => { + const metric = formatSpendMetric(null, 500, "USD", "Token", 6, "{} (teilweise)"); + expect(metric).toBe(`${formatUsd(6, "USD")} (teilweise) · 500 Token`); + expect(metric).not.toContain("known"); + }); + + it("renders a complete known-zero total instead of a subtotal", () => { + const metric = formatSpendMetric(0, 0, "USD", "tokens", 9); + expect(metric).not.toBe("—"); + expect(metric).not.toContain("≥"); + expect(metric).not.toContain("9.00"); + expect(metric).toContain("0 tokens"); + }); + it.each([ [0, "0 subscriptions"], [1, "1 subscription"], diff --git a/apps/desktop-tauri/src/lib/usageSpendSharing.ts b/apps/desktop-tauri/src/lib/usageSpendSharing.ts index 2246e98505..353686f540 100644 --- a/apps/desktop-tauri/src/lib/usageSpendSharing.ts +++ b/apps/desktop-tauri/src/lib/usageSpendSharing.ts @@ -124,15 +124,24 @@ export function formatUsd(value: number | null | undefined, currency: string): s } } -/** Canonical "cost · tokens" cell for spend tables and share renders. */ +/** + * Canonical "cost · tokens" cell for spend tables and share renders. + * `knownSubtotalTemplate` is the localized subtotal label; `{}` is the amount. + */ export function formatSpendMetric( cost: number | null | undefined, tokens: number | null | undefined, currency: string, tokenLabel: string, + knownSubtotal?: number | null, + knownSubtotalTemplate = "≥{} known", ): string { const parts: string[] = []; - if (cost != null && Number.isFinite(cost)) parts.push(formatUsd(cost, currency)); + if (cost != null && Number.isFinite(cost)) { + parts.push(formatUsd(cost, currency)); + } else if (knownSubtotal != null && Number.isFinite(knownSubtotal)) { + parts.push(knownSubtotalTemplate.replace("{}", formatUsd(knownSubtotal, currency))); + } if (tokens != null && Number.isFinite(tokens)) { parts.push(`${Math.max(0, tokens).toLocaleString()} ${tokenLabel}`); } @@ -202,8 +211,20 @@ export function renderUsageSpendSharePng(summary: UsageSpendSummary, title: stri const y = y0 + (index + 1) * rowH; const cells = [ row.displayName, - formatSpendMetric(row.sevenDay, row.sevenDayTokens, row.currency, "tokens"), - formatSpendMetric(row.thirtyDay, row.thirtyDayTokens, row.currency, "tokens"), + formatSpendMetric( + row.sevenDay, + row.sevenDayTokens, + row.currency, + "tokens", + row.sevenDayEstimate?.knownSubtotalUsd, + ), + formatSpendMetric( + row.thirtyDay, + row.thirtyDayTokens, + row.currency, + "tokens", + row.thirtyDayEstimate?.knownSubtotalUsd, + ), row.currency || "USD", row.source, ]; diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index a41405732c..36a304a58c 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -46,7 +46,6 @@ --bar-height: 6px; --bar-radius: 3px; --menu-width: 280px; - --popout-width: 340px; --sidebar-width: 200px; --icon-menu: 16px; --icon-sidebar: 20px; @@ -1598,54 +1597,6 @@ body:has(.tray-panel-reveal) { background: rgba(255, 255, 255, 0.06); } -/* ── Pop-out Panel ────────────────────────────────────────────────── */ - -.shell--popout { - max-width: 100%; - padding: 16px 20px 24px; - animation: surfaceFadeIn 80ms ease-out; -} - -.popout-body { - display: flex; - flex-direction: column; - gap: 10px; -} - -.popout-list { - display: flex; - flex-direction: column; - gap: 8px; -} - -.popout-detail { - padding: 16px; - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 12px; - background: rgba(255, 255, 255, 0.04); -} - -/* Side-by-side layout when window is wide enough */ -@media (min-width: 640px) { - .popout-body--split { - flex-direction: row; - min-height: 0; - } - - .popout-body--split .popout-list { - flex: 0 0 220px; - overflow-y: auto; - max-height: calc(100vh - 160px); - } - - .popout-body--split .popout-detail { - flex: 1; - min-width: 0; - overflow-y: auto; - max-height: calc(100vh - 160px); - } -} - /* ── Provider detail: org label ─────────────────────────────────────── */ .tray-detail__org { @@ -1735,7 +1686,7 @@ body:has(.tray-panel-reveal) { color: var(--pace-burning-fg); } -/* ── Pop-out / tray card: pace badge pill ──────────────────────────── */ +/* ── Tray card: pace badge pill ──────────────────────────── */ .tray-pace-badge { display: inline-flex; @@ -3117,7 +3068,7 @@ body:has(.tray-panel-reveal) { /* ────────────────────────────────────────────────────────────────────── - Menu-bar popover surfaces (Tray + Pop-out) + Menu-bar popover surface (tray panel) Structural parity with upstream SwiftUI `MenuContent` / `UsageMenuCardView` (CodexBar/Sources/CodexBar/MenuCardView.swift). @@ -3152,90 +3103,6 @@ body:has(.tray-panel-reveal) { -webkit-font-smoothing: antialiased; } -.popout-scale-shell { - --window-scale: 1; - width: 100%; - height: 100vh; - min-height: 0; - overflow: hidden; -} - -.popout-scale-shell .menu-surface--popout { - height: 100%; - max-height: 100%; -} - -/* Draggable title bar for the PopOut window mode (borderless window has no - native caption). Bleeds past the surface's 6px/10px padding so it sits - flush to the window edges, mirroring `.settings-titlebar`. */ -.popout-titlebar { - display: flex; - align-items: center; - height: 30px; - margin: -6px -10px 0; - padding: 0 4px 0 12px; - background: #1e1e1e; - -webkit-app-region: drag; - user-select: none; - flex-shrink: 0; -} -.popout-titlebar__title { - flex: 1; - font-size: 12px; - font-weight: 500; - color: rgba(255, 255, 255, 0.7); - letter-spacing: 0.02em; -} -.popout-titlebar__controls { - -webkit-app-region: no-drag; - display: flex; - align-self: stretch; - margin-right: -4px; -} -.popout-titlebar__control { - -webkit-app-region: no-drag; - display: flex; - align-items: center; - justify-content: center; - width: 44px; - min-width: 44px; - height: 30px; - border: none; - background: transparent; - color: rgba(255, 255, 255, 0.82); - cursor: pointer; - transition: background 0.15s, color 0.15s; -} -.popout-titlebar__control svg { - width: 12px; - height: 12px; - display: block; - fill: none; - stroke: currentColor; - stroke-width: 1.6; - stroke-linecap: round; -} -.popout-titlebar__control--minimize::before { - content: ""; - display: block; - width: 10px; - height: 1px; - background: currentColor; - border-radius: 1px; -} -.popout-titlebar__control:hover { - background: rgba(255, 255, 255, 0.12); - color: #fff; -} -.popout-titlebar__control:focus-visible { - outline: 2px solid var(--accent); - outline-offset: -2px; -} -.popout-titlebar__control--close:hover { - background: #e81123; - color: #fff; -} - .settings-field input[type="range"] { width: 150px; accent-color: var(--accent); @@ -3246,14 +3113,6 @@ body:has(.tray-panel-reveal) { cursor: not-allowed; } -.menu-surface--popout { - width: 100%; - padding: 6px 10px; - height: 100vh; - max-height: 100vh; - min-height: 0; -} - /* Header bar — small CodexBar wordmark + iconified actions (refresh / settings / pop-out). Upstream's NSMenu has no dedicated title row; we keep one slim toolbar pinned to the top so the popover @@ -3336,111 +3195,6 @@ body:has(.tray-panel-reveal) { overflow-y: auto; } -.menu-surface--popout .menu-surface__body { - flex: 1 1 auto; - min-height: 0; - overflow-y: auto; - overflow-x: hidden; - scrollbar-width: thin; - scrollbar-color: rgba(142, 142, 147, 0.45) transparent; -} - -.menu-surface--popout .menu-surface__body::-webkit-scrollbar { - width: 6px; -} - -.menu-surface--popout .menu-surface__body::-webkit-scrollbar-track { - background: transparent; -} - -.menu-surface--popout .menu-surface__body::-webkit-scrollbar-thumb { - min-height: 36px; - border: 1px solid transparent; - border-radius: 999px; - background: rgba(142, 142, 147, 0.45); - background-clip: padding-box; -} - -.menu-surface--popout .provider-grid { - grid-template-columns: repeat(10, minmax(0, 1fr)); - gap: 9px 4px; - padding: 8px 8px 7px; -} - -.menu-surface--popout .provider-grid--sparse { - grid-template-columns: repeat(auto-fit, 42px); - justify-content: start; - gap: 8px 7px; -} - -.menu-surface--popout .provider-grid--sparse .provider-grid__item { - width: 42px; -} - -.menu-surface--popout .provider-grid--sparse .provider-grid__label { - max-width: 42px; -} - -.menu-surface--popout .provider-grid__divider { - height: 1px; - margin: 2px 0 6px; - background: rgba(255, 255, 255, 0.08); -} - -.menu-surface--popout .menu-stack { - flex: 0 0 auto; - max-height: none; - overflow: visible; - /* Window mode is wide, so lay the provider cards side by side instead of in - one tall scrolling column. The base `.menu-stack` is `flex-direction: - column`, so we MUST switch to `row` here; `flex: 1 1 0` on each card then - makes them grow to an EQUAL share of the row and fill the full width (no - leftover blank on the right). The row wraps to a single column when the - window is too narrow to fit two ~300px cards. */ - display: flex; - flex-direction: row; - flex-wrap: wrap; - align-content: flex-start; - gap: 12px 16px; - align-items: flex-start; - /* The stack is a flex item of a column flex parent; without an explicit - width it shrink-wraps to the cards' content instead of filling the body, - leaving blank space on the right. Force it to the full body width. */ - width: 100%; -} - -.menu-surface--popout .menu-stack__item { - flex: 1 1 0; - min-width: min(300px, 100%); -} - -/* Window mode must fill the resizable window. The tray widget relies on - shrink-to-fit (content-width) sizing all the way up the html → #root → shell - chain so its window can auto-size to content; the PopOut is a normal - resizable window, so every level must instead stretch to full width — without - this the side-by-side provider cards sit at content width and leave a large - blank area on the right of a wide window. */ -body:has(.menu-surface--popout) #root, -body:has(.menu-surface--popout) .popout-scale-shell, -.menu-surface--popout, -.menu-surface--popout .menu-surface__body, -.menu-surface--popout .menu-stack { - width: 100% !important; -} - -/* The horizontal rules between stacked cards make no sense side by side. */ -.menu-surface--popout .menu-stack__sep { - display: none; -} - -.menu-surface--popout .menu-stack__item, -.menu-surface--popout .menu-card { - background: transparent; - border: 0; - border-radius: 0; - box-shadow: none; -} - .menu-surface__empty { display: flex; flex-direction: column; @@ -4589,15 +4343,6 @@ html:has(.menu-surface--tray) { minimum already covers the floor — let the zoomed surface wrap instead. */ min-width: 0; } -.menu-surface--popout { - width: 100%; - border-radius: 0; - border: none; - box-shadow: none; - height: 100vh; - max-height: 100vh; - min-height: 0; -} .menu-surface--tray { border: 1px solid rgba(255, 255, 255, 0.10); border-radius: 10px; @@ -5756,23 +5501,12 @@ html:has(.menu-surface--tray) { gap: 8px 6px; } -.menu-surface--popout .provider-grid { - grid-template-columns: repeat(8, 38px); - gap: 8px 6px; -} - .menu-surface--tray .provider-grid--sparse { grid-template-columns: repeat(auto-fit, 26px); justify-content: start; gap: 8px 7px; } -.menu-surface--popout .provider-grid--sparse { - grid-template-columns: repeat(auto-fit, 42px); - justify-content: start; - gap: 8px 7px; -} - .provider-grid__item { min-height: 40px; padding: 2px 2px 3px; @@ -5801,11 +5535,6 @@ html:has(.menu-surface--tray) { padding-bottom: 5px; } -.menu-surface--popout .provider-grid--compact { - grid-template-columns: repeat(8, 38px); - gap: 8px 6px; -} - .provider-grid--compact .provider-grid__item { min-height: 40px; gap: 2px; @@ -5929,38 +5658,15 @@ html:has(.menu-surface--tray) { } @media (max-height: 620px) { - .menu-surface--popout .provider-grid, .menu-surface--tray .provider-grid { gap: 6px 5px; padding-top: 6px; padding-bottom: 5px; } - .menu-surface--popout .provider-grid__item, .menu-surface--tray .provider-grid__item { min-height: 36px; } - - .menu-surface--popout .menu-card__content { - gap: 8px; - } - - .menu-surface--popout .menu-metric { - gap: 4px; - } - - .menu-surface--popout .menu-card__local-grid { - gap: 7px 20px; - } - - .menu-surface--popout .menu-card__local-chart { - height: 46px; - } - - .menu-surface--popout .menu-surface__footer-row { - padding-top: 3px; - padding-bottom: 3px; - } } .agent-sessions { display: grid; @@ -6035,31 +5741,6 @@ html:has(.menu-surface--tray) { color: inherit; } -.accent-color-swatch-row { - display: flex; - align-items: center; - gap: 8px; - margin-top: 8px; - font-size: 12px; -} - -.accent-color-swatch { - display: inline-block; - width: 16px; - height: 16px; - border-radius: 4px; - border: 1px solid var(--border-color); -} - -.accent-color-swatch-label { - color: var(--text-secondary); -} - -.accent-color-swatch-value { - font-family: var(--font-mono, monospace); - color: var(--text-secondary); -} - /* ── Mistral monthly spend row (#2821, #2947) ──────────────────── */ .menu-card__monthly-spend { margin-top: 4px; diff --git a/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx b/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx deleted file mode 100644 index 0e7f3053a9..0000000000 --- a/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx +++ /dev/null @@ -1,434 +0,0 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const tauriMocks = vi.hoisted(() => ({ - getCachedProviders: vi.fn(), - refreshProviders: vi.fn(), - refreshProvidersIfStale: vi.fn(), - getSettingsSnapshot: vi.fn(), - getUpdateState: vi.fn(), - checkForUpdates: vi.fn(), - downloadUpdate: vi.fn(), - applyUpdate: vi.fn(), - dismissUpdate: vi.fn(), - openReleasePage: vi.fn(), - openFlyoutWindow: vi.fn(), - openSettingsWindow: vi.fn(), - quitApp: vi.fn(), - getProviderChartData: vi.fn(), - getLocaleStrings: vi.fn(), - setUiLanguage: vi.fn(), - getDeepSeekPricingStatus: vi.fn().mockResolvedValue(null), - claudeReconciliationState: vi.fn().mockResolvedValue(null), -})); - -const eventMocks = vi.hoisted(() => ({ - listen: vi.fn(), -})); - -const windowMocks = vi.hoisted(() => { - const setSize = vi.fn().mockResolvedValue(undefined); - const setPosition = vi.fn().mockResolvedValue(undefined); - const minimize = vi.fn().mockResolvedValue(undefined); - const toggleMaximize = vi.fn().mockResolvedValue(undefined); - const close = vi.fn().mockResolvedValue(undefined); - const isMaximized = vi.fn().mockResolvedValue(false); - const onResized = vi.fn().mockResolvedValue(() => {}); - return { - setSize, - setPosition, - minimize, - toggleMaximize, - close, - isMaximized, - onResized, - getCurrentWindow: vi.fn(() => ({ - setSize, - setPosition, - minimize, - toggleMaximize, - close, - isMaximized, - onResized, - })), - LogicalSize: vi.fn((width: number, height: number) => ({ width, height })), - LogicalPosition: vi.fn((x: number, y: number) => ({ x, y })), - }; -}); - -const webviewWindowMocks = vi.hoisted(() => { - const setZoom = vi.fn().mockResolvedValue(undefined); - return { - setZoom, - getCurrentWebviewWindow: vi.fn(() => ({ setZoom })), - }; -}); - -vi.mock("../lib/tauri", () => tauriMocks); -vi.mock("@tauri-apps/api/event", () => eventMocks); -vi.mock("@tauri-apps/api/window", () => windowMocks); -vi.mock("@tauri-apps/api/webviewWindow", () => webviewWindowMocks); - -import PopOutPanel from "./PopOutPanel"; -import { LocaleProvider } from "../i18n/LocaleProvider"; -import { buildBundle } from "../test/localeHarness"; -import { TEST_PROVIDER_CATALOG } from "../test/providerCatalog"; -import type { - BootstrapState, - ProviderCatalogEntry, - ProviderUsageSnapshot, - SettingsSnapshot, -} from "../types/bridge"; - -function rateWindow(used: number) { - return { - usedPercent: used, - remainingPercent: 100 - used, - windowMinutes: null, - resetsAt: null, - resetDescription: null, - isExhausted: false, - reservePercent: null, - reserveDescription: null, - }; -} - -function provider(id: string, displayName: string, used = 20): ProviderUsageSnapshot { - return { - providerId: id, - displayName, - primary: rateWindow(used), - selectedMetric: rateWindow(used), - primaryLabel: "Monthly", - secondary: null, - modelSpecific: null, - tertiary: null, - extraRateWindows: [], - cost: null, - planName: null, - accountEmail: null, - sourceLabel: "auto", - updatedAt: "2026-05-24T00:00:00Z", - error: null, - errorState: "ready", - pace: null, - accountOrganization: null, - trayStatusLabel: null, - fetchDurationMs: null, - }; -} - -function providerWithThreeQuotaWindows( - id: string, - displayName: string, -): ProviderUsageSnapshot { - const snapshot = provider(id, displayName); - snapshot.secondary = rateWindow(35); - snapshot.secondaryLabel = "Weekly"; - snapshot.tertiary = rateWindow(50); - snapshot.tertiaryLabel = "Monthly"; - return snapshot; -} - -function settings(): SettingsSnapshot { - return { - enabledProviders: ["codex", "claude"], - refreshIntervalSecs: 300, - adaptiveRefresh: false, - refreshAllProvidersOnMenuOpen: false, - lowPowerMode: false, - startAtLogin: false, - startMinimized: false, - showNotifications: true, - soundEnabled: true, - notificationSoundTheme: "windows", - notificationSoundPaths: { - predictiveWarning: null, - highUsage: null, - criticalUsage: null, - exhausted: null, - statusIssue: null, - sessionDepleted: null, - sessionRestored: null, - }, - highUsageThreshold: 70, - criticalUsageThreshold: 90, - predictivePaceWarningEnabled: false, - trayIconMode: "single", - switcherShowsIcons: true, - menuBarShowsHighestUsage: false, - menuBarShowsPercent: false, - showAsUsed: true, - showAllTokenAccountsInMenu: false, - enableAnimations: true, - resetTimeRelative: true, - showResetWhenExhausted: false, - menuBarDisplayMode: "detailed", - overviewLayout: "detailed", - hidePersonalInfo: false, - updateChannel: "stable", - autoDownloadUpdates: false, - installUpdatesOnQuit: false, - globalShortcut: "Ctrl+Shift+U", - codexCustomSessionsDirs: [], - uiLanguage: "english", - theme: "dark", - windowScalePercent: 125, - trayScalePercent: 100, - trayPanelAlwaysOnTop: false, - powertoysStatusPipeEnabled: false, - claudeAvoidKeychainPrompts: false, - codexSparkUsageVisible: true, - disableKeychainAccess: false, - providerMetrics: {}, - floatBarEnabled: false, - floatBarOpacity: 80, - floatBarScale: 100, - floatBarOrientation: "horizontal", - floatBarStyle: "floating", - floatBarClickThrough: false, - floatBarProviderIds: [], - floatBarDarkText: false, - floatBarShowResetInline: false, - floatBarShowCost: false, - claudeDailyRoutinesUsageVisible: true, - claudeAllowReadingClaudeCodeCredentials: false, - alibabaTokenPlanRegion: "cn", - weeklyProgressWorkDays: null, - costSummaryDisplayStyle: "compact", - providerAccentColors: {}, - }; -} - -function bootstrap( - catalog: ProviderCatalogEntry[] = [], - settingsOverride: Partial = {}, -): BootstrapState { - return { - contractVersion: "v1", - providers: catalog, - settings: { ...settings(), ...settingsOverride }, - }; -} - -function renderPopOut( - providers: ProviderUsageSnapshot[], - providerId?: string, - catalog: ProviderCatalogEntry[] = [], - settingsOverride: Partial = {}, -) { - tauriMocks.getCachedProviders.mockResolvedValue(providers); - const snapshot = { ...settings(), ...settingsOverride }; - tauriMocks.getSettingsSnapshot.mockResolvedValue(snapshot); - return render( - - - , - ); -} - -describe("PopOutPanel", () => { - beforeEach(() => { - vi.clearAllMocks(); - tauriMocks.claudeReconciliationState.mockResolvedValue(null); - tauriMocks.refreshProviders.mockResolvedValue(undefined); - tauriMocks.refreshProvidersIfStale.mockResolvedValue(undefined); - tauriMocks.getSettingsSnapshot.mockResolvedValue(settings()); - tauriMocks.getUpdateState.mockResolvedValue({ - status: "idle", - version: null, - error: null, - progress: null, - releaseUrl: null, - canDownload: false, - canApply: false, - lastCheckedAt: null, - }); - tauriMocks.getProviderChartData.mockResolvedValue({ - providerId: "codex", - costHistory: [], - creditsHistory: [], - usageBreakdown: [], - localUsage: null, - }); - tauriMocks.getLocaleStrings.mockResolvedValue( - buildBundle({ - PanelAllProviders: "All providers", - PanelAllProvidersShort: "All", - PanelLeftSuffix: "left", - PanelShowAllProviders: "Show all providers", - PanelShowFewerProviders: "Show fewer providers", - PanelUsedSuffix: "used", - SummaryProvidersLabel: "providers", - }), - ); - tauriMocks.openFlyoutWindow.mockResolvedValue(undefined); - eventMocks.listen.mockResolvedValue(() => {}); - }); - - it("shows the provider grid and focuses provider targets", async () => { - const { container } = renderPopOut( - [provider("codex", "Codex", 80), provider("claude", "Claude", 30)], - "claude", - ); - - await waitFor(() => { - expect(container.querySelectorAll(".provider-grid__item")).toHaveLength(3); - }); - - expect(container.querySelector(".provider-grid__item--active")?.getAttribute("aria-label")).toBe("Claude"); - expect(screen.getAllByText("Claude").length).toBeGreaterThanOrEqual(2); - expect(container.querySelectorAll(".menu-stack__item")).toHaveLength(1); - }); - - it("renders cleanly with the flyout-window rewiring for goTray's onClick", async () => { - // goTray's onClick now calls openFlyoutWindow() (formerly - // setSurfaceMode("trayPanel", ...)) — asserted directly against the mock - // import rather than via a click because `headerActions` (the array - // goTray's handler lives in) is currently never rendered by - // MenuSurface: `actions` is destructured in MenuSurfaceProps but not - // consumed in its JSX (components/MenuSurface.tsx), so there is no - // "back to tray" button in the DOM to click today. That's a pre-existing - // gap tracked separately, not introduced by this rewiring. This test - // instead pins down that the component still renders without error and - // that openFlyoutWindow is never called on mount (only on the — for now - // unreachable — click), so the rewiring doesn't regress anything that - // currently DOES work. - renderPopOut([provider("codex", "Codex", 80)]); - - await waitFor(() => { - expect(screen.getAllByText("Codex").length).toBeGreaterThan(0); - }); - - expect(tauriMocks.openFlyoutWindow).not.toHaveBeenCalled(); - }); - - it("applies the persisted PopOut display scale", async () => { - const { container } = renderPopOut( - [provider("codex", "Codex", 80)], - undefined, - [], - { windowScalePercent: 175 }, - ); - - await waitFor(() => { - expect(container.querySelector(".popout-scale-shell")).not.toBeNull(); - }); - - // Scaling is applied via the webview's native zoom, not an inline - // `--window-scale` style (which the earlier CSS-zoom approach used). - await waitFor(() => { - expect(webviewWindowMocks.setZoom).toHaveBeenCalledWith(1.75); - }); - }); - - it("does not resize or reposition the native window on mount", async () => { - renderPopOut([provider("codex", "Codex", 80)]); - - await waitFor(() => { - expect(screen.getAllByText("Codex").length).toBeGreaterThan(0); - }); - - // The PopOut title bar reads window state (isMaximized) on mount, so - // getCurrentWindow is legitimately called; assert only that the surface - // itself never resizes or repositions the native window. - expect(windowMocks.setSize).not.toHaveBeenCalled(); - expect(windowMocks.setPosition).not.toHaveBeenCalled(); - }); - - it("localizes static popout panel footer labels in Japanese", async () => { - tauriMocks.getLocaleStrings.mockResolvedValue( - buildBundle( - { - MenuAbout: "CodexBar について", - MenuQuit: "終了", - TooltipSettings: "設定", - }, - "japanese", - ), - ); - - renderPopOut([provider("codex", "Codex", 80)]); - - expect(await screen.findByText("設定")).toBeInTheDocument(); - expect(screen.getByText("CodexBar について")).toBeInTheDocument(); - expect(screen.getByText("終了")).toBeInTheDocument(); - }); - - it("renders overview cards in settings catalog order instead of fetch order", async () => { - const catalog: ProviderCatalogEntry[] = [ - { id: "codex", displayName: "Codex", cookieDomain: null }, - { id: "claude", displayName: "Claude", cookieDomain: null }, - { id: "cursor", displayName: "Cursor", cookieDomain: null }, - ]; - - const { container } = renderPopOut( - [ - provider("cursor", "Cursor", 15), - provider("codex", "Codex", 95), - provider("claude", "Claude", 40), - ], - undefined, - catalog, - ); - - await waitFor(() => { - expect(container.querySelectorAll(".menu-stack__item")).toHaveLength(3); - }); - - expect( - Array.from(container.querySelectorAll(".menu-card__name")).map( - (node) => node.textContent, - ), - ).toEqual(["Codex", "Claude", "Cursor"]); - }); - - it("keeps compact Overview limited to two quota rows when explicitly selected", async () => { - const { container } = renderPopOut( - [providerWithThreeQuotaWindows("codex", "Codex")], - undefined, - [], - { overviewLayout: "compact" }, - ); - - await waitFor(() => { - expect(container.querySelector(".menu-stack__item")).not.toBeNull(); - }); - - expect(container.querySelectorAll(".menu-metric")).toHaveLength(2); - }); - - it("keeps the popout overview focused until the provider grid expands", async () => { - const providers = TEST_PROVIDER_CATALOG.map(([id, displayName], index) => - provider(id, displayName, (index * 7) % 100), - ); - - const { container } = renderPopOut(providers); - - await waitFor(() => { - expect(container.querySelector(".provider-grid--compact")).not.toBeNull(); - }); - - expect(container.querySelectorAll(".provider-grid__item")).toHaveLength(20); - expect(container.querySelectorAll(".menu-stack__item")).toHaveLength(4); - - const expand = container.querySelector( - '.provider-grid__item--more[aria-label="Show all providers"]', - ); - expect(expand).not.toBeNull(); - - fireEvent.click(expand!); - - await waitFor(() => { - expect(container.querySelectorAll(".provider-grid__item")).toHaveLength( - providers.length + 2, - ); - }); - expect(container.querySelectorAll(".menu-stack__item")).toHaveLength( - providers.length, - ); - }); -}); diff --git a/apps/desktop-tauri/src/surfaces/PopOutPanel.tsx b/apps/desktop-tauri/src/surfaces/PopOutPanel.tsx deleted file mode 100644 index 824f359716..0000000000 --- a/apps/desktop-tauri/src/surfaces/PopOutPanel.tsx +++ /dev/null @@ -1,280 +0,0 @@ -import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; -import type { BootstrapState, ProviderUsageSnapshot } from "../types/bridge"; -import { openFlyoutWindow, openSettingsWindow, quitApp as quitApplication, reorderProviders } from "../lib/tauri"; -import { useProviders } from "../hooks/useProviders"; -import { useSettings } from "../hooks/useSettings"; -import { useUpdateState } from "../hooks/useUpdateState"; -import { useLocale } from "../hooks/useLocale"; -import MenuCard from "../components/MenuCard"; -import PopOutTitleBar from "../components/PopOutTitleBar"; -import MenuSurface, { - MenuEmpty, - type MenuFooterRow, -} from "../components/MenuSurface"; -import UpdateBanner from "../components/UpdateBanner"; -import ProviderGrid from "../components/ProviderGrid"; -import { orderProviderSnapshots } from "../lib/providerOrder"; - -/** - * Pop-out window — dashboard and provider deep-links both keep the full card - * stack. A provider target only scrolls/focuses the requested card so the - * layout stays consistent with the tray/menu surface. - */ -export default function PopOutPanel({ - state, - providerId, -}: { - state: BootstrapState; - providerId?: string; -}) { - const { - providers, - isRefreshing, - refreshingProviderIds, - refresh, - hasCachedData, - } = useProviders(); - const { settings } = useSettings(state.settings); - const { updateState, checkNow, download, apply, dismiss, openRelease } = - useUpdateState(); - const { t } = useLocale(); - - const sorted = useMemo(() => { - return orderProviderSnapshots( - providers, - state.providers, - settings.enabledProviders, - settings.providerOrder, - ); - }, [providers, settings.enabledProviders, settings.providerOrder, state.providers]); - const [selectedProviderId, setSelectedProviderId] = useState( - providerId ?? null, - ); - const [gridExpanded, setGridExpanded] = useState(false); - const cardRefs = useRef(new Map()); - const windowScale = useMemo(() => { - const scalePercent = Number(settings.windowScalePercent); - return ( - Math.min(250, Math.max(100, Number.isFinite(scalePercent) ? scalePercent : 100)) / 100 - ); - }, [settings.windowScalePercent]); - - // Scale the dashboard via the webview's native zoom (like a browser's Ctrl-+): - // it reflows content at the real window width, so the side-by-side cards keep - // filling the window at any scale — unlike CSS `zoom`, which overflows. The - // main window is shared with the tray surface, so reset zoom to 1 on unmount. - useEffect(() => { - const webview = getCurrentWebviewWindow(); - void webview.setZoom(windowScale).catch(() => {}); - return () => { - void webview.setZoom(1).catch(() => {}); - }; - }, [windowScale]); - - useEffect(() => { - setSelectedProviderId(providerId ?? null); - }, [providerId]); - - const visibleProviders = useMemo( - () => { - if (selectedProviderId === null) { - if (sorted.length + 1 > 32 && !gridExpanded) { - return sorted.slice(0, 4); - } - return sorted; - } - const match = sorted.find((p) => p.providerId === selectedProviderId); - return match ? [match] : sorted; - }, - [sorted, selectedProviderId, gridExpanded], - ); - const providerOrderKey = useMemo( - () => sorted.map((provider) => provider.providerId).join(","), - [sorted], - ); - - const handleGridClick = useCallback((nextProviderId: string | null) => { - setSelectedProviderId(nextProviderId); - }, []); - const handleReorder = useCallback((orderedIds: string[]) => { - void reorderProviders(orderedIds).catch(() => {}); - }, []); - - useEffect(() => { - if (!providerId || selectedProviderId !== providerId || providerOrderKey.length === 0) return; - - let cancelled = false; - const scrollToProvider = () => { - if (cancelled) return; - const target = cardRefs.current.get(providerId); - if (!target) return; - - window.scrollTo(0, 0); - if (document.scrollingElement) { - document.scrollingElement.scrollTop = 0; - } - document.documentElement.scrollTop = 0; - document.body.scrollTop = 0; - - for (const selector of [".menu-stack", ".menu-surface__body"]) { - const container = target.closest(selector); - if (!container) continue; - container.scrollTop = 0; - const targetRect = target.getBoundingClientRect(); - const containerRect = container.getBoundingClientRect(); - container.scrollTop += targetRect.top - containerRect.top; - } - }; - - requestAnimationFrame(() => { - requestAnimationFrame(scrollToProvider); - }); - const timer = window.setTimeout(scrollToProvider, 100); - const lateTimer = window.setTimeout(scrollToProvider, 350); - return () => { - cancelled = true; - window.clearTimeout(timer); - window.clearTimeout(lateTimer); - }; - }, [providerId, selectedProviderId, providerOrderKey]); - - const openSettings = useCallback(() => { - openSettingsWindow("general"); - }, []); - const goTray = useCallback(() => { - // The flyout ("Pop Out Dashboard") is now its own dedicated OS window - // rather than a state of the shared `main` window's surface-mode - // machine, so "back to tray" opens it directly instead of switching - // `main`'s mode. - void openFlyoutWindow().catch(() => {}); - }, []); - const openAbout = useCallback(() => { - openSettingsWindow("about"); - }, []); - const quitApp = useCallback(() => { - void quitApplication(); - }, []); - - const headerActions = [ - { icon: "⊟", title: t("TooltipBackToTray"), onClick: goTray }, - ]; - - const footerRows: MenuFooterRow[] = [ - { icon: "⚙", label: t("TooltipSettings"), shortcut: "Ctrl+,", onClick: openSettings }, - { icon: "ℹ", label: t("MenuAbout"), onClick: openAbout }, - { icon: "✕", label: t("MenuQuit"), shortcut: "Ctrl+Q", onClick: quitApp }, - ]; - - // Keyboard shortcuts - useEffect(() => { - const handler = (e: KeyboardEvent) => { - if (!e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) return; - switch (e.key.toLowerCase()) { - case "r": - e.preventDefault(); - refresh(); - break; - case ",": - e.preventDefault(); - openSettings(); - break; - case "q": - e.preventDefault(); - quitApp(); - break; - } - }; - window.addEventListener("keydown", handler); - return () => window.removeEventListener("keydown", handler); - }, [refresh, openSettings, quitApp]); - - const banner = ( - - ); - - const surface = sorted.length === 0 ? ( - } - onRefresh={refresh} - isRefreshing={isRefreshing} - actions={headerActions} - banner={banner} - footerRows={footerRows} - > - - - ) : ( - } - onRefresh={refresh} - isRefreshing={isRefreshing} - actions={headerActions} - banner={banner} - footerRows={footerRows} - > - -
-
- {visibleProviders.map((p, idx) => ( - - {idx > 0 &&
} -
{ - if (node) { - cardRefs.current.set(p.providerId, node); - } else { - cardRefs.current.delete(p.providerId); - } - }} - > - -
- - ))} -
- - ); - - return ( -
- {surface} -
- ); -} diff --git a/apps/desktop-tauri/src/surfaces/Settings.tsx b/apps/desktop-tauri/src/surfaces/Settings.tsx index 21b5828dda..f3fb995d44 100644 --- a/apps/desktop-tauri/src/surfaces/Settings.tsx +++ b/apps/desktop-tauri/src/surfaces/Settings.tsx @@ -253,7 +253,13 @@ export default function Settings({ state, initialTab: propTab }: { state: Bootst )} {activeTab === "menuBar" && ( - + )} {activeTab === "menu" && ( diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index 8e156a34bf..949d275d26 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -300,14 +300,13 @@ describe("TrayPanel provider grid", () => { // TrayPanel is now hosted exclusively in the dedicated `flyout` OS // window (see App.tsx's isFlyoutWindow() routing), so it must not depend // on `main`'s surface-mode machine to know it's "open" — that machine - // can never report "trayPanel" anymore (main only holds - // Hidden/PopOut/Settings post-refactor). Overriding the snapshot mock to - // something else confirms the fixed-size restore + reveal gate + // can report something other than "trayPanel". Overriding the snapshot + // mock to another mode confirms the fixed-size restore + reveal gate // (isFlyoutOpen, hardcoded true in TrayPanel.tsx) is no longer wired to // useSurfaceMode() at all. tauriMocks.getCurrentSurfaceState.mockResolvedValue({ - mode: "popOut", - target: { kind: "dashboard" }, + mode: "settings", + target: { kind: "settings", tab: "general" }, }); const { container } = renderTrayPanel([provider("claude", "Claude", 35)]); @@ -323,31 +322,25 @@ describe("TrayPanel provider grid", () => { reportingDay: "2026-09-19", dashboardTimezone: "UTC", rows: [ - { - providerId: "codex", - displayName: "Codex", - sevenDay: 1, - thirtyDay: 2, - currency: "USD", - source: "local", - includedInOverview: true, - }, - { - providerId: "claude", - displayName: "Claude", - sevenDay: 3, - thirtyDay: 4, - currency: "USD", - source: "hidden", - includedInOverview: false, - }, + { providerId: "codex", displayName: "Codex", sevenDay: 1, thirtyDay: 2, + currency: "USD", source: "local", includedInOverview: true }, + { providerId: "claude", displayName: "Claude", sevenDay: 3, thirtyDay: 4, + currency: "USD", source: "hidden", includedInOverview: false }, + // A known subtotal is a partial estimate: counted for coverage, not in the total. + { providerId: "antigravity", displayName: "Antigravity", sevenDay: null, thirtyDay: null, + thirtyDayEstimate: { + knownSubtotalUsd: 9, + coverage: { priced: 0, unpriced: 1, unmetered: 0, estimated: 1 }, + }, + currency: "USD", source: "known subtotal", includedInOverview: true }, ], }); renderTrayPanel([provider("codex", "Codex", 35)]); expect(await screen.findByRole("button", { name: "UsageSpendShare" })).toBeInTheDocument(); - expect(screen.getByText(/1 of 1 OverviewSpendProviderCoverage/)).toBeInTheDocument(); + expect(screen.getByText("~$2.00")).toBeInTheDocument(); + expect(screen.getByText(/1 of 2 OverviewSpendProviderCoverage/)).toBeInTheDocument(); }); it("dismisses the tray panel on unmodified Escape", async () => { @@ -794,6 +787,41 @@ describe("TrayPanel provider grid", () => { expect(container.querySelector(".provider-grid__icon-overview")).toBeNull(); }); + it("renders the default tray panel layout with no legacy window chrome", async () => { + // Pins the one dashboard layout: tray-variant surface, icon-first + // provider switcher, and the Zoom / Refresh / Settings... / About / Quit + // footer. The retired PopOut layout had a "CodexBar" title bar with + // window controls and a Settings / About / Quit footer without Zoom or + // Refresh. + const { container } = renderTrayPanel([ + provider("claude", "Claude", 35), + provider("codex", "Codex", 20), + ]); + + await waitFor(() => { + expect(container.querySelector(".menu-surface__footer-zoom")).not.toBeNull(); + }); + + const surface = container.querySelector(".menu-surface"); + expect(surface?.classList.contains("menu-surface--tray")).toBe(true); + expect(container.querySelector(".menu-surface--popout")).toBeNull(); + expect(container.querySelector(".popout-titlebar")).toBeNull(); + expect(container.querySelector(".popout-scale-shell")).toBeNull(); + expect(container.querySelector(".provider-grid")).not.toBeNull(); + + const footerLabels = Array.from( + container.querySelectorAll(".menu-surface__footer > *"), + ).map((el) => el.textContent ?? ""); + expect(footerLabels[0]).toContain("Zoom"); + expect(footerLabels.slice(1).map((label) => label.replace(/Ctrl\+.*/, ""))).toEqual([ + "↻Refresh", + "⚙Settings...", + "ⓘAbout CodexBar", + "⌧Quit", + ]); + expect(tauriMocks.setSurfaceMode).not.toHaveBeenCalled(); + }); + it("renders the tray footer zoom slider above Refresh and persists trayScalePercent after the debounce", async () => { const { container } = renderTrayPanel( [provider("claude", "Claude", 35)], diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx index ccc2e8fe2f..034dfc09a0 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx @@ -30,7 +30,7 @@ import { const HAS_DASHBOARD = new Set([ "abacus", "alibaba", "alibabatokenplan", "amp", "augment", "azureopenai", "bedrock", "claude", "codex", "codebuff", - "aiand", "commandcode", "copilot", "crof", "crossmodel", "cursor", "deepgram", "deepinfra", "deepseek", "zenmux", "clinepass", "longcat", "neuralwatt", "zoommate", + "aiand", "commandcode", "copilot", "crossmodel", "cursor", "deepgram", "deepinfra", "deepseek", "zenmux", "clinepass", "longcat", "neuralwatt", "zoommate", "doubao", "elevenlabs", "factory", "gemini", "grok", "groq", "infini", "jetbrains", "kilo", "kimi", "kimik2", "kiro", "manus", "replicate", "mimo", "minimax", "mistral", "nanogpt", "notion", "ollama", "openaiapi", @@ -57,7 +57,6 @@ export default function TrayPanel({ state }: { state: BootstrapState }) { settings, isRefreshing, refreshingProviderIds, - refresh, hasCachedData, trayScaleDraft, trayScale, @@ -73,7 +72,6 @@ export default function TrayPanel({ state }: { state: BootstrapState }) { wideColumns, useWideColumns, requestLayout, - headerActions, footerRows, updateState, checkNow, @@ -159,10 +157,6 @@ export default function TrayPanel({ state }: { state: BootstrapState }) { return (
(null); const [state, dispatch] = useReducer( providerDetailPaneReducer, { wayfinderGatewayUrl, providerId }, @@ -142,11 +145,34 @@ export function ProviderDetailPane({ } }, []); + const gatewayProviderId = providerId === "wayfinder" || providerId === "bifrost" + ? providerId + : null; + + useEffect(() => { + setGatewayLoadedProviderId(null); + if (!gatewayProviderId) return; + let cancelled = false; + void getProviderGatewayUrl(gatewayProviderId).then((url) => { + if (!cancelled) { + dispatch({ type: "SET_GATEWAY_DRAFT", draft: url }); + setGatewayLoadedProviderId(gatewayProviderId); + } + }).catch((e) => { + if (!cancelled) { + dispatch({ type: "SAVE_GATEWAY_ERROR", error: String(e) }); + setGatewayLoadedProviderId(gatewayProviderId); + } + }); + return () => { cancelled = true; }; + }, [gatewayProviderId]); + const saveGateway = async () => { dispatch({ type: "SAVE_GATEWAY_START" }); try { - await setProviderGatewayUrl("wayfinder", gatewayDraft); - await load("wayfinder"); + if (!gatewayProviderId) return; + await setProviderGatewayUrl(gatewayProviderId, gatewayDraft); + await load(gatewayProviderId); } catch (e) { dispatch({ type: "SAVE_GATEWAY_ERROR", error: String(e) }); } finally { @@ -319,7 +345,8 @@ export function ProviderDetailPane({ t={t} onChanged={reload} /> - {detail.id === "wayfinder" && ( + {(detail.id === "wayfinder" || detail.id === "bifrost") && + gatewayLoadedProviderId === detail.id && ( void saveGateway()} t={t} + bifrost={detail.id === "bifrost"} /> )} { + it("uses the native color input as the only color preview", () => { + const { container } = render( + key} + onChange={vi.fn()} + />, + ); + + expect(container.querySelector('input[type="color"]')).toHaveValue("#123456"); + expect(container.querySelector(".accent-color-swatch-row")).toBeNull(); + expect(container.querySelector(".accent-color-swatch")).toBeNull(); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/AccentColorSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/AccentColorSection.tsx index 9e0f8dca41..112e09f856 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/AccentColorSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/AccentColorSection.tsx @@ -94,16 +94,6 @@ export function AccentColorSection({ {t("ProviderAccentColorReset")}
-
- - {t("ProviderAccentColor")} - - - {effective} -
{error &&

{error}

} ); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/CredentialsDispatcher.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/CredentialsDispatcher.tsx index 76c2652ecd..9e57c58d1d 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/CredentialsDispatcher.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/CredentialsDispatcher.tsx @@ -40,6 +40,7 @@ export function CredentialsDispatcher({ providerId, t }: Props) { case "zed": case "sub2api": case "xai": + case "gitkraken": return ; case "openrouter": return ; diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/RegionSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/RegionSection.tsx index 77ed218f79..e1a95c64aa 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/RegionSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/RegionSection.tsx @@ -12,7 +12,7 @@ interface Props { } /** - * API-region dropdown for Alibaba / Z.ai / MiniMax. + * API-region dropdown for providers with regional endpoints, including Kimi. * * Port of the region ComboBox rows in * `rust/src/native_ui/preferences.rs::render_provider_detail_panel`. diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.tsx index 1f0f28f0e6..77b4067771 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.tsx @@ -8,6 +8,7 @@ interface Props { onDraftChange: (draft: string) => void; onSave: () => void; t: (key: LocaleKey) => string; + bifrost?: boolean; } export function WayfinderGatewaySection({ @@ -18,12 +19,13 @@ export function WayfinderGatewaySection({ onDraftChange, onSave, t, + bifrost = false, }: Props) { return (
-

{t("WayfinderGatewayTitle")}

+

{bifrost ? "Bifrost gateway" : t("WayfinderGatewayTitle")}

-

{t("WayfinderGatewayHelp")}

+

+ {bifrost ? "Base URL of your Bifrost gateway." : t("WayfinderGatewayHelp")} +

{error &&

{error}

}