From b8a2b9b93ef732667a0a7bb44f7fb162bfbe80bb Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 23:34:08 -0500 Subject: [PATCH] feat(desktop): native OS credit alerts + billing-page nudge (#69808) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round out the credit-notice handling from the previous commit with the two optional pieces from the issue: - Native OS notification for the urgent pair. `credits.depleted` / `credits.restored` also fire an Electron notification when Hermes is backgrounded, via a new `credits` NativeNotificationKind (the existing five didn't fit) with its own toggle in Settings → Notifications (the panel is data-driven off NATIVE_NOTIFICATION_KINDS, so the toggle and i18n are the only additions). The escalating usage line and grant-spent notice stay in-app toasts only. Dispatch is `global` (account-wide, not session-bound) and gated by the user's prefs + backgrounded check. - Billing-page nudge. A `credits.*` crossing invalidates the `['billing','state']` query so Settings → Billing reflects the change immediately instead of waiting up to 30s for its poll. `nativeNoticeInput()` is a pure mapping (urgent-key gate → native input), unit-tested directly; the gateway-event branch does the localized-title lookup and gated dispatch. i18n added for all four locales. --- .../hooks/use-message-stream/gateway-event.ts | 22 +++++++++++-- apps/desktop/src/i18n/en.ts | 7 +++- apps/desktop/src/i18n/ja.ts | 7 +++- apps/desktop/src/i18n/types.ts | 3 +- apps/desktop/src/i18n/zh-hant.ts | 7 +++- apps/desktop/src/i18n/zh.ts | 7 +++- apps/desktop/src/store/agent-notices.test.ts | 32 ++++++++++++++++++ apps/desktop/src/store/agent-notices.ts | 33 +++++++++++++++++++ .../desktop/src/store/native-notifications.ts | 7 ++-- 9 files changed, 115 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index 2077d7dd2fc7..30ceb1510562 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -15,7 +15,7 @@ import { resolveGatewayEventSessionId } from '@/lib/gateway-events' import { triggerHaptic } from '@/lib/haptics' import { modelOptionsQueryKey } from '@/lib/model-options' import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors' -import { type AgentNoticePayload, clearAgentNotice, showAgentNotice } from '@/store/agent-notices' +import { type AgentNoticePayload, clearAgentNotice, nativeNoticeInput, showAgentNotice } from '@/store/agent-notices' import { reconcileApprovalModeForProfile } from '@/store/approval-mode' import { billingCtaLabel, clearBillingBlock, runBillingRecovery, setBillingBlock } from '@/store/billing-block' import { clearClarifyRequest, normalizeChoices, setClarifyRequest, warnDroppedChoices } from '@/store/clarify' @@ -873,7 +873,25 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { // as the toast id, so the escalating 50→75→90 credits line replaces in // place instead of stacking. Account-wide signal — shown regardless of // which session is focused. - showAgentNotice(event.payload as AgentNoticePayload | undefined) + const notice = event.payload as AgentNoticePayload | undefined + + showAgentNotice(notice) + + // The urgent pair (access paused / restored) also breaks through as a + // native OS notification when Hermes is backgrounded; dispatch is gated + // by the user's notification prefs + backgrounded check. + const native = nativeNoticeInput(notice, translateNow('notifications.native.creditsTitle')) + + if (native) { + dispatchNativeNotification(native) + } + + // A credits crossing moves the account balance. Settings → Billing polls + // `billing.state` every 30s; nudge it so the page reflects the crossing + // immediately instead of up to 30s late. + if (notice?.key?.startsWith('credits.')) { + void queryClient.invalidateQueries({ queryKey: ['billing', 'state'] }) + } } else if (event.type === 'notification.clear') { // Key-matched dismissal (e.g. credits restored clears the depleted // notice). notify() keys the toast by the notice key, so this maps diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 8b8ba7bbd390..a1f7724d6c60 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -168,7 +168,8 @@ export const en: Translations = { turnDoneBody: 'The response is ready.', turnErrorTitle: 'Turn failed', backgroundDoneTitle: 'Background task finished', - backgroundFailedTitle: 'Background task failed' + backgroundFailedTitle: 'Background task failed', + creditsTitle: 'Credits' } }, @@ -378,6 +379,10 @@ export const en: Translations = { backgroundDone: { label: 'Background task finished', description: 'A backgrounded terminal command completed.' + }, + credits: { + label: 'Credit alerts', + description: 'Credit access is paused or restored.' } }, test: 'Send test notification', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 361378553a34..6d4bd9a89c52 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -169,7 +169,8 @@ export const ja = defineLocale({ turnDoneBody: '応答の準備ができました。', turnErrorTitle: 'ターンが失敗しました', backgroundDoneTitle: 'バックグラウンドタスクが完了しました', - backgroundFailedTitle: 'バックグラウンドタスクが失敗しました' + backgroundFailedTitle: 'バックグラウンドタスクが失敗しました', + creditsTitle: 'クレジット' } }, @@ -262,6 +263,10 @@ export const ja = defineLocale({ backgroundDone: { label: 'バックグラウンドタスク完了', description: 'バックグラウンドのターミナルコマンドが完了しました。' + }, + credits: { + label: 'クレジット通知', + description: 'クレジットの利用が停止または復旧しました。' } }, test: 'テスト通知を送信', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 9a55d2f1a46d..f71770813e00 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -211,6 +211,7 @@ export interface Translations { turnErrorTitle: string backgroundDoneTitle: string backgroundFailedTitle: string + creditsTitle: string } } @@ -314,7 +315,7 @@ export interface Translations { enableAllDesc: string focusedHint: string kinds: Record< - 'approval' | 'backgroundDone' | 'input' | 'turnDone' | 'turnError', + 'approval' | 'backgroundDone' | 'credits' | 'input' | 'turnDone' | 'turnError', { label: string; description: string } > test: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 8657437368a0..919039f5504e 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -164,7 +164,8 @@ export const zhHant = defineLocale({ turnDoneBody: '回覆已就緒。', turnErrorTitle: '本輪失敗', backgroundDoneTitle: '背景工作已完成', - backgroundFailedTitle: '背景工作失敗' + backgroundFailedTitle: '背景工作失敗', + creditsTitle: '額度' } }, @@ -256,6 +257,10 @@ export const zhHant = defineLocale({ backgroundDone: { label: '背景工作完成', description: '背景終端機指令已完成。' + }, + credits: { + label: '額度提醒', + description: '額度存取被暫停或恢復。' } }, test: '傳送測試通知', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 44ef9c68c620..53cf3a93c2bf 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -164,7 +164,8 @@ export const zh: Translations = { turnDoneBody: '回复已就绪。', turnErrorTitle: '本轮失败', backgroundDoneTitle: '后台任务已完成', - backgroundFailedTitle: '后台任务失败' + backgroundFailedTitle: '后台任务失败', + creditsTitle: '额度' } }, @@ -368,6 +369,10 @@ export const zh: Translations = { backgroundDone: { label: '后台任务完成', description: '后台终端命令已完成。' + }, + credits: { + label: '额度提醒', + description: '额度访问被暂停或恢复。' } }, test: '发送测试通知', diff --git a/apps/desktop/src/store/agent-notices.test.ts b/apps/desktop/src/store/agent-notices.test.ts index f40d4deacb41..a4028176ed13 100644 --- a/apps/desktop/src/store/agent-notices.test.ts +++ b/apps/desktop/src/store/agent-notices.test.ts @@ -3,6 +3,7 @@ import { beforeEach, expect, test } from 'vitest' import { type AgentNoticePayload, clearAgentNotice, + nativeNoticeInput, noticeToToast, showAgentNotice } from './agent-notices' @@ -100,3 +101,34 @@ test('clearAgentNotice dismisses only the matching key', () => { clearAgentNotice(undefined) expect($notifications.get()).toHaveLength(1) }) + +// ── nativeNoticeInput: only the urgent credit pair breaks through the OS ────── + +test('only credits.depleted and credits.restored map to a native notification', () => { + expect(nativeNoticeInput(usage({ key: 'credits.usage' }), 'Credits')).toBeNull() + expect(nativeNoticeInput(usage({ key: 'credits.grant_spent' }), 'Credits')).toBeNull() + expect(nativeNoticeInput({ text: 'x', key: undefined }, 'Credits')).toBeNull() + expect(nativeNoticeInput({ text: '', key: 'credits.depleted' }, 'Credits')).toBeNull() +}) + +test('the urgent pair maps to a global native input carrying the text as its body', () => { + const depleted = nativeNoticeInput( + { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ Credit access paused · run /topup to top up' }, + 'Credits' + ) + + expect(depleted).toEqual({ + body: '✕ Credit access paused · run /topup to top up', + global: true, + kind: 'credits', + title: 'Credits' + }) + + const restored = nativeNoticeInput( + { key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ Credit access restored', ttl_ms: 8000 }, + 'Credits' + ) + + expect(restored?.kind).toBe('credits') + expect(restored?.body).toBe('✓ Credit access restored') +}) diff --git a/apps/desktop/src/store/agent-notices.ts b/apps/desktop/src/store/agent-notices.ts index a12dee73b207..c076ee9854e6 100644 --- a/apps/desktop/src/store/agent-notices.ts +++ b/apps/desktop/src/store/agent-notices.ts @@ -1,3 +1,4 @@ +import type { NativeNotificationInput } from '@/store/native-notifications' import { dismissNotification, type NotificationInput, type NotificationKind, notify } from '@/store/notifications' /** @@ -76,3 +77,35 @@ export function clearAgentNotice(key: string | undefined): void { dismissNotification(key) } } + +// Only these two credit notices are urgent enough to break through as a native +// OS notification (when Hermes is backgrounded). The escalating usage line +// (`credits.usage`) and the grant-spent notice stay in-app toasts only — they +// aren't worth interrupting the user's OS for. +const NATIVE_NOTICE_KEYS = new Set(['credits.depleted', 'credits.restored']) + +/** + * Map a notice to a native OS notification input, or `null` when it isn't one of + * the urgent credit notices. Pure — the caller passes the localized `title` and + * decides whether to dispatch. `global: true` because credit state is + * account-wide, not tied to a chat session, so it should fire whenever the user + * is away regardless of which session (if any) is focused. The notice `text` + * already carries its glyph and is passed through as the raw body. + */ +export function nativeNoticeInput( + payload: AgentNoticePayload | undefined, + title: string +): NativeNotificationInput | null { + const text = payload?.text?.trim() + + if (!text || !payload?.key || !NATIVE_NOTICE_KEYS.has(payload.key)) { + return null + } + + return { + body: text, + global: true, + kind: 'credits', + title + } +} diff --git a/apps/desktop/src/store/native-notifications.ts b/apps/desktop/src/store/native-notifications.ts index 5e659d061b40..db56d94a3fa4 100644 --- a/apps/desktop/src/store/native-notifications.ts +++ b/apps/desktop/src/store/native-notifications.ts @@ -8,14 +8,15 @@ import { $activeSessionId } from './session' // Native OS notifications (Electron `Notification`), separate from the in-app // toast feed in `notifications.ts`. Each kind toggles independently. -export type NativeNotificationKind = 'approval' | 'backgroundDone' | 'input' | 'turnDone' | 'turnError' +export type NativeNotificationKind = 'approval' | 'backgroundDone' | 'credits' | 'input' | 'turnDone' | 'turnError' export const NATIVE_NOTIFICATION_KINDS: readonly NativeNotificationKind[] = [ 'approval', 'input', 'turnDone', 'turnError', - 'backgroundDone' + 'backgroundDone', + 'credits' ] // Blocking prompts — surface even while focused if they're for another session. @@ -30,7 +31,7 @@ const STORAGE_KEY = 'hermes:native-notifications' const DEFAULT_PREFS: NativeNotificationPrefs = { enabled: true, - kinds: { approval: true, backgroundDone: true, input: true, turnDone: true, turnError: true } + kinds: { approval: true, backgroundDone: true, credits: true, input: true, turnDone: true, turnError: true } } function readPrefs(): NativeNotificationPrefs {