fix(desktop): render agent credit notices as toasts (#69808)

The desktop renderer had no handler for the `notification.show` /
`notification.clear` WS events, so every credit-usage notice the backend
sends (`agent/credits_tracker.py` → `tui_gateway/server.py`) was silently
dropped. Credit warnings like "• Credits 50% used · $220.00 cap" never
appeared, even though the Ink TUI renders them in its status bar.

Add the two missing branches to the gateway-event dispatcher, delegating
to a small, pure-testable module:

- `store/agent-notices.ts` — `noticeToToast()` maps a notice to a toast
  (level → toast kind, sticky → durationMs 0, ttl → ttl_ms), and uses the
  notice `key` as the toast id. Re-emitting the same key REPLACES the
  toast, so the credits 50→75→90 line escalates in place instead of
  stacking, and a key-matched `notification.clear` maps straight to
  `dismissNotification(key)`.
- The notice `text` already carries its own glyph (• ⚠ ✕ ✓), so no toast
  icon is added.
- Notices are account-wide, so the toast shows regardless of which
  session is focused.

The Ink TUI (`ui-tui/src/app/turnController.ts`) is the reference for the
latest-wins / sticky-vs-ttl / key-matched-clear behavior.

Export `NotificationInput` so the mapping's return type can be named.
This commit is contained in:
Brooklyn Nicholson 2026-07-22 23:08:48 -05:00
parent a2172547a8
commit 58e3d41582
4 changed files with 195 additions and 1 deletions

View file

@ -15,6 +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 { reconcileApprovalModeForProfile } from '@/store/approval-mode'
import { billingCtaLabel, clearBillingBlock, runBillingRecovery, setBillingBlock } from '@/store/billing-block'
import { clearClarifyRequest, normalizeChoices, setClarifyRequest, warnDroppedChoices } from '@/store/clarify'
@ -865,6 +866,19 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
]
}))
}
} else if (event.type === 'notification.show') {
// Driver-agnostic agent notice (credits usage/grant/depleted/restored
// from `agent/credits_tracker.py`). The Ink TUI renders these in its
// status bar; the desktop renders them as toasts. The notice key doubles
// 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)
} 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
// straight to dismissNotification(key).
clearAgentNotice((event.payload as AgentNoticePayload | undefined)?.key)
} else if (event.type === 'error') {
const errorMessage = payload?.message || 'Hermes reported an error'
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)

View file

