diff --git a/apps/desktop-tauri/src-tauri/build.rs b/apps/desktop-tauri/src-tauri/build.rs index d860e1e6a7..7aaf4af1b1 100644 --- a/apps/desktop-tauri/src-tauri/build.rs +++ b/apps/desktop-tauri/src-tauri/build.rs @@ -1,3 +1,44 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + fn main() { + emit_build_date(); tauri_build::build() } + +/// Expose the UTC build date as `CODEXBAR_BUILD_DATE` (YYYY-MM-DD) for the +/// About pane. `SOURCE_DATE_EPOCH` pins it for reproducible builds. +/// +/// `tauri_build` registers its own rerun triggers, so Cargo would otherwise +/// keep this script's first date across later incremental builds. Rerunning +/// when the shell or `codexbar` sources change keeps the date current; those +/// changes recompile this crate anyway. +fn emit_build_date() { + println!("cargo:rerun-if-env-changed=SOURCE_DATE_EPOCH"); + println!("cargo:rerun-if-changed=src"); + println!("cargo:rerun-if-changed=../../../rust/src"); + let seconds = std::env::var("SOURCE_DATE_EPOCH") + .ok() + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or_else(|| { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| elapsed.as_secs()) + .unwrap_or(0) + }); + let (year, month, day) = civil_from_days(seconds / 86_400); + println!("cargo:rustc-env=CODEXBAR_BUILD_DATE={year:04}-{month:02}-{day:02}"); +} + +/// Days since 1970-01-01 to a proleptic Gregorian date (Howard Hinnant's algorithm). +fn civil_from_days(days: u64) -> (u64, u64, u64) { + let z = days + 719_468; + let era = z / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = yoe + era * 400 + u64::from(month <= 2); + (year, month, day) +} diff --git a/apps/desktop-tauri/src-tauri/src/auto_refresh.rs b/apps/desktop-tauri/src-tauri/src/auto_refresh.rs index 75128cb00d..29ccceca0a 100644 --- a/apps/desktop-tauri/src-tauri/src/auto_refresh.rs +++ b/apps/desktop-tauri/src-tauri/src/auto_refresh.rs @@ -311,6 +311,7 @@ mod tests { let mut settings = Settings { enabled_providers: ["codex".to_string()].into_iter().collect(), refresh_interval_secs: 300, + adaptive_refresh: false, ..Default::default() }; settings.set_auto_resume_after_quota_reset(ProviderId::Codex, true); diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 033f24a9b9..f027155d66 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -830,6 +830,7 @@ pub struct SettingsSnapshot { std::collections::HashMap, predictive_pace_warning_enabled: bool, credential_expiry_notifications_enabled: bool, + session_quota_notifications_enabled: bool, show_pace: bool, tray_icon_mode: &'static str, stacked_tray_top_provider: Option, @@ -966,6 +967,7 @@ impl From for SettingsSnapshot { predictive_pace_warning_enabled: settings.predictive_pace_warning_enabled, credential_expiry_notifications_enabled: settings .credential_expiry_notifications_enabled, + session_quota_notifications_enabled: settings.session_quota_notifications_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, diff --git a/apps/desktop-tauri/src-tauri/src/commands/credentials.rs b/apps/desktop-tauri/src-tauri/src/commands/credentials.rs index d405d88c59..cd4b9e916e 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/credentials.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/credentials.rs @@ -40,6 +40,10 @@ pub struct AppInfoBridge { pub name: String, pub version: String, pub build_number: String, + /// UTC date this binary was built (YYYY-MM-DD). `None` when the build + /// script output was reused from a target dir shared with another branch. + #[serde(skip_serializing_if = "Option::is_none")] + pub build_date: Option, pub update_channel: String, pub tagline: String, } diff --git a/apps/desktop-tauri/src-tauri/src/commands/settings.rs b/apps/desktop-tauri/src-tauri/src/commands/settings.rs index 42cb5b2d40..b8dcf2603e 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/settings.rs @@ -26,6 +26,7 @@ pub struct SettingsUpdate { Option>, pub predictive_pace_warning_enabled: Option, pub credential_expiry_notifications_enabled: Option, + pub session_quota_notifications_enabled: Option, pub show_pace: Option, pub tray_icon_mode: Option, pub stacked_tray_top_provider: Option, @@ -340,6 +341,9 @@ impl SettingsUpdate { if let Some(v) = self.credential_expiry_notifications_enabled { settings.credential_expiry_notifications_enabled = v; } + if let Some(v) = self.session_quota_notifications_enabled { + settings.session_quota_notifications_enabled = v; + } if let Some(v) = self.show_pace { settings.show_pace = v; } diff --git a/apps/desktop-tauri/src-tauri/src/commands/system.rs b/apps/desktop-tauri/src-tauri/src/commands/system.rs index 26d6a20f9d..c6eb10e685 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/system.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/system.rs @@ -7,6 +7,7 @@ pub fn get_app_info() -> AppInfoBridge { name: "CodexBar".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), build_number: option_env!("BUILD_NUMBER").unwrap_or("dev").to_string(), + build_date: option_env!("CODEXBAR_BUILD_DATE").map(str::to_string), update_channel: update_channel_label(settings.update_channel).to_string(), tagline: "May your tokens never run out—keep agent limits in view.".to_string(), } diff --git a/apps/desktop-tauri/src/components/FormControls.tsx b/apps/desktop-tauri/src/components/FormControls.tsx index 61be029a71..61fa4e37c5 100644 --- a/apps/desktop-tauri/src/components/FormControls.tsx +++ b/apps/desktop-tauri/src/components/FormControls.tsx @@ -20,18 +20,21 @@ export function Toggle({ label, ariaLabel, disabled, + variant = "checkbox", }: { checked: boolean; onChange: (v: boolean) => void; label?: string; ariaLabel?: string; disabled?: boolean; + /** "switch" draws the macOS-style switch used by the grouped settings panes. */ + variant?: "checkbox" | "switch"; }) { const fieldAria = useFieldAria(ariaLabel); const input = ( ); } + +// ── grouped section (macOS grouped Form look) ─────────────────────── + +/** A bold section title, a rounded box of rows with dividers, and an optional footer. */ +export function SettingsGroup({ + title, + footer, + children, +}: { + title?: string; + footer?: React.ReactNode; + children: React.ReactNode; +}) { + const id = useId(); + return ( +
+ {title && ( +

+ {title} +

+ )} +
{children}
+ {footer &&
{footer}
} +
+ ); +} diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index c002e35809..b14466b465 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -17,6 +17,7 @@ export const ALL_LOCALE_KEYS = [ "TabAbout", "TabShortcuts", "InterfaceLanguage", + "GeneralLanguageHelper", "StartupSettings", "StartAtLogin", "StartMinimized", @@ -227,6 +228,7 @@ export const ALL_LOCALE_KEYS = [ "MenuAbout", "MenuAboutVersion", "MenuQuit", + "QuitCodexBar", "TrayMenuSwitchAccount", "TrayMenuUsageDashboard", "TrayMenuStatusPage", @@ -579,6 +581,8 @@ export const ALL_LOCALE_KEYS = [ "AgentSessionsLoading", "AgentSessionsEmpty", "UpdatesTitle", + "AboutBuilt", + "SectionLinks", "UpdateChannelChoice", "UpdateChannelChoiceHelper", "AutoDownloadUpdates", @@ -595,6 +599,7 @@ export const ALL_LOCALE_KEYS = [ "SelectProvider", "RefreshInterval30Sec", "RefreshInterval1Min", + "RefreshInterval2Min", "RefreshInterval5Min", "RefreshInterval10Min", "BrowserCookiesTitle", @@ -633,7 +638,17 @@ export const ALL_LOCALE_KEYS = [ "UpdateDownloadingMessage", "TabTokenAccounts", "SectionRefresh", + "SectionRefreshing", + "RefreshManualFooter", "SectionNotifications", + "SectionAlerts", + "SessionQuotaNotifications", + "SessionQuotaNotificationsHelper", + "WarningAtPercentUsed", + "CriticalAtPercentUsed", + "ThresholdsCaption", + "ThresholdOverridesTitle", + "ThresholdOverridesCaption", "SectionUsageThresholds", "SectionKeyboard", "SectionUsageRendering", @@ -667,6 +682,9 @@ export const ALL_LOCALE_KEYS = [ "PreferencesTransferCaption", "PreferencesExportButton", "PreferencesImportButton", + "PreferencesTransferFooter", + "SwitcherShortcutsOpenButton", + "DialogDone", "PreferencesExportSuccess", "PreferencesImportSuccess", "ShortcutRecordButton", @@ -735,6 +753,7 @@ export const ALL_LOCALE_KEYS = [ "AvoidKeychainPromptsHelper", "DisableAllKeychainLabel", "DisableAllKeychainHelper", + "CredentialAccessFooter", "SectionTheme", "ThemeLabel", "ThemeHelper", diff --git a/apps/desktop-tauri/src/surfaces/settings/SwitcherShortcutsDialog.test.tsx b/apps/desktop-tauri/src/surfaces/settings/SwitcherShortcutsDialog.test.tsx new file mode 100644 index 0000000000..d9001819a2 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/SwitcherShortcutsDialog.test.tsx @@ -0,0 +1,93 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../../hooks/useLocale", () => ({ + useLocale: () => ({ t: (key: string) => key, language: "english" }), +})); + +import SwitcherShortcutsDialog from "./SwitcherShortcutsDialog"; +import type { SettingsSnapshot } from "../../types/bridge"; + +function renderDialog(onClose = vi.fn(), set = vi.fn()) { + const opener = document.createElement("button"); + document.body.appendChild(opener); + opener.focus(); + const view = render( + , + ); + return { ...view, opener, onClose, set }; +} + +describe("SwitcherShortcutsDialog", () => { + it("is a labelled modal dialog that takes focus and returns it on close", () => { + const { opener, unmount } = renderDialog(); + const dialog = screen.getByRole("dialog", { name: "SwitcherShortcutsTitle" }); + expect(dialog).toHaveAttribute("aria-modal", "true"); + expect(dialog).toHaveFocus(); + + unmount(); + expect(opener).toHaveFocus(); + opener.remove(); + }); + + it("keeps Tab and Shift+Tab inside the sheet", () => { + const { onClose, opener } = renderDialog(); + const dialog = screen.getByRole("dialog"); + const done = screen.getByRole("button", { name: "DialogDone" }); + const firstRecord = screen.getByRole("button", { + name: "SwitcherShortcutPrevious: ShortcutRecordButton", + }); + + // Tab from the last control wraps to the first one. + done.focus(); + fireEvent.keyDown(done, { key: "Tab" }); + expect(firstRecord).toHaveFocus(); + + // Shift+Tab from the first control wraps to Done. + fireEvent.keyDown(firstRecord, { key: "Tab", shiftKey: true }); + expect(done).toHaveFocus(); + + // From the sheet itself (focus on open), Shift+Tab lands on Done and Tab on the first control. + dialog.focus(); + fireEvent.keyDown(dialog, { key: "Tab", shiftKey: true }); + expect(done).toHaveFocus(); + dialog.focus(); + fireEvent.keyDown(dialog, { key: "Tab" }); + expect(firstRecord).toHaveFocus(); + + // A Tab between two inner controls is left to the browser. + const event = new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true }); + firstRecord.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + expect(onClose).not.toHaveBeenCalled(); + opener.remove(); + }); + + it("closes on Escape, on Done and on a backdrop click", () => { + const { onClose, container, opener } = renderDialog(); + fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" }); + fireEvent.click(screen.getByRole("button", { name: "DialogDone" })); + fireEvent.mouseDown(container.querySelector(".settings-dialog-backdrop") as Element); + expect(onClose).toHaveBeenCalledTimes(3); + opener.remove(); + }); + + it("does not close when the press starts inside the sheet", () => { + const { onClose, opener } = renderDialog(); + fireEvent.mouseDown(screen.getByRole("dialog")); + expect(onClose).not.toHaveBeenCalled(); + opener.remove(); + }); + + it("restores defaults through the section's Restore Defaults button", () => { + const { set, opener } = renderDialog(); + fireEvent.click(screen.getByRole("button", { name: "SwitcherShortcutReset" })); + expect(set).toHaveBeenCalledWith({ switcherShortcuts: {} }); + opener.remove(); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/SwitcherShortcutsDialog.tsx b/apps/desktop-tauri/src/surfaces/settings/SwitcherShortcutsDialog.tsx new file mode 100644 index 0000000000..7d2dd0082d --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/SwitcherShortcutsDialog.tsx @@ -0,0 +1,95 @@ +import type React from "react"; +import { useEffect, useId, useRef } from "react"; +import { useLocale } from "../../hooks/useLocale"; +import SwitcherShortcutsSection from "./SwitcherShortcutsSection"; +import type { TabProps } from "./settingsTabs"; + +const FOCUSABLE = + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +/** Keeps Tab and Shift+Tab inside the modal sheet. */ +function trapTab(dialog: HTMLElement, event: React.KeyboardEvent) { + const focusable = Array.from(dialog.querySelectorAll(FOCUSABLE)); + if (focusable.length === 0) { + event.preventDefault(); + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const active = document.activeElement; + const outside = !focusable.includes(active as HTMLElement); + if (event.shiftKey && (active === first || outside)) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && (active === last || outside)) { + event.preventDefault(); + first.focus(); + } +} + +/** + * In-window sheet for the provider-switcher keys, opened from + * Settings > General > Portable preferences. Tab stays inside the sheet, + * Escape or Done closes it and focus returns to the button that opened it. Every change saves at once, + * like the rest of Settings. + */ +export default function SwitcherShortcutsDialog({ + settings, + set, + saving, + onClose, +}: TabProps & { onClose: () => void }) { + const { t } = useLocale(); + const titleId = useId(); + const dialogRef = useRef(null); + + useEffect(() => { + const opener = document.activeElement as HTMLElement | null; + dialogRef.current?.focus(); + return () => opener?.focus?.(); + }, []); + + return ( +
{ + if (event.target === event.currentTarget) onClose(); + }} + > +
{ + if (event.key === "Escape") { + event.stopPropagation(); + onClose(); + } else if (event.key === "Tab" && dialogRef.current) { + trapTab(dialogRef.current, event); + } + }} + > +
+

+ {t("SwitcherShortcutsTitle")} +

+
+
+ +
+
+ +
+
+
+ ); +} diff --git a/apps/desktop-tauri/src/surfaces/settings/SwitcherShortcutsSection.tsx b/apps/desktop-tauri/src/surfaces/settings/SwitcherShortcutsSection.tsx index 16cf8c8654..fd1eb6350e 100644 --- a/apps/desktop-tauri/src/surfaces/settings/SwitcherShortcutsSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/SwitcherShortcutsSection.tsx @@ -23,7 +23,7 @@ const ERROR_KEYS: Record = { invalid: "SwitcherShortcutErrorInvalid", }; -/** Editor for the provider-switcher keys (Settings > Menu). */ +/** Editor for the provider-switcher keys, shown in SwitcherShortcutsDialog (Settings > General). */ export default function SwitcherShortcutsSection({ settings, set, @@ -59,10 +59,9 @@ export default function SwitcherShortcutsSection({ }; return ( -
-

{t("SwitcherShortcutsTitle")}

+

{t("SwitcherShortcutsHelper")}

-
+
{SWITCHER_ACTIONS.map((action) => { const value = mapping[action]; return ( @@ -82,18 +81,20 @@ export default function SwitcherShortcutsSection({ })}
{error && ( -

+

{label(error.action)}: {t(ERROR_KEYS[error.code])}

)} - -
+
+ +
+ ); } diff --git a/apps/desktop-tauri/src/surfaces/settings/settings-layout.css b/apps/desktop-tauri/src/surfaces/settings/settings-layout.css index 1cbe8ac5a2..2b25c654df 100644 --- a/apps/desktop-tauri/src/surfaces/settings/settings-layout.css +++ b/apps/desktop-tauri/src/surfaces/settings/settings-layout.css @@ -58,6 +58,311 @@ align-self: flex-start; } +/* ── Grouped panes (General, Notifications, Advanced, About) ───────── + macOS grouped Form: bold sentence-case title, one rounded box of rows + separated by 1px dividers, and an optional muted footer. */ +.settings-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.settings-group + .settings-group { + margin-top: 20px; +} + +.settings-group__title { + margin: 0 0 0 12px; + font-size: 13px; + font-weight: 650; + color: var(--text-primary); +} + +.settings-group__box { + display: flex; + flex-direction: column; + padding: 0 12px; + border: 1px solid var(--panel-border); + border-radius: 6px; + background: var(--surface-elevated); +} + +.settings-group__box > .settings-field, +.settings-group__box > .settings-group__row { + padding: 10px 0; +} + +.settings-group__box > * + * { + border-top: 1px solid var(--panel-border); +} + +.settings-group__box .settings-field__label { + font-size: 13px; +} + +.settings-group__box .settings-field__desc { + font-size: 12px; + color: var(--text-secondary); +} + +.settings-group__row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.settings-group__row--split { + justify-content: space-between; +} + +.settings-group__footer { + margin: 0 12px; + font-size: 12px; + line-height: 1.4; + color: var(--text-secondary); +} + +.settings-group__footer p { + margin: 0; +} + +.settings-group__error { + margin: 0; + padding: 8px 0; + font-size: 12px; + color: var(--provider-status-error); +} + +/* Footer row of General: running version on the left, Quit on the right. */ +.settings-pane-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 20px; + font-size: 12px; + color: var(--text-secondary); +} + +/* ── Switch toggle ────────────────────────────────────────────────── */ +.toggle.toggle--switch { + width: 32px; + height: 18px; + border-radius: 9px; + border: 1px solid rgba(255, 255, 255, 0.16); + background: rgba(255, 255, 255, 0.16); + transition: background 0.15s, border-color 0.15s; +} + +.toggle.toggle--switch::after { + content: ""; + position: absolute; + top: 1px; + left: 1px; + width: 14px; + height: 14px; + border: none; + border-radius: 50%; + background: #e8e8ea; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.3); + transform: none; + transition: left 0.15s; +} + +.toggle.toggle--switch:checked { + background: var(--accent, #0a84ff); + border-color: var(--accent, #0a84ff); +} + +.toggle.toggle--switch:checked::after { + left: 15px; + width: 14px; + height: 14px; + border: none; + background: #fff; + transform: none; +} + +[data-theme="light"] .toggle.toggle--switch { + border-color: rgba(0, 0, 0, 0.12); + background: rgba(0, 0, 0, 0.12); +} + +[data-theme="light"] .toggle.toggle--switch:checked { + background: var(--accent, #007aff); + border-color: var(--accent, #007aff); +} + +/* ── Menu-style select: borderless "Value ⌄" ──────────────────────── */ +.select.select--menu { + border-color: transparent; + background-color: transparent; + text-align: right; + text-align-last: right; + font-size: 13px; +} + +.select.select--menu:hover:not(:disabled), +.select.select--menu:focus-visible { + border-color: var(--panel-border); + background-color: var(--surface-elevated); +} + +/* ── About hero ───────────────────────────────────────────────────── */ +.about-hero { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 20px 12px; + border: 1px solid var(--panel-border); + border-radius: 6px; + background: var(--surface-elevated); + text-align: center; +} + +.about-hero .about-icon { + width: 64px; + height: 64px; + margin-bottom: 8px; +} + +.about-hero__name { + margin: 0; + font-size: 16px; + font-weight: 650; + color: var(--text-primary); +} + +.about-hero__line { + margin: 0; + font-size: 12px; + color: var(--text-secondary); +} + +.about-hero + .settings-group { + margin-top: 20px; +} + +/* The legacy .about-section centers its children; the Mac pane is full-width and left-aligned. */ +.about-section > .about-hero, +.about-section > .settings-group { + align-self: stretch; +} + +.about-section > .settings-group { + text-align: left; +} + +/* Warning + critical override pair on one row. */ +.settings-inline-fields { + display: flex; + gap: 8px; +} + +.settings-link-row { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 10px 0; + border: none; + border-radius: 0; + background: transparent; + color: var(--text-primary); + font: inherit; + font-size: 13px; + text-align: left; + cursor: pointer; +} + +.settings-group__box > .settings-link-row + .settings-link-row { + border-top: 1px solid var(--panel-border); +} + +.settings-link-row:hover { + color: var(--accent); +} + +.settings-link-row:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +.settings-link-row__arrow { + color: var(--text-secondary); +} + +/* ── Switcher shortcuts dialog ────────────────────────────────────── */ +.settings-dialog-backdrop { + position: fixed; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + background: rgba(0, 0, 0, 0.45); +} + +.settings-dialog { + display: flex; + flex-direction: column; + width: min(560px, 100%); + max-height: calc(100vh - 32px); + border: 1px solid var(--panel-border); + border-radius: 6px; + background: var(--app-bg, #1e1e1e); + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45); +} + +.settings-dialog__header, +.settings-dialog__actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 12px 16px; +} + +.settings-dialog__header { + border-bottom: 1px solid var(--panel-border); +} + +.settings-dialog__title { + margin: 0; + font-size: 13px; + font-weight: 650; + color: var(--text-primary); +} + +.settings-dialog__body { + overflow-y: auto; + padding: 0 16px; +} + +.settings-dialog__actions { + justify-content: flex-end; + border-top: 1px solid var(--panel-border); +} + +.switcher-shortcuts { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 0; +} + +.switcher-shortcuts .settings-section__caption { + margin: 0; +} + +.switcher-shortcuts__reset { + display: flex; + justify-content: flex-end; +} + /* Settings > Providers "Usage details": planning detail moved out of the tray card. */ .provider-usage-details { display: flex; diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx index 7634e0146e..8e1e518d53 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx @@ -16,7 +16,8 @@ const updateMocks = vi.hoisted(() => ({ vi.mock("../../../lib/tauri", () => tauriMocks); vi.mock("../../../hooks/useLocale", () => ({ - useLocale: () => ({ t: (key: string) => key }), + // AboutBuilt keeps its Fluent `{}` placeholder so the rendered date is asserted. + useLocale: () => ({ t: (key: string) => (key === "AboutBuilt" ? "Built {}" : key) }), })); vi.mock("../../../hooks/useUpdateState", () => ({ useUpdateState: () => ({ @@ -159,3 +160,74 @@ describe("AboutTab", () => { }); }); }); + +describe("AboutTab Mac grouped layout", () => { + beforeEach(() => { + vi.clearAllMocks(); + tauriMocks.openExternalUrl.mockResolvedValue(undefined); + }); + + it("shows the build date in the hero when the shell reports one", async () => { + tauriMocks.getAppInfo.mockResolvedValue({ + name: "CodexBar", + version: "0.70.0", + buildNumber: "7", + buildDate: "2026-10-11", + updateChannel: "stable", + tagline: "Keep agent limits in view.", + }); + render(); + + expect(await screen.findByText("Built 2026-10-11")).toBeInTheDocument(); + expect(screen.getByText("Version 0.70.0 (7)")).toBeInTheDocument(); + }); + + it("leaves the build line out when no build date is known", async () => { + tauriMocks.getAppInfo.mockResolvedValue({ + name: "CodexBar", + version: "0.70.0", + buildNumber: "dev", + updateChannel: "stable", + tagline: "Keep agent limits in view.", + }); + render(); + + await screen.findByText("Keep agent limits in view."); + expect(screen.queryByText(/^Built /)).toBeNull(); + }); + + it("groups Updates then Links, with Submit an Issue as a link row", async () => { + tauriMocks.getAppInfo.mockResolvedValue({ + name: "CodexBar", + version: "0.70.0", + buildNumber: "dev", + updateChannel: "stable", + tagline: "Keep agent limits in view.", + }); + const { container } = render( + , + ); + await screen.findByText("Keep agent limits in view."); + + const titles = Array.from( + container.querySelectorAll(".settings-group__title"), + (node) => node.textContent, + ); + expect(titles).toEqual(["UpdatesTitle", "SectionLinks"]); + const linkRows = Array.from( + container.querySelectorAll(".settings-link-row"), + (node) => node.textContent, + ); + expect(linkRows).toEqual([ + "AboutLinkGitHub↗", + "AboutLinkWebsite↗", + "AboutLinkOriginalProject↗", + "SubmitIssue↗", + ]); + expect(screen.getByRole("checkbox", { name: "AutoDownloadUpdates" })).toHaveClass( + "toggle--switch", + ); + expect(screen.getByRole("combobox", { name: "UpdateChannelChoice" })).toHaveValue("stable"); + expect(screen.getByRole("button", { name: "AboutCheckForUpdates" })).toBeEnabled(); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.tsx index c6e62e429c..cd4eaa278a 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { useLocale } from "../../../hooks/useLocale"; import { useUpdateState } from "../../../hooks/useUpdateState"; import { getAppInfo, openExternalUrl } from "../../../lib/tauri"; -import { Field, Select, Toggle } from "../../../components/FormControls"; +import { Field, Select, SettingsGroup, Toggle } from "../../../components/FormControls"; import type { AppInfoBridge, UpdateChannel } from "../../../types/bridge"; import type { LocaleKey } from "../../../i18n/keys"; import type { TabProps } from "../settingsTabs"; @@ -24,6 +24,10 @@ const ABOUT_LINKS: ReadonlyArray<{ labelKey: LocaleKey; url: string }> = [ labelKey: "AboutLinkOriginalProject", url: "https://github.com/steipete/CodexBar", }, + { + labelKey: "SubmitIssue", + url: SUBMIT_ISSUE_URL, + }, ]; export default function AboutTab({ settings, set, saving }: TabProps) { @@ -68,91 +72,69 @@ export default function AboutTab({ settings, set, saving }: TabProps) { const copyrightAfter = t("AboutCopyrightAfter"); return ( -
-
+
+
{t("AppName")} -
-

{appInfo.name}

-

- {t("Version")} {appInfo.version} - {appInfo.buildNumber !== "dev" && ` (${appInfo.buildNumber})`} +

{appInfo.name}

+

+ {t("Version")} {appInfo.version} + {appInfo.buildNumber !== "dev" && ` (${appInfo.buildNumber})`} +

+ {appInfo.buildDate && ( +

+ {t("AboutBuilt").replace("{}", appInfo.buildDate)}

-

{appInfo.tagline}

-
+ )} +

{appInfo.tagline}

-
- {ABOUT_LINKS.map((link) => ( - - ))} -
- - {linkError && ( -

- {t("ErrorPrefix")} {linkError} -

- )} - -
- -
+ set({ autoDownloadUpdates: v })} /> - -
- - set({ updateChannel: v as UpdateChannel })} + /> + +
+ + {t("Version")} {appInfo.version} + + +
{updateState.status === "available" && ( -
+
{t("UpdateAvailableMessage").replace( "{}", @@ -176,15 +158,17 @@ export default function AboutTab({ settings, set, saving }: TabProps) { )} {updateState.status === "downloading" && ( - - {t("UpdateDownloading")} - {updateState.progress != null && - ` ${Math.round(updateState.progress * 100)}%`} - +
+ + {t("UpdateDownloading")} + {updateState.progress != null && + ` ${Math.round(updateState.progress * 100)}%`} + +
)} {updateState.status === "ready" && ( -
+
{t("UpdateReady")} {updateState.canApply ? (
+ + + + {t("ErrorPrefix")} {linkError} +

+ ) : undefined + } + > + {ABOUT_LINKS.map((link) => ( + + ))} +

{copyrightBefore}{" "} @@ -224,6 +237,6 @@ export default function AboutTab({ settings, set, saving }: TabProps) { {" "}{copyrightAfter}

-
+ ); } diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx index 2bf33797de..a59cc74341 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx @@ -209,3 +209,56 @@ describe("AdvancedTab", () => { expect(set).toHaveBeenCalledWith({ stayAwakeEnabled: true }); }); }); + +describe("AdvancedTab Mac grouped layout", () => { + it("orders the groups with Privacy first and no keyboard or portable preferences", () => { + const { container } = render( + , + ); + const titles = Array.from( + container.querySelectorAll(".settings-group__title"), + (node) => node.textContent, + ); + expect(titles).toEqual([ + "PrivacyTitle", + "CodexLocalLogsTitle", + "AgentSessionsTitle", + "SectionLocalIntegrations", + "NetworkProxyTitle", + "HooksTitle", + "DiagnosticsSectionHeading", + ]); + expect(screen.queryByText("SectionKeyboard")).toBeNull(); + expect(screen.queryByText("SectionPreferencesTransfer")).toBeNull(); + expect(screen.queryByText("KEYCHAIN ACCESS")).toBeNull(); + }); + + it("draws every toggle as a switch", () => { + render(); + for (const toggle of screen.getAllByRole("checkbox")) { + expect(toggle).toHaveClass("toggle--switch"); + } + }); + + it("locks the Claude keychain-prompt option while Credential Manager access is off", () => { + const set = vi.fn(); + const { rerender } = render( + , + ); + expect(screen.getByRole("checkbox", { name: "AvoidKeychainPromptsLabel" })).toBeEnabled(); + expect(screen.queryByText("CredentialAccessFooter")).toBeNull(); + + fireEvent.click(screen.getByRole("checkbox", { name: "DisableAllKeychainLabel" })); + expect(set).toHaveBeenCalledWith({ disableKeychainAccess: true }); + + rerender( + , + ); + expect(screen.getByRole("checkbox", { name: "AvoidKeychainPromptsLabel" })).toBeDisabled(); + expect(screen.getByText("CredentialAccessFooter")).toBeInTheDocument(); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.tsx index 88b540e8de..87a376816f 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.tsx @@ -1,14 +1,8 @@ import { useCallback, useEffect, useState } from "react"; import { useLocale } from "../../../hooks/useLocale"; -import { - getSafeDiagnostics, - registerGlobalShortcut, - unregisterGlobalShortcut, -} from "../../../lib/tauri"; -import { ShortcutCapture } from "../../../components/ShortcutCapture"; -import { Field, Toggle } from "../../../components/FormControls"; +import { getSafeDiagnostics } from "../../../lib/tauri"; +import { Field, SettingsGroup, Toggle } from "../../../components/FormControls"; import type { TabProps } from "../settingsTabs"; -import PreferencesTransferSection from "./PreferencesTransferSection"; function formatCodexSessionsDirs(paths: string[]): string { return paths.join("; "); @@ -27,7 +21,6 @@ function parseSshHosts(value: string): string[] { export default function AdvancedTab({ settings, set, saving }: TabProps) { const { t } = useLocale(); - const [shortcutError, setShortcutError] = useState(null); const [diagnosticsStatus, setDiagnosticsStatus] = useState( null, ); @@ -99,303 +92,219 @@ export default function AdvancedTab({ settings, set, saving }: TabProps) { } }, [proxyPasswordDraft, set, settings.httpProxyPassword]); - const commitShortcut = useCallback( - async (accelerator: string) => { - setShortcutError(null); - try { - await registerGlobalShortcut(accelerator).catch(() => {}); - set({ globalShortcut: accelerator }); - } catch (err: unknown) { - setShortcutError(err instanceof Error ? err.message : String(err)); - } - }, - [set], - ); - - const clearShortcut = useCallback(async () => { - setShortcutError(null); - try { - await unregisterGlobalShortcut().catch(() => {}); - set({ globalShortcut: "" }); - } catch (err: unknown) { - setShortcutError(err instanceof Error ? err.message : String(err)); - } - }, [set]); - - return ( <> - {/* ── Keyboard shortcut ────────────────────────────────────── */} -
-

{t("SectionKeyboard")}

-
- - void commitShortcut(accel)} - onClear={() => void clearShortcut()} - /> - -
- {shortcutError && ( -

{shortcutError}

- )} -

{t("ShortcutRecordingHint")}

-
- - {/* -- Codex local logs -------------------------------------- */} -
-

- {t("CodexLocalLogsTitle")} -

-

- {t("CodexLocalLogsCaption")} -

-
- - \home\\.codex`} - disabled={saving} - onChange={(event) => setCodexDirsDraft(event.target.value)} - onBlur={commitCodexDirs} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.currentTarget.blur(); - } - }} - /> - -
-
- - {/* -- Privacy ----------------------------------------------- */} -
-

{t("AgentSessionsTitle")}

-
- - set({ agentSessionsEnabled: v })} - /> - - - set({ stayAwakeEnabled: v })} - /> - - - setSshHostsDraft(event.target.value)} - onBlur={() => set({ agentSessionSshHosts: parseSshHosts(sshHostsDraft) })} - onKeyDown={(event) => { - if (event.key === "Enter") event.currentTarget.blur(); - }} - /> - -
-
+ {t("CredentialAccessFooter")}

: undefined + } + > + + set({ hidePersonalInfo: v })} + /> + + + set({ disableKeychainAccess: v })} + /> + + + set({ claudeAvoidKeychainPrompts: v })} + /> + +
- {/* -- Privacy ----------------------------------------------- */} -
-

{t("PrivacyTitle")}

-
- - set({ hidePersonalInfo: v })} - /> - -
-
+ {t("CodexLocalLogsCaption")}

} + > + + \home\\.codex`} + disabled={saving} + onChange={(event) => setCodexDirsDraft(event.target.value)} + onBlur={commitCodexDirs} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.currentTarget.blur(); + } + }} + /> + +
- {/* -- Local integrations ----------------------------------- */} -
-

- {t("SectionLocalIntegrations")} -

-
- - set({ powertoysStatusPipeEnabled: v })} - /> - -
-
+ + + set({ agentSessionsEnabled: v })} + /> + + + set({ stayAwakeEnabled: v })} + /> + + + setSshHostsDraft(event.target.value)} + onBlur={() => set({ agentSessionSshHosts: parseSshHosts(sshHostsDraft) })} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + }} + /> + + - {/* -- Network proxy ---------------------------------------- */} -
-

{t("NetworkProxyTitle")}

-

{t("NetworkProxyCaption")}

-
- - set({ httpProxyEnabled: v })} - /> - - - setProxyUrlDraft(event.target.value)} - onBlur={commitProxyUrl} - onKeyDown={(event) => { - if (event.key === "Enter") event.currentTarget.blur(); - }} - /> - - - setProxyUsernameDraft(event.target.value)} - onBlur={commitProxyUsername} - onKeyDown={(event) => { - if (event.key === "Enter") event.currentTarget.blur(); - }} - /> - - - setProxyPasswordDraft(event.target.value)} - onBlur={commitProxyPassword} - onKeyDown={(event) => { - if (event.key === "Enter") event.currentTarget.blur(); - }} - /> - -
-
- - {/* -- External hooks --------------------------------------- */} -
-

