feat(desktop): add provider usage submenu to the macOS tray

Surface rate-limit usage in the tray, mirroring the header/mobile usage view.

- New "Usage (Used/Remaining)" submenu groups enabled providers with their
  window limits (e.g. 5-Hour, Weekly Limit, Credits) and per-window values,
  reusing the quota store and the same formatting helpers as the rest of the UI.
- Honors the "configured to show" rule: only providers the user enabled for the
  dropdown and that report as configured are shown; when none qualify the submenu
  is omitted entirely.
- Tray-side data: build usage groups in useTraySync, push on quota-store changes,
  do one initial fetch for enabled providers on launch, and refresh on a
  desktop-only interval that respects the user's auto-refresh setting (no change
  to web behavior).
- Rows are read-only (greyed) info items; provider flush, windows indented.
- Show the first 8 sessions inline and move the rest into the overflow submenu.
This commit is contained in:
Bohdan Triapitsyn
2026-06-10 00:50:20 +03:00
parent 9cf79a8890
commit ae46060e1d
2 changed files with 88 additions and 2 deletions
+27 -1
View File
@@ -17,7 +17,7 @@
import { Tray, Menu, nativeImage } from 'electron';
const MAX_SESSIONS = 12;
const MAX_SESSIONS = 8;
const MAX_APPROVALS = 10;
const truncate = (value, max) => {
@@ -220,6 +220,32 @@ export const createTrayController = ({ idleIconPath, unseenIconPath, breathIconP
template.push({ label: 'No active sessions', enabled: false });
}
// Usage submenu — only when the user has enabled providers for the dropdown
// (same "configured to show" rule as the header/mobile); omitted otherwise.
const usage = snapshot.usage && typeof snapshot.usage === 'object' ? snapshot.usage : null;
const usageGroups = usage && Array.isArray(usage.groups) ? usage.groups : [];
if (usageGroups.length > 0) {
const modeLabel = usage.mode === 'remaining' ? 'Remaining' : 'Used';
const usageSubmenu = [];
usageGroups.forEach((group, index) => {
if (index > 0) usageSubmenu.push({ type: 'separator' });
// Read-only info rows. NSMenu only offers greyed-out for non-clickable
// items (no custom text contrast), so these render dimmed — at the mercy
// of macOS's menu contrast choices. Provider flush, rows indented.
usageSubmenu.push({ label: group.provider, enabled: false });
if (group.status) {
usageSubmenu.push({ label: ` ${truncate(group.status, 40)}`, enabled: false });
}
for (const row of (Array.isArray(group.rows) ? group.rows : [])) {
usageSubmenu.push({ label: ` ${row.label}${row.value}`, enabled: false });
}
});
template.push(
{ type: 'separator' },
{ label: `Usage (${modeLabel})`, submenu: usageSubmenu },
);
}
template.push(
{ type: 'separator' },
{ label: 'New Session', click: () => onAction({ type: 'new-session' }) },
+61 -1
View File
@@ -12,6 +12,8 @@ import {
refreshGlobalSessions,
resolveGlobalSessionDirectory,
} from '@/stores/useGlobalSessionsStore';
import { useQuotaStore } from '@/stores/useQuotaStore';
import { QUOTA_PROVIDERS, formatWindowLabel, formatQuotaValueLabel } from '@/lib/quota';
import { toast } from '@/components/ui';
import type { PermissionRequest } from '@/types/permission';
import type { QuestionRequest } from '@/types/question';
@@ -54,12 +56,20 @@ type TrayApproval = {
directory: string;
};
type TrayUsageRow = { label: string; value: string };
type TrayUsageGroup = { provider: string; rows: TrayUsageRow[]; status: string | null };
type TrayUsage = { mode: 'usage' | 'remaining'; groups: TrayUsageGroup[] };
type TraySnapshot = {
sessions: TraySession[];
approvals: TrayApproval[];
// Active instance label (e.g. "Local OpenChamber" or a remote host name) so
// the tray header makes clear which instance/window it reflects.
instanceName: string;
// Provider rate-limit usage, only for providers the user enabled for the
// dropdown (same "configured to show" rule as the header/mobile). Empty
// groups → the tray omits the Usage submenu entirely.
usage: TrayUsage;
};
// focus-session / new-session are routed natively by the main process through
@@ -94,6 +104,37 @@ const questionLabel = (request: QuestionRequest): string => {
const updatedAt = (session: Session): number =>
session.time?.updated ?? session.time?.created ?? 0;
// Build the usage groups exactly like the header/mobile: only providers the
// user enabled for the dropdown AND that report as configured. Window rows only
// (the headline limits) — model breakdowns stay in the full UI.
const buildUsage = (): TrayUsage => {
const { results, dropdownProviderIds, displayMode } = useQuotaStore.getState();
const mode: TrayUsage['mode'] = displayMode === 'remaining' ? 'remaining' : 'usage';
if (!dropdownProviderIds.length) return { mode, groups: [] };
const byProvider = new Map(results.map((result) => [result.providerId, result]));
const groups: TrayUsageGroup[] = [];
for (const meta of QUOTA_PROVIDERS) {
if (!dropdownProviderIds.includes(meta.id)) continue;
const result = byProvider.get(meta.id);
if (!result || result.configured !== true) continue;
const rows: TrayUsageRow[] = [];
for (const [label, window] of Object.entries(result.usage?.windows ?? {})) {
const percent = mode === 'remaining' ? window.remainingPercent : window.usedPercent;
rows.push({ label: formatWindowLabel(label), value: formatQuotaValueLabel(window.valueLabel, percent) });
}
const status = !result.ok && result.error
? result.error
: rows.length === 0
? 'No rate limits reported'
: null;
groups.push({ provider: meta.name, rows, status });
}
return { mode, groups };
};
// Mirrors the header's instance resolution (Header.refreshCurrentInstanceLabel):
// the local origin shows as "Local OpenChamber"; a remote host shows its
// configured name. Async because the host config is read over IPC.
@@ -229,7 +270,7 @@ const buildSnapshot = (instanceName: string): TraySnapshot => {
const approvals = live.approvals.map((a) => ({ ...a, sessionTitle: titleById.get(a.sessionId) || '' }));
return { sessions, approvals, instanceName };
return { sessions, approvals, instanceName, usage: buildUsage() };
};
export const useTraySync = (): void => {
@@ -319,6 +360,23 @@ export const useTraySync = (): void => {
void ensureGlobalSessionsLoaded(getAllSyncSessions());
const refreshInterval = window.setInterval(() => { void refreshGlobalSessions(); }, GLOBAL_REFRESH_MS);
// Usage: push to the tray whenever the quota store changes, and do one
// initial fetch for enabled providers so the submenu isn't empty on launch.
const unsubscribeQuota = useQuotaStore.subscribe(() => scheduleFlush());
void useQuotaStore.getState().loadSettings().then(() => {
if (disposed) return;
const { dropdownProviderIds, results } = useQuotaStore.getState();
const needsFetch = dropdownProviderIds.length > 0
&& dropdownProviderIds.some((id) => !results.some((r) => r.providerId === id));
if (needsFetch) void useQuotaStore.getState().fetchAllQuotas();
});
// Keep the Usage submenu current per the user's auto-refresh setting
// (desktop-only; checked each tick so toggling it mid-session applies).
const usageRefreshTick = window.setInterval(() => {
const quota = useQuotaStore.getState();
if (quota.autoRefresh && quota.dropdownProviderIds.length > 0) void quota.fetchAllQuotas();
}, Math.max(30000, useQuotaStore.getState().refreshIntervalMs || 60000));
// Safety net: catches anything the event subscriptions miss (e.g. a store
// that existed before the registry subscription was attached).
const interval = window.setInterval(() => { rebindStores(); flushNow(); }, POLL_INTERVAL_MS);
@@ -330,8 +388,10 @@ export const useTraySync = (): void => {
if (flushTimer !== null) window.clearTimeout(flushTimer);
window.clearInterval(interval);
window.clearInterval(refreshInterval);
window.clearInterval(usageRefreshTick);
unsubscribeNotif();
unsubscribeGlobal();
unsubscribeQuota();
unsubscribeRegistry?.();
for (const unsub of storeUnsubs.values()) unsub();
storeUnsubs.clear();