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 a96e780e875..2077d7dd2fc 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,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) diff --git a/apps/desktop/src/store/agent-notices.test.ts b/apps/desktop/src/store/agent-notices.test.ts new file mode 100644 index 00000000000..f40d4deacb4 --- /dev/null +++ b/apps/desktop/src/store/agent-notices.test.ts @@ -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 { + 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) +}) diff --git a/apps/desktop/src/store/agent-notices.ts b/apps/desktop/src/store/agent-notices.ts new file mode 100644 index 00000000000..a12dee73b20 --- /dev/null +++ b/apps/desktop/src/store/agent-notices.ts @@ -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 = { + 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, warn→warning). + * - `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 50→75→90 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) + } +} diff --git a/apps/desktop/src/store/notifications.ts b/apps/desktop/src/store/notifications.ts index 82a67e97312..5393d4a2623 100644 --- a/apps/desktop/src/store/notifications.ts +++ b/apps/desktop/src/store/notifications.ts @@ -25,7 +25,7 @@ export interface AppNotification { placement?: NotificationPlacement } -interface NotificationInput { +export interface NotificationInput { id?: string kind?: NotificationKind icon?: string