{t("HooksTitle")}

-

{t("HooksCaption")}

-
- - set({ hooksEnabled: v })} - /> - -
-

{t("HooksConfigPathHint")}

-
+ + + set({ powertoysStatusPipeEnabled: v })} + /> + + - {/* ── Keychain access ──────────────────────────────────────── */} -
-

- KEYCHAIN ACCESS -

-

- Disable all Keychain reads and writes. Browser cookie import is - unavailable; paste Cookie headers manually in Providers. -

-
- - set({ disableKeychainAccess: v })} - /> - - - set({ claudeAvoidKeychainPrompts: v })} - /> - -
-
+ {t("NetworkProxyCaption")}

} + > + + set({ httpProxyEnabled: v })} + /> + + + setProxyUrlDraft(event.target.value)} + onBlur={commitProxyUrl} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + }} + /> + + + setProxyUsernameDraft(event.target.value)} + onBlur={commitProxyUsername} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + }} + /> + + + setProxyPasswordDraft(event.target.value)} + onBlur={commitProxyPassword} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + }} + /> + +
- + +

{t("HooksCaption")}

+

{t("HooksConfigPathHint")}

+ + } + > + + set({ hooksEnabled: v })} + /> + +
- {/* ── Diagnostics ──────────────────────────────────────────── */} -
-