@ -0,0 +1,102 @@
import { beforeEach, expect, test } from 'vitest'
import {
type AgentNoticePayload,
clearAgentNotice,
noticeToToast,
showAgentNotice
} from './agent-notices'
import { $notifications, clearNotifications } from './notifications'
function usage(overrides: Partial<AgentNoticePayload> = {}): AgentNoticePayload {
return {
key: 'credits.usage',
kind: 'sticky',
level: 'info',
text: '• Credits 50% used · $220.00 cap',
...overrides
}
}
beforeEach(() => {
clearNotifications()
})
// ── noticeToToast: the whole mapping contract ────────────────────────────────
test('drops a notice with no text', () => {
expect(noticeToToast(undefined)).toBeNull()
expect(noticeToToast({ text: '' })).toBeNull()
expect(noticeToToast({ text: ' ' })).toBeNull()
})
test('level maps to toast kind (warn → warning)', () => {
expect(noticeToToast(usage({ level: 'info' }))?.kind).toBe('info')
expect(noticeToToast(usage({ level: 'warn' }))?.kind).toBe('warning')
expect(noticeToToast(usage({ level: 'error' }))?.kind).toBe('error')
expect(noticeToToast(usage({ level: 'success' }))?.kind).toBe('success')
})
test('unknown / missing level falls back to info', () => {
expect(noticeToToast({ text: 'x', level: 'bogus' })?.kind).toBe('info')
expect(noticeToToast({ text: 'x' })?.kind).toBe('info')
})
test('sticky notices never auto-dismiss', () => {
expect(noticeToToast(usage({ kind: 'sticky' }))?.durationMs).toBe(0)
})
test('ttl notice carries its ttl_ms as the duration', () => {
const toast = noticeToToast({ key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ restored', ttl_ms: 8000 })
expect(toast?.durationMs).toBe(8000)
})
test('ttl notice without a usable ttl_ms defers to notify()s default', () => {
expect(noticeToToast({ text: 'x', kind: 'ttl' })?.durationMs).toBeUndefined()
expect(noticeToToast({ text: 'x', kind: 'ttl', ttl_ms: 0 })?.durationMs).toBeUndefined()
})
test('the notice key is the toast id, falling back to id', () => {
expect(noticeToToast(usage({ key: 'credits.usage' }))?.id).toBe('credits.usage')
expect(noticeToToast({ text: 'x', id: 'n1', key: undefined })?.id).toBe('n1')
})
test('the glyph-bearing text passes through verbatim as the message', () => {
expect(noticeToToast(usage())?.message).toBe('• Credits 50% used · $220.00 cap')
})
// ── show / clear: rendered through the notifications store ────────────────────
test('showAgentNotice renders a toast; empty text is a no-op', () => {
showAgentNotice(usage())
expect($notifications.get()).toHaveLength(1)
expect($notifications.get()[0]?.id).toBe('credits.usage')
showAgentNotice({ text: '' })
expect($notifications.get()).toHaveLength(1)
})
test('re-emitting the same key replaces the toast instead of stacking (50→75→90)', () => {
showAgentNotice(usage({ level: 'info', text: '• Credits 50% used' }))
showAgentNotice(usage({ level: 'warn', text: '• Credits 75% used' }))
showAgentNotice(usage({ level: 'warn', text: '• Credits 90% used' }))
const toasts = $notifications.get().filter(item => item.id === 'credits.usage')
expect(toasts).toHaveLength(1)
expect(toasts[0]?.message).toBe('• Credits 90% used')
expect(toasts[0]?.kind).toBe('warning')
})
test('clearAgentNotice dismisses only the matching key', () => {
showAgentNotice(usage())
showAgentNotice({ key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ paused' })
expect($notifications.get()).toHaveLength(2)
clearAgentNotice('credits.usage')
const ids = $notifications.get().map(item => item.id)
expect(ids).toContain('credits.depleted')
expect(ids).not.toContain('credits.usage')
clearAgentNotice(undefined)
expect($notifications.get()).toHaveLength(1)
})

View file

@ -0,0 +1,78 @@
import { dismissNotification, type NotificationInput, type NotificationKind, notify } from '@/store/notifications'
/**
* Wire shape of a `notification.show` payload the driver-agnostic
* `AgentNotice` spine (`agent/credits_tracker.py`) as forwarded by
* `tui_gateway/server.py`. Snake_case to match the wire; the `text` already
* carries its own leading glyph ( ) from the Python policy, so a toast
* NEVER adds another icon on top.
*
* - `level` is severity: info | warn | error | success.
* - `kind` is lifetime: `sticky` (stays until an explicit clear) or `ttl`
* (self-expires after `ttl_ms`).
*/
export interface AgentNoticePayload {
text?: string
level?: string
kind?: string
ttl_ms?: null | number
key?: string
id?: string
}
const LEVEL_TO_TOAST_KIND: Record<string, NotificationKind> = {
error: 'error',
info: 'info',
success: 'success',
warn: 'warning'
}
/**
* Map an agent notice to a toast input, or `null` when it carries no text.
*
* Pure and side-effect free so it can be unit-tested directly. The mapping is
* the whole contract:
* - `level` toast kind (info/warn/error/success, warnwarning).
* - `sticky` `durationMs: 0` (persists); `ttl` `durationMs: ttl_ms`.
* - the notice `key` doubles as the toast `id`, so re-emitting the same key
* REPLACES the prior toast the credits 507590 line escalates in place
* instead of stacking, and a key-matched `notification.clear` can dismiss it.
*/
export function noticeToToast(payload: AgentNoticePayload | undefined): NotificationInput | null {
const text = payload?.text?.trim()
if (!text) {
return null
}
const isTtl = payload?.kind === 'ttl'
const ttl = isTtl && typeof payload?.ttl_ms === 'number' && payload.ttl_ms > 0 ? payload.ttl_ms : undefined
return {
// sticky → 0 (never auto-dismiss); ttl with a ttl_ms → that value; a ttl
// without a usable ttl_ms falls back to notify()'s per-kind default.
durationMs: isTtl ? ttl : 0,
id: payload?.key || payload?.id,
kind: LEVEL_TO_TOAST_KIND[payload?.level ?? 'info'] ?? 'info',
message: text
}
}
/** Render a `notification.show` notice as a toast (no-op when it has no text). */
export function showAgentNotice(payload: AgentNoticePayload | undefined): void {
const toast = noticeToToast(payload)
if (toast) {
notify(toast)
}
}
/**
* Dismiss the toast a `notification.clear` targets. The clear only ever names a
* `key`, which we used as the toast id, so this is a key-matched dismissal.
*/
export function clearAgentNotice(key: string | undefined): void {
if (key) {
dismissNotification(key)
}
}

View file

@ -25,7 +25,7 @@ export interface AppNotification {
placement?: NotificationPlacement
}
interface NotificationInput {
export interface NotificationInput {
id?: string
kind?: NotificationKind
icon?: string