feat(tray): show cached provider usage in the system tray menu (#2184)

* feat: add Rust-side write-through usage cache

Introduce an in-memory UsageCache on AppState that the existing usage
query commands populate on success. The cache is read-only to the rest
of the app today; the next commit consumes it from the tray menu.

- New services::usage_cache module with split maps: subscription keyed
  by AppType, script keyed by (AppType, provider_id).
- AppType gains Eq + Hash so it can be used as a HashMap key.
- commands::subscription::get_subscription_quota now takes State<AppState>
  and writes through on success (signature change is invisible to the
  frontend — Tauri injects State automatically).
- commands::provider::queryProviderUsage body extracted into an inner
  async fn; the public command wraps it with write-through, covering
  Copilot, coding-plan, balance, and generic script paths uniformly.

Cache is in-memory only; auto-query interval and the upcoming tray
refresh action rebuild it after restarts.

* feat(tray): surface cached usage in the system tray menu

Read UsageCache populated by the previous commit and render it in three
places, scoped to whatever TRAY_SECTIONS covers (Claude/Codex/Gemini):

1. Inline suffix on each provider submenu item
   "AnyProvider  · 🟢 5h 18% / 7d 23%"
2. Disabled summary row per visible app under "Show Main"
   "Claude · Anthropic Official · 🟢 5h 18% / 7d 23%"
3. "Refresh all usage" menu item that triggers get_subscription_quota +
   queryProviderUsage for every applicable provider, then rebuilds the
   tray menu via the existing refresh_tray_menu path.

Color encoding uses emoji (🟢 <70% / 🟠 70-89% / 🔴 ≥90%) since Tauri 2
tray labels are plain text. Missing cache entry leaves the label
unchanged — tray never issues network requests when opened. Three new
i18n-ready strings live in TrayTexts (en/zh/ja), following the existing
pattern for tray text.

Closes #2178.

* feat(usage): bridge tray UsageCache writes to frontend React Query

Why: tray hover triggers backend-only refresh that wrote to UsageCache but
never notified the frontend, leaving main UI stale while tray showed fresh
numbers. Emit a payload-carrying event after each cache write so React Query
can setQueryData directly, keeping both views in sync without duplicate fetches.

* fix(tray): skip hidden apps on hover refresh and drop stale disabled-script cache

Address P2 findings from automated review on #2184:

1. refresh_all_usage_in_tray now filters TRAY_SECTIONS by settings.visible_apps
   before scheduling subscription/script queries, matching create_tray_menu and
   preventing wasted external API calls (and rate-limit/auth-error log noise)
   for apps the user has hidden.

2. format_usage_suffix only trusts the script cache when provider.meta.usage_script
   is still enabled; when a script is disabled/removed the cached suffix is now
   invalidated so the tray label no longer shows stale data indefinitely.

* refactor: consolidate codex provider helpers and fix test semantics

- Add Provider::is_codex_oauth() and Provider::codex_fast_mode_enabled()
  to eliminate duplicated meta extraction in claude.rs and stream_check.rs
- Fix non-codex-oauth tests to pass codex_fast_mode=false (was true, harmless
  but semantically misleading)
- Remove redundant is_dir() guard after resolve_skill_source_dir already
  guarantees the returned path is a directory

* style: apply cargo fmt

* fix(tray): reflect failed refreshes in cache and support Gemini flash-lite

Follow-up to the tray usage-display feature addressing review feedback:

- Write snapshots for both Ok(success:false) and Err paths in
  queryProviderUsage / get_subscription_quota so stale success data
  no longer persists across failed refreshes; the original Err is
  still returned to the frontend onError handler.
- Include gemini_flash_lite tier in the tray summary with label "l".
  Matches the frontend SubscriptionQuotaFooter and keeps the worst
  emoji correct when lite is the highest utilization.
- Add TIER_GEMINI_PRO / _FLASH / _FLASH_LITE constants in
  services/subscription.rs and reuse them in classify_gemini_model
  and sort_order.
- Extract Provider::has_usage_script_enabled() to remove the
  duplicated meta.usage_script chain at two call sites.
- Use db.get_provider_by_id in refresh_all_usage_in_tray instead of
  materialising the full provider map, and parallelise subscription
  and script futures via futures::future::join.
- Narrow refresh_all_usage_in_tray to each section's effective
  current provider (script if enabled, else subscription when the
  provider is official). Hover refreshes now issue at most
  TRAY_SECTIONS.len() outbound requests.
- Add 10 unit tests in tray::tests covering Claude/Codex h/w dispatch,
  Gemini p/f/l dispatch (including lite-only and lite-worst cases),
  and success/failure guards.

---------

Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
涂瑜
2026-04-23 16:17:15 +08:00
committed by GitHub
parent 010b163430
commit dc04165f18
21 changed files with 855 additions and 115 deletions
+3
View File
@@ -49,6 +49,7 @@ import {
import { hermesApi } from "@/lib/api/hermes";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import { useAutoCompact } from "@/hooks/useAutoCompact";
import { useUsageCacheBridge } from "@/hooks/useUsageCacheBridge";
import { useLastValidValue } from "@/hooks/useLastValidValue";
import { extractErrorMessage } from "@/utils/errorUtils";
import { isTextEditableTarget } from "@/utils/domUtils";
@@ -236,6 +237,8 @@ function App() {
const toolbarRef = useRef<HTMLDivElement>(null);
const isToolbarCompact = useAutoCompact(toolbarRef);
useUsageCacheBridge();
const promptPanelRef = useRef<any>(null);
const mcpPanelRef = useRef<any>(null);
const skillsPageRef = useRef<any>(null);
+66
View File
@@ -0,0 +1,66 @@
import { useEffect } from "react";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { useQueryClient } from "@tanstack/react-query";
import type { AppId } from "@/lib/api/types";
import type { UsageResult } from "@/types";
import type { SubscriptionQuota } from "@/types/subscription";
import { usageKeys } from "@/lib/query/usage";
import { subscriptionKeys } from "@/lib/query/subscription";
type UsageCacheUpdatedPayload =
| {
kind: "script";
appType: AppId;
providerId: string;
data: UsageResult;
}
| {
kind: "subscription";
appType: AppId;
data: SubscriptionQuota;
};
/**
* 后端 `UsageCache` 写入后会 emit `usage-cache-updated`,本 hook 把 payload 同步到
* React Query 缓存,让托盘触发的刷新(不经前端)也能立刻反映到主界面,避免
* React Query 与 Rust 侧两份缓存各自为战。
*/
export function useUsageCacheBridge() {
const queryClient = useQueryClient();
useEffect(() => {
let unlisten: UnlistenFn | undefined;
let disposed = false;
(async () => {
const off = await listen<UsageCacheUpdatedPayload>(
"usage-cache-updated",
(event) => {
const payload = event.payload;
if (payload.kind === "script") {
queryClient.setQueryData<UsageResult>(
usageKeys.script(payload.providerId, payload.appType),
payload.data,
);
} else if (payload.kind === "subscription") {
queryClient.setQueryData<SubscriptionQuota>(
subscriptionKeys.quota(payload.appType),
payload.data,
);
}
},
);
if (disposed) {
off();
} else {
unlisten = off;
}
})();
return () => {
disposed = true;
unlisten?.();
};
}, [queryClient]);
}
+2 -1
View File
@@ -17,6 +17,7 @@ import type {
SessionMeta,
SessionMessage,
} from "@/types";
import { usageKeys } from "@/lib/query/usage";
const sortProviders = (
providers: Record<string, Provider>,
@@ -113,7 +114,7 @@ export const useUsageQuery = (
: 5 * 60 * 1000; // 默认 5 分钟
const query = useQuery<UsageResult>({
queryKey: ["usage", providerId, appId],
queryKey: usageKeys.script(providerId, appId),
queryFn: async () => usageApi.query(providerId, appId),
enabled: enabled && !!providerId,
refetchInterval:
+6 -1
View File
@@ -7,13 +7,18 @@ import { PROVIDER_TYPES } from "@/config/constants";
const REFETCH_INTERVAL = 5 * 60 * 1000; // 5 minutes
export const subscriptionKeys = {
all: ["subscription"] as const,
quota: (appId: AppId) => [...subscriptionKeys.all, "quota", appId] as const,
};
export function useSubscriptionQuota(
appId: AppId,
enabled: boolean,
autoQuery = false,
) {
return useQuery({
queryKey: ["subscription", "quota", appId],
queryKey: subscriptionKeys.quota(appId),
queryFn: () => subscriptionApi.getQuota(appId),
enabled: enabled && ["claude", "codex", "gemini"].includes(appId),
refetchInterval: autoQuery ? REFETCH_INTERVAL : false,
+2
View File
@@ -106,6 +106,8 @@ export const usageKeys = {
pricing: () => [...usageKeys.all, "pricing"] as const,
limits: (providerId: string, appType: string) =>
[...usageKeys.all, "limits", providerId, appType] as const,
script: (providerId: string, appType: string) =>
[...usageKeys.all, providerId, appType] as const,
};
// Hooks