- {t("DiagnosticsSectionHeading")} -

-
+ {diagnosticsStatus}

: undefined} + > +
- {diagnosticsStatus && ( -

{diagnosticsStatus}

- )}
-
+ ); } diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.test.tsx index 8cff53314d..33f6ef1b4f 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.test.tsx @@ -158,3 +158,11 @@ describe("DisplayTab stacked tray providers", () => { expect(set).toHaveBeenCalledWith({ stackedTrayBottomProvider: "codex" }); }); }); + +describe("DisplayTab switcher shortcuts", () => { + it("no longer hosts the provider switcher shortcuts on the Menu tab", () => { + renderTab(vi.fn(), "menu"); + expect(screen.queryByText("SwitcherShortcutsTitle")).toBeNull(); + expect(screen.queryByRole("button", { name: "SwitcherShortcutReset" })).toBeNull(); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.tsx index 79c6ed0aed..572b91beda 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.tsx @@ -11,7 +11,6 @@ import type { } from "../../../types/bridge"; import type { TabProps } from "../settingsTabs"; import FloatBarSettingsSection from "../../../floatbar/SettingsSection"; -import SwitcherShortcutsSection from "../SwitcherShortcutsSection"; import { getTrayVisibilityStatus } from "../../../lib/tauri"; import { clampTrayScalePercent, @@ -291,10 +290,6 @@ export default function DisplayTab({ } - {mode === "menu" && ( - - )} - {mode === "menu" && ( )} diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx index 5185a6f884..e028f488ac 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; vi.mock("../../../hooks/useLocale", () => ({ @@ -116,7 +116,7 @@ describe("GeneralTab running version", () => { it("shows the running app version on the general tab", async () => { render(); - expect(await screen.findByText("0.60.3 (42)")).toBeInTheDocument(); + expect(await screen.findByText("Version 0.60.3 (42)")).toBeInTheDocument(); }); }); @@ -409,3 +409,279 @@ describe("GeneralTab preferred currency picker", () => { expect(screen.queryByRole("combobox", { name: "ThemeLabel" })).toBeNull(); }); + +describe("GeneralTab Mac grouped layout", () => { + it("orders the groups System, Refreshing, Portable preferences, Keyboard shortcut", () => { + const { container } = render( + , + ); + const titles = Array.from( + container.querySelectorAll(".settings-group__title"), + (node) => node.textContent, + ); + expect(titles).toEqual([ + "StartupSettings", + "SectionRefreshing", + "SectionPreferencesTransfer", + "SectionKeyboard", + ]); + }); + + it("lists System rows in Mac order with Windows extras after them", () => { + const { container } = render( + , + ); + const system = container.querySelector(".settings-group") as HTMLElement; + const labels = Array.from( + system.querySelectorAll(".settings-field__label"), + (node) => node.textContent, + ); + expect(labels).toEqual([ + "InterfaceLanguage", + "PreferredCurrencyLabel", + "Appearance", + "StartAtLogin", + "StartMinimized", + ]); + const toggles = within(system).getAllByRole("checkbox"); + expect(toggles).toHaveLength(2); + for (const toggle of toggles) { + expect(toggle).toHaveClass("toggle--switch"); + } + }); + + it("offers the Mac refresh intervals plus the Windows 1 hour option", () => { + render(); + const select = screen.getByRole("combobox", { name: "RefreshIntervalLabel" }); + const values = Array.from( + select.querySelectorAll("option"), + (option) => option.getAttribute("value"), + ); + expect(values).toEqual(["0", "60", "120", "300", "900", "1800", "3600", "adaptive"]); + }); + + it("saves 2 min as a fixed interval and turns adaptive off", () => { + const set = vi.fn(); + render( + , + ); + fireEvent.change(screen.getByRole("combobox", { name: "RefreshIntervalLabel" }), { + target: { value: "120" }, + }); + expect(set).toHaveBeenCalledWith({ adaptiveRefresh: false, refreshIntervalSecs: 120 }); + }); + + it("keeps a saved interval that is not in the menu visible", () => { + render( + , + ); + const select = screen.getByRole("combobox", { name: "RefreshIntervalLabel" }); + expect(select).toHaveValue("45"); + expect(within(select).getByText("45 s")).toBeInTheDocument(); + }); + + it("shows the Manual footer only when auto-refresh is off", () => { + const { rerender } = render( + , + ); + expect(screen.queryByText("RefreshManualFooter")).toBeNull(); + + rerender( + , + ); + expect(screen.getByText("RefreshManualFooter")).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.queryByText("RefreshManualFooter")).toBeNull(); + }); + + it("hosts the global shortcut recorder as Open menu", () => { + render(); + expect(screen.getByText("GlobalShortcutFieldLabel")).toBeInTheDocument(); + expect(screen.getByText("ShortcutRecordingHint")).toBeInTheDocument(); + }); + + it("shows the register error and does not save a shortcut that failed to register", async () => { + const set = vi.fn(); + render(); + vi.mocked(invoke).mockRejectedValueOnce("Failed to register shortcut \"Ctrl+Shift+K\": taken"); + + fireEvent.click(screen.getByRole("button", { name: "ShortcutRecordButton" })); + fireEvent.keyDown(window, { key: "k", code: "KeyK", ctrlKey: true, shiftKey: true }); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "Failed to register shortcut \"Ctrl+Shift+K\": taken", + ); + expect(invoke).toHaveBeenCalledWith("register_global_shortcut", { accelerator: "Ctrl+Shift+K" }); + expect(set).not.toHaveBeenCalledWith({ globalShortcut: "Ctrl+Shift+K" }); + }); + + it("shows the unregister error and keeps the shortcut when clearing fails", async () => { + const set = vi.fn(); + render( + , + ); + vi.mocked(invoke).mockRejectedValueOnce(new Error("Failed to clear shortcuts: busy")); + + fireEvent.click(screen.getByRole("button", { name: "ShortcutClearButton" })); + + expect(await screen.findByRole("alert")).toHaveTextContent("Failed to clear shortcuts: busy"); + expect(invoke).toHaveBeenCalledWith("unregister_global_shortcut"); + expect(set).not.toHaveBeenCalledWith({ globalShortcut: "" }); + }); + + it("saves the shortcut after it registers", async () => { + const set = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "ShortcutRecordButton" })); + fireEvent.keyDown(window, { key: "k", code: "KeyK", ctrlKey: true, shiftKey: true }); + + await waitFor(() => expect(set).toHaveBeenCalledWith({ globalShortcut: "Ctrl+Shift+K" })); + expect(screen.queryByRole("alert")).toBeNull(); + }); + + it("quits from the footer button", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "QuitCodexBar" })); + expect(invoke).toHaveBeenCalledWith("quit_app"); + }); + + it("opens the provider switcher shortcuts dialog and closes it with Escape or Done", () => { + render(); + expect(screen.queryByRole("dialog")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "SwitcherShortcutsOpenButton" })); + const dialog = screen.getByRole("dialog", { name: "SwitcherShortcutsTitle" }); + expect( + within(dialog).getByRole("button", { name: "SwitcherShortcutReset" }), + ).toBeInTheDocument(); + + fireEvent.keyDown(dialog, { key: "Escape" }); + expect(screen.queryByRole("dialog")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "SwitcherShortcutsOpenButton" })); + fireEvent.click(screen.getByRole("button", { name: "DialogDone" })); + expect(screen.queryByRole("dialog")).toBeNull(); + }); +}); + +describe("GeneralTab notifications pane", () => { + it("orders the Alerts rows like the Mac pane", () => { + const { container } = render( + , + ); + const alerts = container.querySelector(".settings-group") as HTMLElement; + const labels = Array.from( + alerts.querySelectorAll(".settings-field__label"), + (node) => node.textContent, + ); + expect(labels).toEqual([ + "CredentialExpiryNotifications", + "SessionQuotaNotifications", + "ShowNotifications", + "PredictivePaceWarnings", + ]); + }); + + it("saves quota depleted and restored alerts as their own key", () => { + const set = vi.fn(); + render( + , + ); + const toggle = screen.getByRole("checkbox", { name: "SessionQuotaNotifications" }); + expect(toggle).toBeChecked(); + fireEvent.click(toggle); + expect(set).toHaveBeenCalledWith({ sessionQuotaNotificationsEnabled: false }); + }); + + it("falls back to show notifications when the quota key is absent", () => { + render( + , + ); + expect( + screen.getByRole("checkbox", { name: "SessionQuotaNotifications" }), + ).not.toBeChecked(); + }); + + it("locks credential and pace alerts and hides thresholds while usage warnings are off", () => { + render( + , + ); + const pace = screen.getByRole("checkbox", { name: "PredictivePaceWarnings" }); + const credential = screen.getByRole("checkbox", { name: "CredentialExpiryNotifications" }); + expect(pace).toBeDisabled(); + expect(pace).toBeChecked(); + expect(credential).toBeDisabled(); + expect(credential).toBeChecked(); + expect(screen.getByRole("checkbox", { name: "SessionQuotaNotifications" })).toBeEnabled(); + expect(screen.queryByText("WarningAtPercentUsed")).toBeNull(); + expect(screen.queryByText("ThresholdOverridesTitle")).toBeNull(); + expect(screen.getByRole("checkbox", { name: "SoundEnabled" })).toBeInTheDocument(); + }); + + it("shows % used thresholds and overrides while usage warnings are on", () => { + render( + , + ); + expect(screen.getByText("WarningAtPercentUsed")).toBeInTheDocument(); + expect(screen.getByText("CriticalAtPercentUsed")).toBeInTheDocument(); + expect(screen.getByText("ThresholdOverridesTitle")).toBeInTheDocument(); + expect(screen.getByText("ThresholdsCaption")).toBeInTheDocument(); + }); + + it("hides the sound set and per-event sounds while sound is off", () => { + render( + , + ); + expect(screen.queryByRole("combobox", { name: "NotificationSoundTheme" })).toBeNull(); + expect(screen.queryAllByRole("button", { name: /NotificationTestSound$/ })).toHaveLength(0); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx index e4c48b2d6e..3bc75c2ba7 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx @@ -2,9 +2,22 @@ import { useCallback, useEffect, useState } from "react"; import { useLocale } from "../../../hooks/useLocale"; import { invoke } from "@tauri-apps/api/core"; import { open } from "@tauri-apps/plugin-dialog"; -import { getAppInfo, playNotificationSound, quitApp } from "../../../lib/tauri"; +import { + getAppInfo, + playNotificationSound, + quitApp, + registerGlobalShortcut, + unregisterGlobalShortcut, +} from "../../../lib/tauri"; import type { AppInfoBridge } from "../../../types/bridge"; -import { Field, NumberInput, Select, Toggle } from "../../../components/FormControls"; +import { + Field, + NumberInput, + Select, + SettingsGroup, + Toggle, +} from "../../../components/FormControls"; +import { ShortcutCapture } from "../../../components/ShortcutCapture"; import type { Language, LanguageOption, @@ -17,6 +30,8 @@ import type { import type { LocaleKey } from "../../../i18n/keys"; import { CURRENCY_PICKER_OPTIONS } from "../../../lib/currency"; import type { TabProps } from "../settingsTabs"; +import PreferencesTransferSection from "./PreferencesTransferSection"; +import SwitcherShortcutsDialog from "../SwitcherShortcutsDialog"; const FALLBACK_LANGUAGE_OPTIONS: LanguageOption[] = [ { value: "english", display: "English" }, @@ -31,14 +46,17 @@ const FALLBACK_LANGUAGE_OPTIONS: LanguageOption[] = [ { value: "ukrainian", display: "Українська" }, ]; +// Mac order (Manual, minutes, Adaptive last) plus the Windows "1 hour", +// which existing installs may have saved. const REFRESH_CADENCE_OPTIONS: { value: string; labelKey: LocaleKey }[] = [ { value: "0", labelKey: "RefreshIntervalManual" }, - { value: "adaptive", labelKey: "RefreshIntervalAdaptive" }, { value: "60", labelKey: "RefreshInterval1Min" }, + { value: "120", labelKey: "RefreshInterval2Min" }, { value: "300", labelKey: "RefreshInterval5Min" }, { value: "900", labelKey: "RefreshInterval15Min" }, { value: "1800", labelKey: "RefreshInterval30Min" }, { value: "3600", labelKey: "RefreshInterval1Hour" }, + { value: "adaptive", labelKey: "RefreshIntervalAdaptive" }, ]; const NOTIFICATION_SOUND_THEME_OPTIONS: { @@ -109,7 +127,7 @@ const THEME_OPTIONS: { value: ThemePreference; labelKey: LocaleKey }[] = [ { value: "auto", labelKey: "ThemeAutoOption" }, { value: "light", labelKey: "ThemeLightOption" }, { value: "dark", labelKey: "ThemeDarkOption" }, -] +]; function fileName(path: string): string { return path.split(/[\\/]/).pop() ?? path; @@ -156,6 +174,7 @@ function ThresholdOverrideInputs({
(null); const [soundError, setSoundError] = useState(null); + const [shortcutError, setShortcutError] = useState(null); + const [switcherOpen, setSwitcherOpen] = useState(false); const [languageOptions, setLanguageOptions] = useState( FALLBACK_LANGUAGE_OPTIONS, ); @@ -262,206 +284,409 @@ export default function GeneralTab({ [set, settings.notificationSoundPaths], ); + const commitShortcut = useCallback( + async (accelerator: string) => { + setShortcutError(null); + try { + await registerGlobalShortcut(accelerator); + set({ globalShortcut: accelerator }); + } catch (err: unknown) { + setShortcutError(err instanceof Error ? err.message : String(err)); + } + }, + [set], + ); + + const clearShortcut = useCallback(async () => { + setShortcutError(null); + try { + await unregisterGlobalShortcut(); + set({ globalShortcut: "" }); + } catch (err: unknown) { + setShortcutError(err instanceof Error ? err.message : String(err)); + } + }, [set]); + + if (mode === "notifications") { + return ( + void handleChooseSound(pathKey)} + onClearSound={handleClearSound} + /> + ); + } + + const refreshValue = settings.adaptiveRefresh + ? "adaptive" + : String(settings.refreshIntervalSecs); + const refreshOptions = REFRESH_CADENCE_OPTIONS.map((o) => ({ + value: o.value, + label: t(o.labelKey), + })); + // A saved interval outside the menu (for example 30 s from an older build) + // stays visible instead of the menu silently showing another value. + if (!refreshOptions.some((o) => o.value === refreshValue)) { + refreshOptions.push({ + value: refreshValue, + label: `${settings.refreshIntervalSecs} s`, + }); + } + const manualRefresh = !settings.adaptiveRefresh && settings.refreshIntervalSecs === 0; + return ( <> - {mode === "general" &&
-

{t("SectionLanguage")}

-
- - ({ + value: opt.value, + label: opt.display, + }))} + onChange={(v) => set({ uiLanguage: v as Language })} + /> + + + ({ + value: option.value, + label: t(option.labelKey), + }))} + onChange={(value) => set({ theme: value as ThemePreference })} + /> + + + set({ startAtLogin: v })} + /> + + + set({ startMinimized: v })} + /> + + + + {t("RefreshManualFooter")}

: undefined} + > + + set({ + lowPowerModePreference: v as "off" | "on" | "automatic", + })} + /> + +
+ + setSwitcherOpen(true)} /> + + + {shortcutError && ( +

{shortcutError}

+ )} +

