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/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/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/tests.rs index d25a0a064a..444e93da0a 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!( diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index 02f48672b6..df9a86e148 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -27,7 +27,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 +45,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 @@ -150,9 +159,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![ @@ -302,17 +309,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 +435,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..4a08484f39 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 @@ -31,7 +32,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 +42,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 +91,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 +113,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. @@ -352,17 +356,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 +449,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] 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/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..cb11675767 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -16,8 +16,6 @@ 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}; @@ -150,31 +148,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 +163,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 +179,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 +231,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 +286,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); } @@ -803,6 +732,7 @@ fn build_native_menu_entry( #[cfg(test)] mod tests { use super::*; + use crate::surface::SurfaceMode; fn sample_provider_catalog() -> Vec { vec![ @@ -854,35 +784,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 +799,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] 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/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/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/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 195620e3bf..90c0984f49 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", @@ -552,13 +551,9 @@ export const ALL_LOCALE_KEYS = [ "DisplayModeDetailed", "DisplayModeCompact", "DisplayModeMinimal", - "WindowScaleLabel", - "WindowScaleHelper", - "WindowScaleAriaLabel", "TrayPanelAlwaysOnTopLabel", "TrayPanelAlwaysOnTopHelper", "WindowMinimize", - "WindowMaximize", "WindowRestore", "WindowClose", "ShowAsUsedLabel", @@ -594,9 +589,6 @@ export const ALL_LOCALE_KEYS = [ "EnableProvidersHint", "OpenSettingsButton", "TooltipRefresh", - "TooltipSettings", - "TooltipPopOut", - "TooltipBackToTray", "TrayCardErrorBadge", "SummaryProvidersLabel", "SummaryRefreshing", diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index a41405732c..c3465d0ec0 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; 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/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index 8e156a34bf..4655c3b58f 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)]); @@ -794,6 +793,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..2cfdd609e1 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx @@ -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 (
({ useLocale: () => ({ t: (key: string) => key, language: "english" }), })); // The FloatBar section pulls in its own bridge dependencies; it is irrelevant -// to the window-scale control under test. +// to the display controls under test. vi.mock("../../../floatbar/SettingsSection", () => ({ default: () => null, })); @@ -35,27 +35,13 @@ function renderTab(set: (patch: Record) => void) { ); } -describe("DisplayTab window scale", () => { - it("commits the new window scale on blur", () => { - const set = vi.fn(); - renderTab(set); - const slider = screen.getByRole("slider", { name: "WindowScaleAriaLabel" }); - - fireEvent.change(slider, { target: { value: "175" } }); - fireEvent.blur(slider); - - expect(set).toHaveBeenCalledWith({ windowScalePercent: 175 }); - }); - - it("does not commit when the value is unchanged", () => { - const set = vi.fn(); - renderTab(set); - const slider = screen.getByRole("slider", { name: "WindowScaleAriaLabel" }); - - fireEvent.change(slider, { target: { value: "100" } }); - fireEvent.blur(slider); +describe("DisplayTab menu settings", () => { + it("no longer offers the retired PopOut window scale", () => { + // Window scale only zoomed the retired PopOut layout. The tray panel + // has its own Zoom slider in its footer (trayScalePercent). + const { container } = renderTab(vi.fn()); - expect(set).not.toHaveBeenCalled(); + expect(container.querySelector('input[type="range"]')).toBeNull(); }); it("updates the exhausted reset display preference", () => { diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.tsx index aed13a723b..52588deb84 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { useLocale } from "../../../hooks/useLocale"; import { Field, Select, Toggle } from "../../../components/FormControls"; import type { @@ -11,10 +11,6 @@ import type { TabProps } from "../settingsTabs"; import FloatBarSettingsSection from "../../../floatbar/SettingsSection"; import { getTrayVisibilityStatus } from "../../../lib/tauri"; -function clampWindowScalePercent(value: number): number { - return Math.min(250, Math.max(100, Number.isFinite(value) ? value : 100)); -} - export default function DisplayTab({ mode = "menu", settings, @@ -22,9 +18,6 @@ export default function DisplayTab({ saving, }: TabProps & { mode?: "menuBar" | "menu" }) { const { t } = useLocale(); - const [windowScaleDraft, setWindowScaleDraft] = useState(() => - clampWindowScalePercent(settings.windowScalePercent), - ); const [trayVisibility, setTrayVisibility] = useState(null); useEffect(() => { @@ -33,16 +26,6 @@ export default function DisplayTab({ .catch(() => setTrayVisibility(null)); }, []); - useEffect(() => { - setWindowScaleDraft(clampWindowScalePercent(settings.windowScalePercent)); - }, [settings.windowScalePercent]); - - const commitWindowScale = useCallback(() => { - const next = clampWindowScalePercent(windowScaleDraft); - if (next !== settings.windowScalePercent) { - set({ windowScalePercent: next }); - } - }, [set, settings.windowScalePercent, windowScaleDraft]); return ( <> {/* ── Menu bar ─────────────────────────────────────────────── */} @@ -135,29 +118,6 @@ export default function DisplayTab({ {mode === "menu" &&

{t("TabMenu")}

- - - setWindowScaleDraft( - clampWindowScalePercent(Number(e.target.value)), - ) - } - onPointerUp={commitWindowScale} - onTouchEnd={commitWindowScale} - onBlur={commitWindowScale} - onKeyUp={commitWindowScale} - aria-label={t("WindowScaleAriaLabel")} - /> -