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';
|
import { Tray, Menu, nativeImage } from 'electron';
|
||||||
|
|
||||||
const MAX_SESSIONS = 12;
|
const MAX_SESSIONS = 8;
|
||||||
const MAX_APPROVALS = 10;
|
const MAX_APPROVALS = 10;
|
||||||
|
|
||||||
const truncate = (value, max) => {
|
const truncate = (value, max) => {
|
||||||
@@ -220,6 +220,32 @@ export const createTrayController = ({ idleIconPath, unseenIconPath, breathIconP
|
|||||||
template.push({ label: 'No active sessions', enabled: false });
|
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(
|
template.push(
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ label: 'New Session', click: () => onAction({ type: 'new-session' }) },
|
{ label: 'New Session', click: () => onAction({ type: 'new-session' }) },
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
refreshGlobalSessions,
|
refreshGlobalSessions,
|
||||||
resolveGlobalSessionDirectory,
|
resolveGlobalSessionDirectory,
|
||||||
} from '@/stores/useGlobalSessionsStore';
|
} from '@/stores/useGlobalSessionsStore';
|
||||||
|
import { useQuotaStore } from '@/stores/useQuotaStore';
|
||||||
|
import { QUOTA_PROVIDERS, formatWindowLabel, formatQuotaValueLabel } from '@/lib/quota';
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
import type { PermissionRequest } from '@/types/permission';
|
import type { PermissionRequest } from '@/types/permission';
|
||||||
import type { QuestionRequest } from '@/types/question';
|
import type { QuestionRequest } from '@/types/question';
|
||||||
@@ -54,12 +56,20 @@ type TrayApproval = {
|
|||||||
directory: string;
|
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 = {
|
type TraySnapshot = {
|
||||||
sessions: TraySession[];
|
sessions: TraySession[];
|
||||||
approvals: TrayApproval[];
|
approvals: TrayApproval[];
|
||||||
// Active instance label (e.g. "Local OpenChamber" or a remote host name) so
|
// Active instance label (e.g. "Local OpenChamber" or a remote host name) so
|
||||||
// the tray header makes clear which instance/window it reflects.
|
// the tray header makes clear which instance/window it reflects.
|
||||||
instanceName: string;
|
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
|
// 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 =>
|
const updatedAt = (session: Session): number =>
|
||||||
session.time?.updated ?? session.time?.created ?? 0;
|
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):
|
// Mirrors the header's instance resolution (Header.refreshCurrentInstanceLabel):
|
||||||
// the local origin shows as "Local OpenChamber"; a remote host shows its
|
// the local origin shows as "Local OpenChamber"; a remote host shows its
|
||||||
// configured name. Async because the host config is read over IPC.
|
// 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) || '' }));
|
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 => {
|
export const useTraySync = (): void => {
|
||||||
@@ -319,6 +360,23 @@ export const useTraySync = (): void => {
|
|||||||
void ensureGlobalSessionsLoaded(getAllSyncSessions());
|
void ensureGlobalSessionsLoaded(getAllSyncSessions());
|
||||||
const refreshInterval = window.setInterval(() => { void refreshGlobalSessions(); }, GLOBAL_REFRESH_MS);
|
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
|
// Safety net: catches anything the event subscriptions miss (e.g. a store
|
||||||
// that existed before the registry subscription was attached).
|
// that existed before the registry subscription was attached).
|
||||||
const interval = window.setInterval(() => { rebindStores(); flushNow(); }, POLL_INTERVAL_MS);
|
const interval = window.setInterval(() => { rebindStores(); flushNow(); }, POLL_INTERVAL_MS);
|
||||||
@@ -330,8 +388,10 @@ export const useTraySync = (): void => {
|
|||||||
if (flushTimer !== null) window.clearTimeout(flushTimer);
|
if (flushTimer !== null) window.clearTimeout(flushTimer);
|
||||||
window.clearInterval(interval);
|
window.clearInterval(interval);
|
||||||
window.clearInterval(refreshInterval);
|
window.clearInterval(refreshInterval);
|
||||||
|
window.clearInterval(usageRefreshTick);
|
||||||
unsubscribeNotif();
|
unsubscribeNotif();
|
||||||
unsubscribeGlobal();
|
unsubscribeGlobal();
|
||||||
|
unsubscribeQuota();
|
||||||
unsubscribeRegistry?.();
|
unsubscribeRegistry?.();
|
||||||
for (const unsub of storeUnsubs.values()) unsub();
|
for (const unsub of storeUnsubs.values()) unsub();
|
||||||
storeUnsubs.clear();
|
storeUnsubs.clear();
|
||||||
|
|||||||
Reference in New Issue
Block a user