{t("ShortcutRecordingHint")}

+ + } + > + + void commitShortcut(accel)} + onClear={() => void clearShortcut()} + /> + +
+ +
+ + {appVersion && + `${t("Version")} ${appVersion.version}${ + appVersion.buildNumber !== "dev" ? ` (${appVersion.buildNumber})` : "" + }`} + + +
+ + {switcherOpen && ( + setSwitcherOpen(false)} + /> + )} + + ); +} + +function NotificationsPane({ + settings, + set, + saving, + playingSound, + soundError, + onTestSound, + onChooseSound, + onClearSound, +}: TabProps & { + playingSound: NotificationSoundEvent | null; + soundError: string | null; + onTestSound: (event: NotificationSoundEvent) => void; + onChooseSound: (pathKey: NotificationSoundPathKey) => void; + onClearSound: (pathKey: NotificationSoundPathKey) => void; +}) { + const { t } = useLocale(); + // `show_notifications` still gates credential and pace alerts in the + // backend, so their switches lock (keeping their saved state) while + // threshold warnings are off. Quota depleted & restored has its own key. + const alertsMuted = !settings.showNotifications; + const values = settings.providerUsageThresholds ?? {}; + + return ( + <> + + + set({ credentialExpiryNotificationsEnabled: v })} + /> + + + set({ sessionQuotaNotificationsEnabled: v })} + /> + + + set({ showNotifications: v })} + /> + + + set({ predictivePaceWarningEnabled: v })} + /> + + + + {settings.showNotifications && ( + {t("ThresholdsCaption")}

}> + + ({ - value: opt.value, - label: opt.display, - }))} - onChange={(v) => set({ uiLanguage: v as Language })} + onChange={(v) => set({ highUsageThreshold: v })} /> - - ({ + ariaLabel={t("NotificationSoundTheme")} + minWidth={NOTIFICATION_SOUND_THEME_SELECT_MIN_WIDTH} + options={NOTIFICATION_SOUND_THEME_OPTIONS.map((option) => ({ value: option.value, label: t(option.labelKey), }))} - onChange={(value) => set({ theme: value as ThemePreference })} - /> - -
-
} - {mode === "general" &&
-

{t("StartupSettings")}

-
- - set({ startAtLogin: v })} - /> - - - set({ startMinimized: v })} - /> - -
-
} - - {mode === "notifications" &&
-

- {t("SectionNotifications")} -

-
- - set({ showNotifications: v })} - /> - - - set({ predictivePaceWarningEnabled: v })} - /> - - - set({ credentialExpiryNotificationsEnabled: v })} - /> - - - set({ soundEnabled: v })} + onChange={(value) => { + if (isNotificationSoundTheme(value)) { + set({ notificationSoundTheme: value }); + } + }} /> - {settings.soundEnabled && ( - <> - - ({ - value: o.value, - label: t(o.labelKey), - }))} - onChange={(v) => { - if (v === "adaptive") { - set({ adaptiveRefresh: true }); - return; - } - set({ - adaptiveRefresh: false, - refreshIntervalSecs: Number(v), - }); - }} - /> - - - set({ refreshAllProvidersOnMenuOpen: v })} - /> - - -