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:
@@ -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' }) },
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user