diff --git a/apps/desktop-tauri/src-tauri/permissions/commands.toml b/apps/desktop-tauri/src-tauri/permissions/commands.toml index 0bd54af7..e1619522 100644 --- a/apps/desktop-tauri/src-tauri/permissions/commands.toml +++ b/apps/desktop-tauri/src-tauri/permissions/commands.toml @@ -75,6 +75,7 @@ commands.allow = [ "get_launch_block_reason", "get_work_area_rect", "play_notification_sound", + "send_test_notification", "open_external_url", "reanchor_tray_panel", "quit_app", diff --git a/apps/desktop-tauri/src-tauri/src/commands/system.rs b/apps/desktop-tauri/src-tauri/src/commands/system.rs index 07189e18..03000fc9 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/system.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/system.rs @@ -247,6 +247,14 @@ pub fn play_notification_sound() -> Result<(), String> { Ok(()) } +/// Show a real Windows toast on demand so the user can confirm notifications +/// are delivered on their machine. Runs the full toast pipeline synchronously +/// and surfaces any failure back to the Settings button. +#[tauri::command] +pub fn send_test_notification() -> Result<(), String> { + codexbar::notifications::send_test_notification() +} + /// Reposition the flyout window so its bottom-right corner stays anchored to /// the system-tray area. Called from the frontend after dynamic resize. /// diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index ee9c981b..3b2ca402 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -209,6 +209,7 @@ fn main() { commands::get_launch_block_reason, commands::get_work_area_rect, commands::play_notification_sound, + commands::send_test_notification, commands::open_external_url, commands::reanchor_tray_panel, commands::quit_app, diff --git a/apps/desktop-tauri/src/components/PlanStatusCard.tsx b/apps/desktop-tauri/src/components/PlanStatusCard.tsx index 31cecae7..8bb23856 100644 --- a/apps/desktop-tauri/src/components/PlanStatusCard.tsx +++ b/apps/desktop-tauri/src/components/PlanStatusCard.tsx @@ -6,8 +6,8 @@ import { useFormattedResetTime } from "../hooks/useFormattedResetTime"; import { useLocale } from "../hooks/useLocale"; import { capacityFreshness, + codexResetCredits, glanceMeters, - resetCreditsAvailable, type ConstrainingWindow, } from "../lib/capacityPresentation"; @@ -166,7 +166,7 @@ export default function PlanStatusCard({ const freshness = capacityFreshness(provider); const planName = displayPlanName(provider.planName, provider.displayName); const inactiveSummary = inactiveWindowSummary(provider); - const resetCredits = resetCreditsAvailable(provider); + const resetCredits = codexResetCredits(provider); const className = [ "plan-status-card", @@ -205,7 +205,9 @@ export default function PlanStatusCard({ )} {resetCredits != null && ( - + ↻ {resetCredits} {resetCredits === 1 ? "reset available" : "resets available"} )} diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index fe758cba..c887e514 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -377,6 +377,12 @@ export const ALL_LOCALE_KEYS = [ "ShortcutEmptyPlaceholder", "NotificationTestSound", "NotificationTestSoundPlaying", + "NotificationTest", + "NotificationTestHelper", + "NotificationTestButton", + "NotificationTestSending", + "NotificationTestSent", + "NotificationTestFailed", "TrayIconModeLabel", "TrayIconModeHelper", "TrayIconModeSingle", diff --git a/apps/desktop-tauri/src/lib/capacityPresentation.test.ts b/apps/desktop-tauri/src/lib/capacityPresentation.test.ts index 98b63d0c..0c65b48c 100644 --- a/apps/desktop-tauri/src/lib/capacityPresentation.test.ts +++ b/apps/desktop-tauri/src/lib/capacityPresentation.test.ts @@ -7,6 +7,7 @@ import { activePromoInclusions, providerGlanceStatus, resetCreditsAvailable, + codexResetCredits, } from "./capacityPresentation"; import type { ProviderUsageSnapshot, RateWindowSnapshot } from "../types/bridge"; @@ -211,4 +212,23 @@ describe("capacityPresentation", () => { expect(resetCreditsAvailable(snap)).toBe(1); expect(glanceMeters(snap).companions).toEqual([]); }); + + it("reports a known zero banked-reset count instead of hiding it", () => { + expect(resetCreditsAvailable(provider({ resetCreditsAvailable: 0 }))).toBe(0); + }); + + it("exposes Codex banked resets only for Codex, including the zero state", () => { + // Codex is the only provider with banked resets, so the persistent + // indicator stays Codex-scoped and shows even when the count is zero. + expect( + codexResetCredits(provider({ providerId: "codex", resetCreditsAvailable: 0 })), + ).toBe(0); + expect( + codexResetCredits(provider({ providerId: "codex", resetCreditsAvailable: 3 })), + ).toBe(3); + // Another provider reporting the field must never light up the Codex chip. + expect( + codexResetCredits(provider({ providerId: "cursor", resetCreditsAvailable: 2 })), + ).toBeNull(); + }); }); diff --git a/apps/desktop-tauri/src/lib/capacityPresentation.ts b/apps/desktop-tauri/src/lib/capacityPresentation.ts index 06e312a1..8f5448c9 100644 --- a/apps/desktop-tauri/src/lib/capacityPresentation.ts +++ b/apps/desktop-tauri/src/lib/capacityPresentation.ts @@ -130,13 +130,17 @@ function nonPrimaryWindows( return out; } -/** Available provider-reported resets, including legacy cached snapshots. */ +/** + * Provider-reported banked resets, including legacy cached snapshots. Returns + * the observed count — `0` is a real reading and stays distinct from `null`, + * which means the provider does not report resets (or the fetch is unknown). + */ export function resetCreditsAvailable( provider: ProviderUsageSnapshot, ): number | null { if ( typeof provider.resetCreditsAvailable === "number" && - provider.resetCreditsAvailable > 0 + provider.resetCreditsAvailable >= 0 ) { return Math.floor(provider.resetCreditsAvailable); } @@ -147,6 +151,19 @@ export function resetCreditsAvailable( return match ? Number(match[0]) : null; } +/** + * Codex banked resets for the persistent indicator. Codex is the only provider + * with this concept, so it is shown Codex-only and always (including the `0` + * state) whenever we have a trustworthy reading, keeping the feature visible + * and building trust that Ceiling is tracking it. + */ +export function codexResetCredits( + provider: ProviderUsageSnapshot, +): number | null { + if (provider.providerId !== "codex") return null; + return resetCreditsAvailable(provider); +} + /** * Every measured rate window for a provider (plan pool + session/weekly/model/ * extra lanes), each carrying its display label. Powers the Activity timeline, diff --git a/apps/desktop-tauri/src/lib/tauri.ts b/apps/desktop-tauri/src/lib/tauri.ts index cd5820a3..0684b569 100644 --- a/apps/desktop-tauri/src/lib/tauri.ts +++ b/apps/desktop-tauri/src/lib/tauri.ts @@ -436,6 +436,11 @@ export function playNotificationSound(): Promise { return invoke("play_notification_sound"); } +/** Show a real toast so the user can confirm notifications are delivered. */ +export function sendTestNotification(): Promise { + return invoke("send_test_notification"); +} + export function reanchorTrayPanel(): Promise { return invoke("reanchor_tray_panel"); } diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index c1fcc4d3..d5054f06 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -1273,6 +1273,13 @@ body:has(.taskbar-flyout-frame) #root { line-height: 14px; } +/* No banked resets: keep the indicator visible but quiet — nothing to act on. */ +.taskbar-flyout__reset-credit--empty { + border-color: rgba(224, 247, 255, 0.18); + background: transparent; + color: rgba(224, 247, 255, 0.6); +} + .taskbar-flyout__provider-status { margin-top: 4px; overflow: hidden; @@ -4375,6 +4382,13 @@ html:has(.menu-surface--tray) { line-height: 16px; } +/* No banked resets: keep the indicator visible but quiet — nothing to act on. */ +.plan-status-card__reset-credit--empty { + border-color: color-mix(in srgb, var(--text-secondary) 22%, transparent); + background: transparent; + color: var(--text-tertiary, var(--text-secondary)); +} + .plan-status-card__error { margin: 0; font-size: 11px; @@ -6644,6 +6658,13 @@ body:has(.dashboard-shell) #root { line-height: 1; white-space: nowrap; } + +/* No banked resets: keep the indicator visible but quiet — nothing to act on. */ +.provider-focus__reset-credit--empty { + border-color: color-mix(in srgb, var(--text-secondary) 22%, transparent); + background: transparent; + color: var(--text-tertiary, var(--text-secondary)); +} .provider-focus__primary { padding: 0 0 20px; } diff --git a/apps/desktop-tauri/src/surfaces/ProviderDetailView.tsx b/apps/desktop-tauri/src/surfaces/ProviderDetailView.tsx index 0268d29c..53082c94 100644 --- a/apps/desktop-tauri/src/surfaces/ProviderDetailView.tsx +++ b/apps/desktop-tauri/src/surfaces/ProviderDetailView.tsx @@ -12,7 +12,7 @@ import { useLocale } from "../hooks/useLocale"; import { formatRelativeUpdated } from "../lib/relativeTime"; import { getProviderChartData } from "../lib/tauri"; import { providerSupportsChartData } from "../lib/providerCharts"; -import { allMeasuredWindows, resetCreditsAvailable } from "../lib/capacityPresentation"; +import { allMeasuredWindows, codexResetCredits } from "../lib/capacityPresentation"; type DetailWindow = { id: string; @@ -229,7 +229,7 @@ export default function ProviderDetailView({ const primaryPercent = Math.round(percentFor(provider.primary, showAsUsed)); const primaryLabel = provider.primaryLabel?.trim() || "Primary"; const planName = displayPlanName(provider.planName); - const resetCredits = resetCreditsAvailable(provider); + const resetCredits = codexResetCredits(provider); const updated = Number.isNaN(Date.parse(provider.updatedAt)) ? provider.updatedAt : formatRelativeUpdated(Date.parse(provider.updatedAt), t); @@ -251,7 +251,9 @@ export default function ProviderDetailView({
{planName && {planName}} {resetCredits != null && ( - + ↻ {resetCredits} {resetCredits === 1 ? "reset available" : "resets available"} )} diff --git a/apps/desktop-tauri/src/surfaces/TaskbarFlyout.tsx b/apps/desktop-tauri/src/surfaces/TaskbarFlyout.tsx index 2e980ff0..085077a3 100644 --- a/apps/desktop-tauri/src/surfaces/TaskbarFlyout.tsx +++ b/apps/desktop-tauri/src/surfaces/TaskbarFlyout.tsx @@ -6,7 +6,7 @@ import { useProviders } from "../hooks/useProviders"; import { useSettings } from "../hooks/useSettings"; import { getProviderIcon } from "../components/providers/providerIcons"; import { orderProviderSnapshots } from "../lib/providerOrder"; -import { allMeasuredWindows, resetCreditsAvailable, type ConstrainingWindow } from "../lib/capacityPresentation"; +import { allMeasuredWindows, codexResetCredits, type ConstrainingWindow } from "../lib/capacityPresentation"; import { dismissTrayPanel, getTaskbarSurfaceColor, @@ -112,7 +112,7 @@ function ProviderRow({ provider, showAsUsed, now }: { ); } const windows = flyoutWindows(provider); - const resetCredits = resetCreditsAvailable(provider); + const resetCredits = codexResetCredits(provider); const hiddenWindowCount = Math.max( 0, allMeasuredWindows(provider).filter(isUtilityWindow).length - windows.length, @@ -124,7 +124,9 @@ function ProviderRow({ provider, showAsUsed, now }: {
{provider.displayName} {resetCredits != null && ( - + ↻ {resetCredits} {resetCredits === 1 ? "reset ready" : "resets ready"} )} 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 31653de2..97237a55 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx @@ -1,10 +1,19 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; vi.mock("../../../hooks/useLocale", () => ({ useLocale: () => ({ t: (key: string) => key }), })); +const { sendTestNotificationMock } = vi.hoisted(() => ({ + sendTestNotificationMock: vi.fn(() => Promise.resolve()), +})); + +vi.mock("../../../lib/tauri", () => ({ + playNotificationSound: vi.fn(() => Promise.resolve()), + sendTestNotification: sendTestNotificationMock, +})); + import GeneralTab from "./GeneralTab"; import type { SettingsSnapshot } from "../../../types/bridge"; @@ -106,6 +115,42 @@ describe("GeneralTab", () => { expect(set).toHaveBeenCalledWith({ capacityEventNotificationsEnabled: false }); }); + it("sends a real test notification so delivery can be verified", async () => { + sendTestNotificationMock.mockClear(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "NotificationTestButton" })); + + expect(sendTestNotificationMock).toHaveBeenCalledTimes(1); + await waitFor(() => + expect( + screen.getByRole("button", { name: "NotificationTestSent" }), + ).toBeInTheDocument(), + ); + }); + + it("disables the test notification button when notifications are off", () => { + render( + , + ); + + expect( + screen.getByRole("button", { name: "NotificationTestButton" }), + ).toBeDisabled(); + }); + it("uses one global usage warning threshold", () => { const set = vi.fn(); render( diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx index 71402334..674078c2 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx @@ -1,9 +1,11 @@ import { useCallback, useState } from "react"; import { useLocale } from "../../../hooks/useLocale"; -import { playNotificationSound } from "../../../lib/tauri"; +import { playNotificationSound, sendTestNotification } from "../../../lib/tauri"; import { Field, NumberInput, Toggle } from "../../../components/FormControls"; import type { TabProps } from "../../Settings"; +type TestNotificationStatus = "idle" | "sending" | "sent" | "failed"; + export default function GeneralTab({ mode = "general", settings, @@ -12,6 +14,7 @@ export default function GeneralTab({ }: TabProps & { mode?: "general" | "notifications" }) { const { t } = useLocale(); const [playingSound, setPlayingSound] = useState(false); + const [testStatus, setTestStatus] = useState("idle"); const handleTestSound = useCallback(() => { setPlayingSound(true); @@ -19,6 +22,16 @@ export default function GeneralTab({ window.setTimeout(() => setPlayingSound(false), 1500); }, []); + const handleTestNotification = useCallback(() => { + setTestStatus("sending"); + void sendTestNotification() + .then(() => setTestStatus("sent")) + .catch(() => setTestStatus("failed")) + .finally(() => { + window.setTimeout(() => setTestStatus("idle"), 4000); + }); + }, []); + return ( <> {mode === "general" &&
@@ -73,6 +86,32 @@ export default function GeneralTab({ onChange={(v) => set({ capacityEventNotificationsEnabled: v })} /> + +
+ +
+
String { - s.replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) - .replace('\'', "'") - } - let safe_title = xml_escape(title); - let safe_body = xml_escape(body); - - // Uses ToastGeneric (Win 10+) and wraps in try/catch so PowerShell exits - // with code 1 on failure rather than swallowing the error silently. - // Single-quoted here-string (@'...'@) prevents variable expansion of the - // XML content by PowerShell. - let script = format!( - r#"try {{ - [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null - [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null - $template = @' -{}{} -'@ - $xml = New-Object Windows.Data.Xml.Dom.XmlDocument - $xml.LoadXml($template) - $toast = [Windows.UI.Notifications.ToastNotification]::new($xml) - $notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("Ceiling") - if ($null -eq $notifier) {{ throw "CreateToastNotifier returned null" }} - $notifier.Show($toast) -}} catch {{ - [System.Console]::Error.WriteLine("Ceiling toast failed: $_") - exit 1 -}}"#, - safe_title, safe_body - ); + ensure_aumid_registered_once(); + // Fire-and-forget on the normal notification path: a provider refresh must + // never block on PowerShell. Failures are logged, not surfaced. match Command::new("powershell") .args([ "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", - &script, + &toast_powershell_script(title, body), ]) .creation_flags(0x08000000) // CREATE_NO_WINDOW .spawn() @@ -761,6 +722,122 @@ impl Default for NotificationManager { } } +/// Escape a string for inclusion in the toast XML payload. +#[cfg(target_os = "windows")] +fn xml_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +/// Build the PowerShell snippet that shows a single Ceiling toast. Uses +/// ToastGeneric (Win 10+) and wraps the body in try/catch so PowerShell exits +/// with code 1 on failure instead of swallowing the error. A single-quoted +/// here-string (@'...'@) prevents PowerShell from expanding the XML content. +#[cfg(target_os = "windows")] +fn toast_powershell_script(title: &str, body: &str) -> String { + format!( + r#"try {{ + [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null + [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null + $template = @' +{}{} +'@ + $xml = New-Object Windows.Data.Xml.Dom.XmlDocument + $xml.LoadXml($template) + $toast = [Windows.UI.Notifications.ToastNotification]::new($xml) + $notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("Ceiling") + if ($null -eq $notifier) {{ throw "CreateToastNotifier returned null" }} + $notifier.Show($toast) +}} catch {{ + [System.Console]::Error.WriteLine("Ceiling toast failed: $_") + exit 1 +}}"#, + xml_escape(title), + xml_escape(body) + ) +} + +/// Register the AUMID at most once per process before showing a toast. +#[cfg(target_os = "windows")] +fn ensure_aumid_registered_once() { + use std::sync::Once; + static AUMID_INIT: Once = Once::new(); + AUMID_INIT.call_once(ensure_aumid_registered); +} + +/// Show a Ceiling toast synchronously and report whether the OS actually +/// accepted it. Powers the Settings "Send test notification" button, so it +/// deliberately bypasses the startup-baseline gate, the per-refresh limit, and +/// the rolling cooldown: the user asked for it right now and needs to see the +/// real Windows toast pipeline (AUMID registration + PowerShell + WinRT) +/// succeed or fail end-to-end. Blocking is fine — this runs off a user click, +/// never the refresh loop. +#[cfg(target_os = "windows")] +pub fn send_test_notification() -> Result<(), String> { + use std::os::windows::process::CommandExt; + use std::process::Command; + + ensure_aumid_registered_once(); + + let title = "Ceiling notifications are on"; + let body = "This is a test. Unexpected resets and usage alerts will look like this."; + + let output = Command::new("powershell") + .args([ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + &toast_powershell_script(title, body), + ]) + .creation_flags(0x08000000) // CREATE_NO_WINDOW + .output() + .map_err(|e| format!("Could not launch the Windows notifier: {e}"))?; + + if output.status.success() { + tracing::debug!("Test toast notification shown"); + return Ok(()); + } + + let detail = String::from_utf8_lossy(&output.stderr); + let detail = detail.trim(); + tracing::warn!("Test toast notification failed: {detail}"); + if detail.is_empty() { + Err("Windows rejected the notification. Check notification settings.".to_string()) + } else { + Err(detail.to_string()) + } +} + +/// Non-Windows fallback for the test-notification button. +#[cfg(not(target_os = "windows"))] +pub fn send_test_notification() -> Result<(), String> { + use std::process::Command; + + let title = "Ceiling notifications are on"; + let body = "This is a test. Unexpected resets and usage alerts will look like this."; + + match Command::new("notify-send") + .args(["--app-name=Ceiling", "--icon=dialog-information", title, body]) + .output() + { + Ok(output) if output.status.success() => Ok(()), + Ok(output) => { + let detail = String::from_utf8_lossy(&output.stderr); + let detail = detail.trim(); + if detail.is_empty() { + Err("The system notifier rejected the notification.".to_string()) + } else { + Err(detail.to_string()) + } + } + Err(e) => Err(format!("No system notifier available: {e}")), + } +} + /// Register the Ceiling App User Model ID (AUMID) in the Windows registry so that /// `CreateToastNotifier("Ceiling")` resolves to a valid notifier instead of returning /// null. Must be called at least once before the first toast. Safe to call multiple diff --git a/rust/src/providers/codex/api.rs b/rust/src/providers/codex/api.rs index 99db4706..104dec19 100755 --- a/rust/src/providers/codex/api.rs +++ b/rust/src/providers/codex/api.rs @@ -704,8 +704,11 @@ fn timestamp_to_datetime(timestamp: Option) -> Option> { } fn with_reset_credits(mut usage: UsageSnapshot, reset_credits: &ResetCredits) -> UsageSnapshot { - usage.reset_credits_available = - (reset_credits.available_count > 0).then_some(reset_credits.available_count); + // Record the observed count even when it is zero. A successful fetch that + // reports no banked resets is a real, trustworthy reading, and surfaces show + // an explicit "0 resets" state so the feature stays discoverable. `None` + // remains reserved for "not fetched / unknown" (e.g. the sub-request failed). + usage.reset_credits_available = Some(reset_credits.available_count); usage } @@ -935,6 +938,21 @@ mod tests { assert_eq!(credits.credits.len(), 1); } + #[test] + fn zero_reset_credits_are_recorded_as_a_known_zero() { + // A successful fetch reporting no banked resets must surface as Some(0), + // not None, so the UI can show an explicit, discoverable "0 resets" + // state instead of hiding the feature entirely. + let usage = with_reset_credits( + UsageSnapshot::new(RateWindow::default()), + &ResetCredits { + credits: Vec::new(), + available_count: 0, + }, + ); + assert_eq!(usage.reset_credits_available, Some(0)); + } + #[test] fn maps_codex_spark_additional_rate_limits() { let api = CodexApi::new();