mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-24 16:54:43 +00:00
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:
parent
58e3d41582
commit
b8a2b9b93e
9 changed files with 115 additions and 10 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: 'テスト通知を送信',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: '傳送測試通知',
|
||||
|
|
|
|||
|
|
@ -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: '发送测试通知',
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue