From ae46060e1ddbefe6ee7282f0ec2228f545fa883e Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 10 Jun 2026 00:50:20 +0300 Subject: [PATCH] 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. --- packages/electron/tray.mjs | 28 ++++++++++++- packages/ui/src/hooks/useTraySync.ts | 62 +++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/packages/electron/tray.mjs b/packages/electron/tray.mjs index e37f9114..b96dbcdd 100644 --- a/packages/electron/tray.mjs +++ b/packages/electron/tray.mjs @@ -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' }) }, diff --git a/packages/ui/src/hooks/useTraySync.ts b/packages/ui/src/hooks/useTraySync.ts index 46788561..72a62bec 100644 --- a/packages/ui/src/hooks/useTraySync.ts +++ b/packages/ui/src/hooks/useTraySync.ts @@ -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();