diff --git a/agent/credits_tracker.py b/agent/credits_tracker.py index f2186fb91a56..929bc34d3262 100644 --- a/agent/credits_tracker.py +++ b/agent/credits_tracker.py @@ -316,12 +316,21 @@ def evaluate_credits_notices( active.discard(CREDITS_USAGE_KEY) if target_band is not None: # Belt-and-suspenders: a producer could set subscription_limit_micros - # without subscription_limit_usd. Render "$? cap" rather than "$None cap". + # without subscription_limit_usd. Render "$?" rather than "$None". _cap_usd = state.subscription_limit_usd or "?" _level = current_band[1] # type: ignore[index] (current_band set when target_band set) + # Report absolute dollars used, not a bare "N% used": the percentage is + # only meaningful against a Nous subscription cap (no cap → never fires), + # so dollars are clearer and don't imply a universal %. Used = cap − + # remaining (micros, money-safe), clamped to [0, cap]. Re-emits on band + # change (50 → 75 → 90), not every turn — a snapshot, not a live ticker. + _lim = state.subscription_limit_micros or 0 + _used_micros = max(0, min(_lim, _lim - state.subscription_micros)) + _used_usd = f"{_used_micros / 1_000_000:.2f}" if _lim else "?" + _glyph = "⚠" if _level == "warn" else "•" to_show.append( AgentNotice( - text=f"{'⚠' if _level == 'warn' else '•'} Credits {target_band}% used · ${_cap_usd} cap", + text=f"{_glyph} You've used ${_used_usd} of your ${_cap_usd} cap", level=_level, kind=CREDITS_NOTICE_KIND, key=CREDITS_USAGE_KEY, diff --git a/apps/desktop/src/app/contrib/dev/credits-notice-demo.ts b/apps/desktop/src/app/contrib/dev/credits-notice-demo.ts new file mode 100644 index 000000000000..1766489818b8 --- /dev/null +++ b/apps/desktop/src/app/contrib/dev/credits-notice-demo.ts @@ -0,0 +1,128 @@ +// Dev-only: drive the credit-notice UX without a backend that can hit real +// usage bands. Each trigger emits ONE synthetic `notification.show` / +// `notification.clear` gateway event through the real fan-out +// (`emitLocalGatewayEvent`), so it exercises the actual dispatcher branch in +// `use-message-stream/gateway-event.ts` — toast render, key-replacement +// escalation, TTL self-dismiss, native OS notification, and billing re-poll. +// +// Installed only under `import.meta.env.DEV` (see contrib/wiring.tsx), so none +// of this ships in a production build. + +import type { GatewayEvent } from '@hermes/shared' + +import { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/contrib' +import { registry } from '@/contrib/registry' +import { CreditCard } from '@/lib/icons' +import { emitLocalGatewayEvent } from '@/store/gateway' +import { $activeSessionId } from '@/store/session' + +interface NoticeStep { + key: string + level: string + kind: 'sticky' | 'ttl' + text: string + ttl_ms?: number +} + +// Walks the same lifecycle the Nous credits tracker drives: usage escalates in +// place (50→75→90, one key), then grant-spent, then the depleted/restored pair. +// Wraps around. These are all separate SHOW steps; the stepper auto-clears the +// previous notice when the key changes, so the demo shows one toast at a time +// (real usage CAN stack these, but that's noise when you're eyeballing a single +// transition). The same-key usage steps still demonstrate in-place escalation. +const STEPS: readonly NoticeStep[] = [ + { key: 'credits.usage', kind: 'sticky', level: 'info', text: "• You've used $110.00 of your $220.00 cap" }, + { key: 'credits.usage', kind: 'sticky', level: 'warn', text: "⚠ You've used $165.00 of your $220.00 cap" }, + { key: 'credits.usage', kind: 'sticky', level: 'warn', text: "⚠ You've used $198.00 of your $220.00 cap" }, + { key: 'credits.grant_spent', kind: 'sticky', level: 'info', text: '• Grant spent · $12.00 top-up left' }, + { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ Credit access paused · run /topup to top up' }, + { key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ Credit access restored', ttl_ms: 8000 } +] + +let cursor = 0 +let lastShownKey: null | string = null + +function clearNotice(key: string): void { + emitLocalGatewayEvent({ + payload: { key }, + session_id: $activeSessionId.get() ?? '', + type: 'notification.clear' + } as GatewayEvent) +} + +function showNotice(step: NoticeStep): void { + emitLocalGatewayEvent({ + payload: { + id: `${step.key}:${Date.now()}`, + key: step.key, + kind: step.kind, + level: step.level, + text: step.text, + ttl_ms: step.ttl_ms ?? null + }, + session_id: $activeSessionId.get() ?? '', + type: 'notification.show' + } as GatewayEvent) +} + +/** Fire the next notice in the scripted sequence, wrapping at the end. */ +export function stepCreditsNoticeDemo(): void { + const step = STEPS[cursor % STEPS.length] + + // One toast at a time: retire the previous notice when we move to a new key. + // (Same-key steps skip this, so the usage line still escalates in place.) + if (lastShownKey && lastShownKey !== step.key) { + clearNotice(lastShownKey) + } + + showNotice(step) + lastShownKey = step.key + cursor += 1 +} + +// The hotkey: Ctrl+Shift+C on every platform (Ctrl, not Cmd, so it can't clash +// with a system Cmd chord and works the same on Windows/Linux). Matched on +// `code` so it's keyboard-layout independent, and captured before the composer +// so a focused input can't swallow it. +function isTriggerChord(e: KeyboardEvent): boolean { + return e.ctrlKey && e.shiftKey && !e.metaKey && !e.altKey && e.code === 'KeyC' +} + +/** + * Install the dev trigger: a capture-phase hotkey (Ctrl+Shift+C), a ⌘K palette + * entry, and a `window.__creditsDemo()` console hook. Returns a disposer. + */ +export function installCreditsNoticeDemo(): () => void { + const onKeyDown = (e: KeyboardEvent) => { + if (!isTriggerChord(e)) { + return + } + + e.preventDefault() + e.stopPropagation() + stepCreditsNoticeDemo() + } + + window.addEventListener('keydown', onKeyDown, { capture: true }) + ;(window as unknown as { __creditsDemo?: () => void }).__creditsDemo = stepCreditsNoticeDemo + + const disposePalette = registry.register({ + id: 'dev.creditsNotice', + area: PALETTE_AREA, + data: { + id: 'dev.creditsNotice', + icon: CreditCard, + keywords: ['credits', 'notice', 'toast', 'billing', 'dev', 'demo'], + label: 'Dev: cycle credit notices', + run: stepCreditsNoticeDemo + } satisfies PaletteContribution + }) + + console.info('[dev] credit-notice demo ready — press Ctrl+Shift+C, or run window.__creditsDemo()') + + return () => { + window.removeEventListener('keydown', onKeyDown, { capture: true }) + disposePalette() + delete (window as unknown as { __creditsDemo?: () => void }).__creditsDemo + } +} diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index b8908150c0aa..978d328e228f 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -272,6 +272,23 @@ export function ContribWiring({ children }: { children: ReactNode }) { return () => window.removeEventListener('hermes:open-keybinds', onOpenKeybinds) }, [navigate]) + // Dev-only: install the credit-notice demo trigger (Ctrl+Shift+C / ⌘K palette + // / window.__creditsDemo). Dynamic import inside the DEV guard so the module + // is dropped from production builds. + useEffect(() => { + if (!import.meta.env.DEV) { + return + } + + let dispose: (() => void) | undefined + + void import('./dev/credits-notice-demo').then(m => { + dispose = m.installCreditsNoticeDemo() + }) + + return () => dispose?.() + }, []) + // Post-turn rehydrate from stored history (same behavior as DesktopController, // including finished-todos restoration). const hydrateFromStoredSession = useCallback( 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 a96e780e875a..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,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, 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' @@ -865,6 +866,37 @@ 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. + 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 + // 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/app/settings/billing/use-billing-state.ts b/apps/desktop/src/app/settings/billing/use-billing-state.ts index 7611a055508c..606495d27e75 100644 --- a/apps/desktop/src/app/settings/billing/use-billing-state.ts +++ b/apps/desktop/src/app/settings/billing/use-billing-state.ts @@ -11,15 +11,22 @@ export const EMPTY_BILLING_VALUE = '—' export const FALLBACK_PORTAL_BILLING_URL = 'https://portal.nousresearch.com/billing' export const FALLBACK_PORTAL_URL = 'https://portal.nousresearch.com' -// Billing polls on its own while the page is mounted (react-query only ticks an -// active observer), so the view stays live without a manual refresh control — -// matching every other data view in the app. It pauses when the window is -// backgrounded (refetchIntervalInBackground defaults to false). +// The billing endpoint is the authoritative source of truth for balance / cap / +// plan — the inference `x-nous-credits-*` headers are best-effort and can drift +// out of sync (notably in team/org accounts where another member's spend moves +// the shared balance without ever touching THIS client's headers). So the page +// never trusts a cache: `staleTime: 0` + `refetchOnMount: 'always'` force a +// fresh fetch every time it opens or regains focus, and it keeps polling every +// 30s while mounted (react-query only ticks an active observer; it pauses when +// the window is backgrounded — refetchIntervalInBackground defaults to false). +// A `credits.*` notice crossing additionally invalidates ['billing','state'] to +// pull the change in immediately rather than waiting for the next poll tick. const BILLING_QUERY_OPTIONS = { refetchInterval: 30_000, + refetchOnMount: 'always', refetchOnWindowFocus: true, retry: false, - staleTime: 30_000 + staleTime: 0 } as const export interface BillingSummaryItemView { diff --git a/apps/desktop/src/components/notifications.tsx b/apps/desktop/src/components/notifications.tsx index ec6051843cd5..465e501f54ce 100644 --- a/apps/desktop/src/components/notifications.tsx +++ b/apps/desktop/src/components/notifications.tsx @@ -1,5 +1,5 @@ import { useStore } from '@nanostores/react' -import { type ReactNode, useEffect, useRef, useState } from 'react' +import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' @@ -162,6 +162,30 @@ function BottomRightStack({ ) } +// Emphasize only the leading money figure ("$16.00" — the amount used) with the +// accent color (semibold), leaving the rest of the line in its default muted +// tone. No accent, or no figure in the message → render the text untouched. +function renderMessage(message: string, accent?: string): ReactNode { + const match = accent ? /\$\d+(?:\.\d{2})?/.exec(message) : null + + if (!match) { + return message + } + + const start = match.index + const end = start + match[0].length + + return ( + <> + {message.slice(0, start)} + + {match[0]} + + {message.slice(end)} + + ) +} + function NotificationItem({ notification }: { notification: AppNotification }) { const styles = tone[notification.kind] const Icon = styles.icon @@ -169,6 +193,12 @@ function NotificationItem({ notification }: { notification: AppNotification }) { const { t } = useI18n() const copy = t.notifications + // Nudge the icon down to sit on the first text line, in `ch` so it tracks the + // toast's font size instead of a fixed rem. `accentColor` (when set) tints the + // icon + message as a severity ramp, overriding the kind's default color. + const accent = notification.accentColor + const iconStyle: CSSProperties = { marginTop: '0.42ch', ...(accent ? { color: accent } : {}) } + return ( {notification.icon ? ( - + ) : ( - + )}
{notification.title && {notification.title}} -

{notification.message}

+

{renderMessage(notification.message, accent)}

+ {notification.meta && ( +

{notification.meta}

+ )} {hasDetail && } {notification.action && (