feat(desktop): native OS credit alerts + billing-page nudge (#69808)

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.
This commit is contained in:
Brooklyn Nicholson 2026-07-22 23:34:08 -05:00
parent 58e3d41582
commit b8a2b9b93e
9 changed files with 115 additions and 10 deletions

View file

@ -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

View file

@ -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',

View file

@ -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: 'テスト通知を送信',

View file

@ -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

View file

@ -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: '傳送測試通知',

View file

@ -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: '发送测试通知',

View file

@ -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')
})

View file

@ -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
}
}

View file

@ -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 {