diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 8f24fc3818..6d22005b24 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -194,6 +194,8 @@ pub struct ProviderUsageSnapshot { pub updated_at: String, #[serde(default)] pub error: Option, + #[serde(default = "default_error_state")] + pub error_state: codexbar::core::ProviderStateKind, #[serde(default)] pub pace: Option, #[serde(default)] @@ -216,6 +218,10 @@ fn default_source_label() -> String { "seed".to_string() } +fn default_error_state() -> codexbar::core::ProviderStateKind { + codexbar::core::ProviderStateKind::Unknown +} + /// Provider payload after applying settings-driven cross-surface presentation. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -380,6 +386,7 @@ impl ProviderUsageSnapshot { source_label: result.source_label.clone(), updated_at: usage.updated_at.to_rfc3339(), error: None, + error_state: codexbar::core::ProviderStateKind::Ready, pace, account_organization: usage.account_organization.clone(), tray_status_label: None, @@ -389,7 +396,12 @@ impl ProviderUsageSnapshot { } } - pub(super) fn from_error(id: ProviderId, metadata: &ProviderMetadata, error: String) -> Self { + pub(super) fn from_error( + id: ProviderId, + metadata: &ProviderMetadata, + error: String, + state_kind: codexbar::core::ProviderStateKind, + ) -> Self { let error = friendly_provider_error(id, &error); Self { provider_id: id.cli_name().to_string(), @@ -420,6 +432,7 @@ impl ProviderUsageSnapshot { source_label: String::new(), updated_at: chrono::Utc::now().to_rfc3339(), error: Some(error), + error_state: state_kind, pace: None, account_organization: None, tray_status_label: None, diff --git a/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs b/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs index 252a3885d3..8d80136076 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs @@ -31,6 +31,8 @@ pub struct ProviderDetail { // Error / state. pub last_error: Option, + /// Backend-classified availability state for the latest refresh. + pub error_state: Option, // URLs for quick-actions (button visibility). pub dashboard_url: Option, @@ -87,6 +89,7 @@ pub(crate) fn build_provider_detail(provider_id: &str) -> Result ProviderUsageSnapshot::from_error( id, &metadata, - codexbar::logging::safe_error_message(e), + codexbar::logging::safe_error_message(&e), + provider.error_state_kind(&e), + ), + Err(_) => ProviderUsageSnapshot::from_error( + id, + &metadata, + "Timeout".to_string(), + codexbar::core::ProviderStateKind::Unknown, ), - Err(_) => ProviderUsageSnapshot::from_error(id, &metadata, "Timeout".to_string()), }; record_provider_fetch_duration(id, &mut snapshot, started); @@ -1057,7 +1063,12 @@ mod predictive_warning_tests { let metadata = codexbar::core::instantiate_provider(ProviderId::Claude) .metadata() .clone(); - ProviderUsageSnapshot::from_error(ProviderId::Claude, &metadata, "unused".to_string()) + ProviderUsageSnapshot::from_error( + ProviderId::Claude, + &metadata, + "unused".to_string(), + codexbar::core::ProviderStateKind::Unknown, + ) } #[test] @@ -1163,6 +1174,7 @@ mod reset_backfill_tests { source_label: String::new(), updated_at: "2026-01-01T00:00:00Z".into(), error: None, + error_state: codexbar::core::ProviderStateKind::Ready, pace: None, account_organization: None, tray_status_label: None, diff --git a/apps/desktop-tauri/src-tauri/src/commands/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/tests.rs index 183067aa23..8d58b7192e 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/tests.rs @@ -952,6 +952,7 @@ fn claude_transient_auth_failure_preserves_first_last_good_snapshot() { ProviderId::Claude, &metadata, "Unauthorized".to_string(), + codexbar::core::ProviderStateKind::NeedsAuthentication, ); let mut state = crate::state::AppState::new(); state.provider_cache.push(good.clone()); @@ -981,6 +982,7 @@ fn claude_repeated_auth_failure_surfaces_error() { ProviderId::Claude, &metadata, "Unauthorized".to_string(), + codexbar::core::ProviderStateKind::NeedsAuthentication, ); let second_error = first_error.clone(); let mut state = crate::state::AppState::new(); @@ -1015,6 +1017,7 @@ fn claude_cli_parse_failure_keeps_last_good_every_time() { ProviderId::Claude, &metadata, "Parse error: Empty output from Claude CLI".to_string(), + codexbar::core::ProviderStateKind::Unknown, ); let mut state = crate::state::AppState::new(); state.provider_cache.push(good.clone()); @@ -1050,6 +1053,7 @@ fn claude_hard_credentials_missing_does_not_preserve_stale() { &metadata, "OAuth error: Claude OAuth credentials not found. Run `claude` to authenticate." .to_string(), + codexbar::core::ProviderStateKind::NeedsAuthentication, ); let mut state = crate::state::AppState::new(); state.provider_cache.push(good); @@ -1057,6 +1061,11 @@ fn claude_hard_credentials_missing_does_not_preserve_stale() { let out = super::providers::preserve_last_good_transient_failure(&mut state, ProviderId::Claude, err); assert!(out.error.is_some()); + assert_eq!( + out.error_state, + codexbar::core::ProviderStateKind::NeedsAuthentication, + "hard auth failure must carry its classification on the snapshot" + ); } #[test] diff --git a/apps/desktop-tauri/src-tauri/src/powertoys.rs b/apps/desktop-tauri/src-tauri/src/powertoys.rs index b33d8c7973..492385c336 100644 --- a/apps/desktop-tauri/src-tauri/src/powertoys.rs +++ b/apps/desktop-tauri/src-tauri/src/powertoys.rs @@ -201,6 +201,7 @@ mod tests { source_label: "web".to_string(), updated_at: "2026-07-09T00:00:00Z".to_string(), error: None, + error_state: codexbar::core::ProviderStateKind::Ready, pace: None, account_organization: Some("Example Org".to_string()), tray_status_label: None, @@ -246,6 +247,7 @@ mod tests { source_label: "web".to_string(), updated_at: "2026-07-09T00:00:00Z".to_string(), error: None, + error_state: codexbar::core::ProviderStateKind::Ready, pace: None, account_organization: None, tray_status_label: None, diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index 694c4e9bbd..f621397f5a 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -1082,6 +1082,7 @@ mod tests { source_label: String::new(), updated_at: "2025-01-01T00:00:00Z".into(), error: None, + error_state: codexbar::core::ProviderStateKind::Ready, pace: None, account_organization: None, tray_status_label: None, diff --git a/apps/desktop-tauri/src-tauri/src/usage_metric.rs b/apps/desktop-tauri/src-tauri/src/usage_metric.rs index c464f75c57..4838ee757b 100644 --- a/apps/desktop-tauri/src-tauri/src/usage_metric.rs +++ b/apps/desktop-tauri/src-tauri/src/usage_metric.rs @@ -213,6 +213,7 @@ mod tests { source_label: "test".to_string(), updated_at: "2026-08-16T00:00:00Z".to_string(), error: None, + error_state: codexbar::core::ProviderStateKind::Ready, pace: None, account_organization: None, tray_status_label: None, diff --git a/apps/desktop-tauri/src/components/MenuCard.test.tsx b/apps/desktop-tauri/src/components/MenuCard.test.tsx index 15e3eadc5a..c7838cf8e6 100644 --- a/apps/desktop-tauri/src/components/MenuCard.test.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.test.tsx @@ -71,6 +71,7 @@ function provider( sourceLabel: "oauth", updatedAt: "2026-05-24T00:00:00Z", error, + errorState: "unknown", pace: null, accountOrganization: null, trayStatusLabel: null, diff --git a/apps/desktop-tauri/src/components/providerGridUtils.test.ts b/apps/desktop-tauri/src/components/providerGridUtils.test.ts index 2b219fa29d..e6336ad02d 100644 --- a/apps/desktop-tauri/src/components/providerGridUtils.test.ts +++ b/apps/desktop-tauri/src/components/providerGridUtils.test.ts @@ -44,6 +44,7 @@ function provider(id: string): ProviderUsageSnapshot { sourceLabel: "oauth", updatedAt: "2026-07-31T00:00:00Z", error: null, + errorState: "ready", pace: null, accountOrganization: null, trayStatusLabel: null, diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.css b/apps/desktop-tauri/src/floatbar/FloatBar.css index 188957ac36..c809df443f 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.css +++ b/apps/desktop-tauri/src/floatbar/FloatBar.css @@ -103,6 +103,14 @@ body.floatbar-window #root { .floatbar__empty * { pointer-events: none; } + +.floatbar__cost-estimate { + color: rgba(24, 42, 54, 0.72); + font-size: calc(8px * var(--floatbar-scale, 1)); + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; +} .floatbar__provider-icon { display: inline-flex; align-items: center; diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx index 567ea0bff7..49ec2537e9 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx @@ -73,6 +73,7 @@ function snapshot( opts: { exhausted?: boolean; error?: string | null; + errorState?: ProviderUsageSnapshot["errorState"]; resetsAt?: string | null; resetDescription?: string | null; informational?: boolean; @@ -117,6 +118,7 @@ function snapshot( sourceLabel: "auto", updatedAt: "2026-05-15T00:00:00Z", error: opts.error ?? null, + errorState: opts.errorState ?? "ready", pace: null, accountOrganization: null, trayStatusLabel: null, @@ -221,6 +223,11 @@ describe("FloatBar", () => { TrayResetsDueNow: "Resetting", PanelToday: "Today", PanelUsedSuffix: "used", + OverviewSpendEstimate: "Estimate", + ProviderIssueAuthRequired: "Sign-in required", + ProviderIssueSessionExpired: "Session expired", + ProviderIssueLocalRuntimeOffline: "Local runtime offline", + ProviderIssueUnknown: "Usage unavailable", FloatBarThirtyDayShort: "30d", FloatBarNoProviders: "No providers", FloatBarRemainingSuffix: "remaining", @@ -393,6 +400,31 @@ describe("FloatBar", () => { expect(tauriMocks.getProviderChartData).not.toHaveBeenCalled(); }); + it("marks displayed local cost as an estimate", async () => { + tauriMocks.getCachedProviders.mockResolvedValue([snapshot("codex", "Codex", 75)]); + tauriMocks.getSettingsSnapshot.mockResolvedValue(settings({ floatBarShowCost: true })); + tauriMocks.getProviderLocalUsageSummary.mockResolvedValue({ + todayCost: 1.25, + thirtyDayCost: 12.5, + thirtyDayTokens: 1000, + latestTokens: 200, + topModel: "gpt-5", + estimateNote: "Estimated from local logs", + tokenCostUpdatedAtMs: 1234, + }); + + const { container } = renderFloatBar(bootstrap({ floatBarShowCost: true })); + + await waitFor(() => { + expect(container.querySelector(".floatbar__cost-estimate")?.textContent).toBe( + "Estimate", + ); + }); + expect(container.querySelector(".floatbar__cost-pill")?.getAttribute("title")).toContain( + "(Estimate)", + ); + }); + it("does not scan local costs by default", async () => { tauriMocks.getCachedProviders.mockResolvedValue([ snapshot("codex", "Codex", 75), @@ -405,6 +437,44 @@ describe("FloatBar", () => { expect(tauriMocks.getCachedProviders).toHaveBeenCalled(); }); expect(tauriMocks.getProviderLocalUsageSummary).not.toHaveBeenCalled(); + expect(document.querySelector(".floatbar__cost-pill")).toBeNull(); + }); + + it("uses a safe state label instead of a raw provider error", async () => { + const raw = "legacy telemetry failed for https://private.example.test; cookie=super-secret"; + tauriMocks.getCachedProviders.mockResolvedValue([ + snapshot("gemini", "Gemini", 12, { error: raw, errorState: "unknown" }), + ]); + tauriMocks.getSettingsSnapshot.mockResolvedValue(settings({ enabledProviders: ["gemini"] })); + + const { container } = renderFloatBar(bootstrap({ enabledProviders: ["gemini"] })); + + await waitFor(() => { + const pill = container.querySelector(".floatbar__pill"); + expect(pill?.textContent).toContain("Usage unavailable"); + expect(pill?.getAttribute("title")).toBe("Gemini: Usage unavailable"); + expect(pill?.textContent).not.toContain("super-secret"); + expect(pill?.getAttribute("title")).not.toContain("private.example.test"); + }); + }); + + it("renders the sign-in label when the backend classifies needsAuthentication", async () => { + tauriMocks.getCachedProviders.mockResolvedValue([ + snapshot("copilot", "GitHub Copilot", 12, { + error: "GitHub Copilot token not found. Sign in with GitHub.", + errorState: "needsAuthentication", + }), + ]); + tauriMocks.getSettingsSnapshot.mockResolvedValue(settings({ enabledProviders: ["copilot"] })); + + const { container } = renderFloatBar(bootstrap({ enabledProviders: ["copilot"] })); + + await waitFor(() => { + const pill = container.querySelector(".floatbar__pill"); + expect(pill?.textContent).toContain("Sign-in required"); + expect(pill?.textContent).not.toContain("12%"); + expect(pill?.getAttribute("title")).toBe("GitHub Copilot: Sign-in required"); + }); }); it("can show remaining percentages when configured", async () => { diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.tsx index 86df5a307b..31d722bf2f 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.tsx @@ -19,6 +19,7 @@ import { } from "../lib/tauri"; import { ProviderIcon } from "../components/providers/ProviderIcon"; import { getProviderIcon } from "../components/providers/providerIcons"; +import { describeProviderState } from "../lib/providerState"; import type { BootstrapState, ProviderLocalUsageSummary, @@ -98,11 +99,13 @@ function CostPill({ scale, todayLabel, thirtyDayLabel, + estimateLabel, }: { summary: FloatBarCostSummary; scale: number; todayLabel: string; thirtyDayLabel: string; + estimateLabel: string; }) { const today = formatUsd(summary.todayCost); const thirtyDay = formatUsd(summary.thirtyDayCost); @@ -118,7 +121,7 @@ function CostPill({ return (
@@ -147,6 +150,9 @@ function CostPill({ )} + + {estimateLabel} +
); } @@ -167,6 +173,7 @@ function ProviderPill({ resetRelative, usedSuffix, remainingSuffix, + stateLabel, }: { provider: ProviderUsageSnapshot; highRemaining: number; @@ -177,19 +184,21 @@ function ProviderPill({ resetRelative: boolean; usedSuffix: string; remainingSuffix: string; + stateLabel: string; }) { const rateWindow = provider.selectedMetric; const remaining = Math.max(0, Math.min(100, rateWindow.remainingPercent)); const used = Math.max(0, Math.min(100, rateWindow.usedPercent)); const displayPercent = showAsUsed ? used : remaining; const displaySuffix = showAsUsed ? usedSuffix : remainingSuffix; - const exhausted = rateWindow.isExhausted || provider.error; + const state = describeProviderState(provider.errorState); + const exhausted = rateWindow.isExhausted || state.isProblem; let tone: "ok" | "warn" | "crit" = "ok"; if (exhausted || remaining <= critRemaining) tone = "crit"; else if (remaining <= highRemaining) tone = "warn"; const brand = getProviderIcon(provider.providerId).brandColor; - const label = provider.error ? "—" : `${Math.round(displayPercent)}%`; + const label = state.isProblem ? stateLabel : `${Math.round(displayPercent)}%`; const resetText = useFormattedResetTime( rateWindow.resetsAt, rateWindow.resetDescription, @@ -203,7 +212,11 @@ function ProviderPill({ return (
@@ -478,6 +491,7 @@ export default function FloatBar({ state }: { state: BootstrapState }) { resetRelative={settings.resetTimeRelative} usedSuffix={t("PanelUsedSuffix")} remainingSuffix={t("FloatBarRemainingSuffix")} + stateLabel={t(describeProviderState(p.errorState).labelKey)} /> ))} {visibleCosts.map((summary) => ( @@ -487,6 +501,7 @@ export default function FloatBar({ state }: { state: BootstrapState }) { scale={scale} todayLabel={t("PanelToday")} thirtyDayLabel={t("FloatBarThirtyDayShort")} + estimateLabel={t("OverviewSpendEstimate")} /> ))} diff --git a/apps/desktop-tauri/src/hooks/useProviders.test.tsx b/apps/desktop-tauri/src/hooks/useProviders.test.tsx index 9ad9789081..0f07ad3878 100644 --- a/apps/desktop-tauri/src/hooks/useProviders.test.tsx +++ b/apps/desktop-tauri/src/hooks/useProviders.test.tsx @@ -54,6 +54,7 @@ function provider(id: string, usedPercent = 20): ProviderUsageSnapshot { sourceLabel: "CLI", updatedAt: new Date().toISOString(), error: null, + errorState: "ready", pace: null, accountOrganization: null, trayStatusLabel: null, @@ -313,6 +314,7 @@ describe("useProviders", () => { sourceLabel: "CLI", updatedAt: new Date().toISOString(), error: null, + errorState: "ready", pace: null, accountOrganization: null, trayStatusLabel: "25%", diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index fc5dcb8a22..94bc229fb1 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -641,6 +641,11 @@ export const ALL_LOCALE_KEYS = [ "ProviderIssueFetchNeedsAttention", "ProviderIssueCopy", "ProviderIssueUnsupportedSourceModePrefix", + "ProviderIssueAuthRequired", + "ProviderIssueSessionExpired", + "ProviderIssueLocalRuntimeOffline", + "ProviderIssueUnknown", + "ProviderIssuePrivacySafeDetail", "CredentialStorageTitle", "CredentialRevokeStored", "CredentialApiKeys", diff --git a/apps/desktop-tauri/src/lib/providerOrder.test.ts b/apps/desktop-tauri/src/lib/providerOrder.test.ts index 53f7fca23b..4ff5c852c6 100644 --- a/apps/desktop-tauri/src/lib/providerOrder.test.ts +++ b/apps/desktop-tauri/src/lib/providerOrder.test.ts @@ -42,6 +42,7 @@ function snapshot(providerId: string, displayName: string): ProviderUsageSnapsho sourceLabel: "test", updatedAt: "2026-01-01T00:00:00Z", error: null, + errorState: "ready", pace: null, accountOrganization: null, trayStatusLabel: null, diff --git a/apps/desktop-tauri/src/lib/providerState.test.ts b/apps/desktop-tauri/src/lib/providerState.test.ts new file mode 100644 index 0000000000..c35bcc2100 --- /dev/null +++ b/apps/desktop-tauri/src/lib/providerState.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import type { ProviderStateKind } from "../types/bridge"; +import { describeProviderState } from "./providerState"; + +const ALL_KINDS: ProviderStateKind[] = [ + "ready", + "needsAuthentication", + "expiredSession", + "localRuntimeOffline", + "unknown", +]; + +describe("describeProviderState", () => { + it.each([ + ["ready", false, "ProviderStatusOk"], + ["needsAuthentication", true, "ProviderIssueAuthRequired"], + ["expiredSession", true, "ProviderIssueSessionExpired"], + ["localRuntimeOffline", true, "ProviderIssueLocalRuntimeOffline"], + ["unknown", true, "ProviderIssueUnknown"], + ] as const)( + "maps %s to a safe descriptor", + (kind, isProblem, labelKey) => { + expect(describeProviderState(kind)).toEqual({ + kind, + isProblem, + labelKey, + }); + }, + ); + + it("treats null and undefined as the unknown problem state", () => { + expect(describeProviderState(null)).toEqual({ + kind: "unknown", + isProblem: true, + labelKey: "ProviderIssueUnknown", + }); + expect(describeProviderState(undefined)).toEqual({ + kind: "unknown", + isProblem: true, + labelKey: "ProviderIssueUnknown", + }); + }); + + it("resolves every union member to a descriptor whose kind round-trips", () => { + // Casing-drift guard: the union values must mirror the Rust serde + // camelCase wire strings exactly. A kebab-case regression would make + // STATE_DESCRIPTORS[kind] undefined at runtime while tsc stays green, + // so this must fail instead of silently returning an unknown state. + for (const kind of ALL_KINDS) { + const descriptor = describeProviderState(kind); + expect(descriptor).toBeDefined(); + expect(descriptor.kind).toBe(kind); + } + }); + + it("mirrors the Rust serde contract for the camelCase wire values", () => { + // Pins the bridge spelling: `serializes_as_camel_case_for_the_bridge` + // in rust/src/core/provider_state.rs emits these exact strings. + expect(describeProviderState("needsAuthentication").labelKey).toBe( + "ProviderIssueAuthRequired", + ); + expect(describeProviderState("expiredSession").labelKey).toBe( + "ProviderIssueSessionExpired", + ); + expect(describeProviderState("localRuntimeOffline").labelKey).toBe( + "ProviderIssueLocalRuntimeOffline", + ); + }); + + it("never embeds arbitrary error text in the descriptor", () => { + // The descriptor is built from the classified kind alone; even a hostile + // error string on the bridge can only influence the kind, never appear + // in the descriptor payload. + const descriptor = describeProviderState("needsAuthentication"); + expect(JSON.stringify(descriptor)).not.toContain("super-secret"); + expect(JSON.stringify(descriptor)).not.toContain("private.example.test"); + expect(JSON.stringify(descriptor)).not.toContain("cookie="); + expect(JSON.stringify(descriptor)).not.toContain("sign in"); + }); +}); diff --git a/apps/desktop-tauri/src/lib/providerState.ts b/apps/desktop-tauri/src/lib/providerState.ts new file mode 100644 index 0000000000..9a2ac41bca --- /dev/null +++ b/apps/desktop-tauri/src/lib/providerState.ts @@ -0,0 +1,62 @@ +import type { LocaleKey } from "../i18n/keys"; +import type { ProviderStateKind as BridgeProviderStateKind } from "../types/bridge"; + +/** + * Backend-classified provider availability state. + * + * Classification happens in Rust from the typed `ProviderError` and travels + * on the snapshot/detail bridge as `errorState` (serde camelCase: + * `"needsAuthentication"`, `"expiredSession"`, `"localRuntimeOffline"`). + * The union values here must mirror those wire strings exactly — this + * module only maps the kind to a locale key, never the raw error text. + */ +export type ProviderStateKind = + | "ready" + | "needsAuthentication" + | "expiredSession" + | "localRuntimeOffline" + | "unknown"; + +export interface ProviderStateDescriptor { + kind: ProviderStateKind; + isProblem: boolean; + labelKey: LocaleKey; +} + +const STATE_DESCRIPTORS: Record = { + ready: { kind: "ready", isProblem: false, labelKey: "ProviderStatusOk" }, + needsAuthentication: { + kind: "needsAuthentication", + isProblem: true, + labelKey: "ProviderIssueAuthRequired", + }, + expiredSession: { + kind: "expiredSession", + isProblem: true, + labelKey: "ProviderIssueSessionExpired", + }, + localRuntimeOffline: { + kind: "localRuntimeOffline", + isProblem: true, + labelKey: "ProviderIssueLocalRuntimeOffline", + }, + unknown: { + kind: "unknown", + isProblem: true, + labelKey: "ProviderIssueUnknown", + }, +}; + +/** + * Map a backend-classified state to its presentation descriptor. Missing + * values (absent bridge field, legacy snapshot) fall back to the safe + * `unknown` problem state. + */ +export function describeProviderState( + kind: BridgeProviderStateKind | null | undefined, +): ProviderStateDescriptor { + if (!kind) { + return STATE_DESCRIPTORS.unknown; + } + return STATE_DESCRIPTORS[kind]; +} diff --git a/apps/desktop-tauri/src/lib/trayProviders.ts b/apps/desktop-tauri/src/lib/trayProviders.ts index 05a6d13e84..8fa8ba952b 100644 --- a/apps/desktop-tauri/src/lib/trayProviders.ts +++ b/apps/desktop-tauri/src/lib/trayProviders.ts @@ -78,6 +78,7 @@ export function providerPlaceholder( sourceLabel: "pending", updatedAt: new Date(0).toISOString(), error: "Loading provider data...", + errorState: "unknown", pace: null, accountOrganization: null, trayStatusLabel: null, diff --git a/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx b/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx index 816bfae1a1..f188819637 100644 --- a/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx @@ -109,6 +109,7 @@ function provider(id: string, displayName: string, used = 20): ProviderUsageSnap sourceLabel: "auto", updatedAt: "2026-05-24T00:00:00Z", error: null, + errorState: "ready", pace: null, accountOrganization: null, trayStatusLabel: null, diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index 678e621888..2eb2140036 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -96,6 +96,7 @@ function provider(id: string, displayName: string, used = 20): ProviderUsageSnap sourceLabel: "auto", updatedAt: "2026-05-24T00:00:00Z", error: null, + errorState: "ready", pace: null, accountOrganization: null, trayStatusLabel: null, diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx index 744672f088..29ff8c4d50 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx @@ -254,11 +254,6 @@ export function ProviderDetailPane({ const handleOpenStatusPage = () => { void openProviderStatusPage(detail.id).catch(setErr); }; - const handleCopyError = () => { - if (detail.lastError && navigator.clipboard) { - void navigator.clipboard.writeText(detail.lastError); - } - }; const handleBuyCredits = () => { if (detail.buyCreditsUrl) { void openProviderDashboard(detail.id).catch(setErr); @@ -270,12 +265,7 @@ export function ProviderDetailPane({ {detail.lastError && ( - + )} diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/providerDetailFormat.test.ts b/apps/desktop-tauri/src/surfaces/settings/providers/providerDetailFormat.test.ts index 37d972ffd4..236fb5b43a 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/providerDetailFormat.test.ts +++ b/apps/desktop-tauri/src/surfaces/settings/providers/providerDetailFormat.test.ts @@ -27,6 +27,7 @@ function baseDetail(over: Partial = {}): ProviderDetail { cost: null, pace: null, lastError: null, + errorState: null, dashboardUrl: null, statusPageUrl: null, buyCreditsUrl: null, diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/providerDetailPaneState.test.ts b/apps/desktop-tauri/src/surfaces/settings/providers/providerDetailPaneState.test.ts index 56576ec1d0..78175074ee 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/providerDetailPaneState.test.ts +++ b/apps/desktop-tauri/src/surfaces/settings/providers/providerDetailPaneState.test.ts @@ -34,6 +34,7 @@ function baseDetail(id = "claude"): ProviderDetail { cost: null, pace: null, lastError: null, + errorState: null, dashboardUrl: null, statusPageUrl: null, buyCreditsUrl: null, diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.test.tsx index fec1c40356..0ca592d25b 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/MenuBarMetricSection.test.tsx @@ -24,6 +24,7 @@ function provider(extra = true): ProviderDetail { cost: null, pace: null, lastError: null, + errorState: null, dashboardUrl: null, statusPageUrl: null, buyCreditsUrl: null, diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/ProviderIssueNotice.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/ProviderIssueNotice.test.tsx new file mode 100644 index 0000000000..00c96db7a4 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/ProviderIssueNotice.test.tsx @@ -0,0 +1,52 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { ProviderDetail } from "../../../../types/bridge"; +import { ProviderIssueNotice } from "./ProviderIssueNotice"; + +const detail = { + id: "cursor", + displayName: "Cursor", + errorState: "needsAuthentication", +} as ProviderDetail; + +describe("ProviderIssueNotice", () => { + it("renders a categorized notice without rendering the raw diagnostic", () => { + // Raw diagnostic text only ever lives on lastError; the notice must + // classify from the backend's errorState and never echo the text. + const raw = + "cookie=super-secret; authentication required at https://private.example.test"; + const shownDetail = { ...detail, lastError: raw } as ProviderDetail; + const t = vi.fn((key: string) => ({ + ProviderIssueAuthRequired: "Sign-in required", + ProviderIssuePrivacySafeDetail: "Details are hidden here to protect account data.", + })[key] ?? key); + + render(); + + const status = screen.getByRole("status"); + expect(status).toHaveTextContent("Cursor: Sign-in required"); + expect(status).toHaveTextContent( + "Details are hidden here to protect account data.", + ); + expect(status).not.toHaveTextContent(/super-secret|private\.example\.test/i); + expect(status).not.toHaveTextContent(/cookie=/i); + }); + + it("falls back to the unknown state when the bridge omits errorState", () => { + const t = vi.fn((key: string) => ({ + ProviderIssueUnknown: "Usage unavailable", + ProviderIssuePrivacySafeDetail: "Details are hidden here to protect account data.", + })[key] ?? key); + + render( + , + ); + + expect(screen.getByRole("status")).toHaveTextContent( + "Cursor: Usage unavailable", + ); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/ProviderIssueNotice.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/ProviderIssueNotice.tsx index 97a75bda45..d20154d81e 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/ProviderIssueNotice.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/ProviderIssueNotice.tsx @@ -1,55 +1,22 @@ import type { ProviderDetail } from "../../../../types/bridge"; import type { LocaleKey } from "../../../../i18n/keys"; +import { describeProviderState } from "../../../../lib/providerState"; interface Props { detail: ProviderDetail; - message: string; - onCopy: () => void; t: (key: LocaleKey) => string; } -export function ProviderIssueNotice({ detail, message, onCopy, t }: Props) { - const cleaned = message.replace(/^last fetch failed:\s*/i, "").trim(); - const lower = cleaned.toLowerCase(); - const needsLogin = - lower.includes("auth.json not found") || - lower.includes("not signed in") || - lower.includes("credentials not found") || - lower.includes("oauth credentials not found") || - lower.includes("run `") || - lower.includes("run codex") || - lower.includes("run claude"); - const title = needsLogin - ? `${detail.displayName} ${t("ProviderIssueNeedsSignIn")}` - : t("ProviderIssueFetchNeedsAttention"); - const displayMessage = localizeProviderIssue(cleaned, t); +export function ProviderIssueNotice({ detail, t }: Props) { + const state = describeProviderState(detail.errorState ?? "unknown"); + const title = `${detail.displayName}: ${t(state.labelKey)}`; return (
{title} -
-

{displayMessage}

+

{t("ProviderIssuePrivacySafeDetail")}

); } - -function localizeProviderIssue( - message: string, - t: (key: LocaleKey) => string, -): string { - const unsupported = message.match( - /^Source mode `?([^`']+)`? not supported for this provider$/i, - ); - if (unsupported) { - return `${t("ProviderIssueUnsupportedSourceModePrefix")} (${unsupported[1]})`; - } - return message; -} diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/QuickActionsSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/QuickActionsSection.tsx index a9731745bb..63964a6ab3 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/QuickActionsSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/QuickActionsSection.tsx @@ -8,7 +8,6 @@ interface Props { onSwitchAccount: () => void; onOpenDashboard: () => void; onOpenStatusPage: () => void; - onCopyError: () => void; onBuyCredits: () => void; t: (key: LocaleKey) => string; } @@ -26,7 +25,6 @@ export function QuickActionsSection({ onSwitchAccount, onOpenDashboard, onOpenStatusPage, - onCopyError, onBuyCredits, t, }: Props) { @@ -70,15 +68,6 @@ export function QuickActionsSection({ {t("ActionStatusPage")} )} - {provider.lastError && ( - - )} {provider.buyCreditsUrl && (