mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
fix(desktop): de-dupe credit toast icon, band-color the figure, split detail
Three fixes to how agent credit notices render as toasts: - Strip the leading severity glyph (the toast already draws a kind icon, so the raw text doubled it). Native OS notifications keep the glyph (no icon there). - Icon top-margin is now 0.42ch (font-relative) instead of a fixed rem. - Band-color the $used figure (semibold) by $used/$cap: muted <75%, --ui-orange >=75%, --ui-red >=90% (depleted red, restored green), reusing the existing --ui-* usage palette. Icon shares the accent. - Split a trailing '. detail' into a muted secondary line (title+description convention) instead of an inline middot. Generic 'accentColor' + 'meta' slots on the notification; degrades gracefully when a notice has no figure.
This commit is contained in:
parent
59a735b8f3
commit
d010220588
4 changed files with 225 additions and 17 deletions
|
|
@ -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)}
|
||||
<span className="font-semibold" style={{ color: accent }}>
|
||||
{match[0]}
|
||||
</span>
|
||||
{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 (
|
||||
<Alert
|
||||
aria-live={notification.kind === 'error' ? 'assertive' : 'polite'}
|
||||
|
|
@ -177,14 +207,17 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
|
|||
variant={styles.variant}
|
||||
>
|
||||
{notification.icon ? (
|
||||
<Codicon className={styles.iconClass} name={notification.icon} size="1rem" />
|
||||
<Codicon className={styles.iconClass} name={notification.icon} size="1rem" style={iconStyle} />
|
||||
) : (
|
||||
<Icon className={styles.iconClass} />
|
||||
<Icon className={styles.iconClass} style={iconStyle} />
|
||||
)}
|
||||
<div className="col-start-2 min-w-0">
|
||||
{notification.title && <AlertTitle className="col-start-auto">{notification.title}</AlertTitle>}
|
||||
<AlertDescription className="col-start-auto">
|
||||
<p className="m-0">{notification.message}</p>
|
||||
<p className="m-0">{renderMessage(notification.message, accent)}</p>
|
||||
{notification.meta && (
|
||||
<p className="m-0 text-xs text-muted-foreground tabular-nums">{notification.meta}</p>
|
||||
)}
|
||||
{hasDetail && <NotificationDetail detail={notification.detail || ''} />}
|
||||
{notification.action && (
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -4,8 +4,12 @@ import {
|
|||
type AgentNoticePayload,
|
||||
clearAgentNotice,
|
||||
nativeNoticeInput,
|
||||
noticeAccent,
|
||||
noticeToToast,
|
||||
showAgentNotice
|
||||
showAgentNotice,
|
||||
splitMeta,
|
||||
stripGlyph,
|
||||
usageFraction
|
||||
} from './agent-notices'
|
||||
import { $notifications, clearNotifications } from './notifications'
|
||||
|
||||
|
|
@ -14,7 +18,7 @@ function usage(overrides: Partial<AgentNoticePayload> = {}): AgentNoticePayload
|
|||
key: 'credits.usage',
|
||||
kind: 'sticky',
|
||||
level: 'info',
|
||||
text: '• Credits 50% used · $220.00 cap',
|
||||
text: "• You've used $110.00 of your $220.00 cap",
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
|
@ -62,8 +66,76 @@ test('the notice key is the toast id, falling back to id', () => {
|
|||
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')
|
||||
test('the leading severity glyph is stripped from the toast message', () => {
|
||||
// The toast renders a kind icon, so the message must not double up on a glyph.
|
||||
expect(noticeToToast(usage())?.message).toBe("You've used $110.00 of your $220.00 cap")
|
||||
expect(noticeToToast(usage({ level: 'error', text: '✕ Credit access paused' }))?.message).toBe('Credit access paused')
|
||||
expect(noticeToToast(usage({ level: 'success', text: '✓ Credit access restored' }))?.message).toBe(
|
||||
'Credit access restored'
|
||||
)
|
||||
})
|
||||
|
||||
test('the trailing "· detail" is split off as a secondary meta line, not inlined', () => {
|
||||
// grant_spent still carries a `· detail` tail.
|
||||
const grant = noticeToToast({ key: 'credits.grant_spent', level: 'info', text: '• Grant spent · $12.00 top-up left' })
|
||||
expect(grant?.message).toBe('Grant spent')
|
||||
expect(grant?.meta).toBe('$12.00 top-up left')
|
||||
|
||||
// The usage line has no middot → whole line is the message, no meta.
|
||||
const plain = noticeToToast(usage())
|
||||
expect(plain?.message).toBe("You've used $110.00 of your $220.00 cap")
|
||||
expect(plain?.meta).toBeUndefined()
|
||||
})
|
||||
|
||||
test('splitMeta splits on the first space-middot-space only', () => {
|
||||
expect(splitMeta('Grant spent · $12.00 top-up left')).toEqual(['Grant spent', '$12.00 top-up left'])
|
||||
expect(splitMeta('Credit access restored')).toEqual(['Credit access restored', undefined])
|
||||
// Interior middots after the first split stay in the meta.
|
||||
expect(splitMeta('a · b · c')).toEqual(['a', 'b · c'])
|
||||
})
|
||||
|
||||
test('stripGlyph removes only a single leading severity glyph', () => {
|
||||
expect(stripGlyph('• Credits 50% used')).toBe('Credits 50% used')
|
||||
expect(stripGlyph('⚠ warn')).toBe('warn')
|
||||
expect(stripGlyph('✕ paused')).toBe('paused')
|
||||
expect(stripGlyph('✓ ok')).toBe('ok')
|
||||
// No leading glyph → unchanged; interior glyphs are preserved.
|
||||
expect(stripGlyph('Credits 50% used')).toBe('Credits 50% used')
|
||||
expect(stripGlyph('spent · $12.00 • top-up left')).toBe('spent · $12.00 • top-up left')
|
||||
})
|
||||
|
||||
// ── noticeAccent: severity color ramp keyed off $used / $cap ─────────────────
|
||||
|
||||
test('usageFraction derives $used / $cap from the notice text', () => {
|
||||
expect(usageFraction("You've used $15.00 of your $20.00 cap")).toBeCloseTo(0.75)
|
||||
expect(usageFraction("You've used $198.00 of your $220.00 cap")).toBeCloseTo(0.9)
|
||||
// Fewer than two amounts, or a zero cap → no fraction.
|
||||
expect(usageFraction('Grant spent')).toBeNull()
|
||||
expect(usageFraction("You've used $5.00 of your $0.00 cap")).toBeNull()
|
||||
expect(usageFraction(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
test('usage accent stays muted below 75%, then ramps orange → red', () => {
|
||||
expect(noticeAccent(usage({ text: "• You've used $10.00 of your $20.00 cap" }))).toBeUndefined() // 50%
|
||||
expect(noticeAccent(usage({ text: "• You've used $14.80 of your $20.00 cap" }))).toBeUndefined() // 74%
|
||||
expect(noticeAccent(usage({ level: 'warn', text: "⚠ You've used $15.00 of your $20.00 cap" }))).toBe('var(--ui-orange)') // 75%
|
||||
expect(noticeAccent(usage({ level: 'warn', text: "⚠ You've used $17.80 of your $20.00 cap" }))).toBe('var(--ui-orange)') // 89%
|
||||
expect(noticeAccent(usage({ level: 'warn', text: "⚠ You've used $18.00 of your $20.00 cap" }))).toBe('var(--ui-red)') // 90%
|
||||
expect(noticeAccent(usage({ level: 'warn', text: "⚠ You've used $20.00 of your $20.00 cap" }))).toBe('var(--ui-red)') // 100%
|
||||
})
|
||||
|
||||
test('terminal credit states carry their own accent; others stay default', () => {
|
||||
expect(noticeAccent({ key: 'credits.depleted', text: '✕ paused' })).toBe('var(--ui-red)')
|
||||
expect(noticeAccent({ key: 'credits.restored', text: '✓ restored' })).toBe('var(--ui-green)')
|
||||
expect(noticeAccent({ key: 'credits.grant_spent', text: '• Grant spent' })).toBeUndefined()
|
||||
expect(noticeAccent(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
test('noticeToToast attaches the band accent to the toast', () => {
|
||||
expect(noticeToToast(usage({ level: 'warn', text: "⚠ You've used $15.00 of your $20.00 cap" }))?.accentColor).toBe(
|
||||
'var(--ui-orange)'
|
||||
)
|
||||
expect(noticeToToast(usage({ text: "• You've used $10.00 of your $20.00 cap" }))?.accentColor).toBeUndefined()
|
||||
})
|
||||
|
||||
// ── show / clear: rendered through the notifications store ────────────────────
|
||||
|
|
@ -78,13 +150,13 @@ test('showAgentNotice renders a toast; empty text is a no-op', () => {
|
|||
})
|
||||
|
||||
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' }))
|
||||
showAgentNotice(usage({ level: 'info', text: "• You've used $10.00 of your $20.00 cap" }))
|
||||
showAgentNotice(usage({ level: 'warn', text: "⚠ You've used $15.00 of your $20.00 cap" }))
|
||||
showAgentNotice(usage({ level: 'warn', text: "⚠ You've used $18.00 of your $20.00 cap" }))
|
||||
|
||||
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]?.message).toBe("You've used $18.00 of your $20.00 cap")
|
||||
expect(toasts[0]?.kind).toBe('warning')
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,14 @@ import { dismissNotification, type NotificationInput, type NotificationKind, not
|
|||
/**
|
||||
* 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.
|
||||
* `tui_gateway/server.py`. Snake_case to match the wire.
|
||||
*
|
||||
* The `text` carries its own leading severity glyph (• ⚠ ✕ ✓) from the Python
|
||||
* policy — that's how the CLI/TUI render it (glyph in a status line, no separate
|
||||
* icon). The desktop toast is different: every toast renders a kind icon, so we
|
||||
* strip the leading glyph and let that icon carry severity (see `stripGlyph`),
|
||||
* otherwise the toast shows two markers. The native OS notification keeps the
|
||||
* glyph (it has no icon of ours).
|
||||
*
|
||||
* - `level` is severity: info | warn | error | success.
|
||||
* - `kind` is lifetime: `sticky` (stays until an explicit clear) or `ttl`
|
||||
|
|
@ -28,6 +33,70 @@ const LEVEL_TO_TOAST_KIND: Record<string, NotificationKind> = {
|
|||
warn: 'warning'
|
||||
}
|
||||
|
||||
// The severity glyphs the Python notice policy prefixes (`•` `⚠` `✕`/`✗` `✓`),
|
||||
// optionally with a variation selector, plus trailing space. Stripped for the
|
||||
// desktop toast because the toast already renders a kind icon.
|
||||
const LEADING_GLYPH = /^[•⚠✕✗✓]\uFE0F?\s*/u
|
||||
|
||||
/** Drop a single leading severity glyph so the toast doesn't double up on it. */
|
||||
export function stripGlyph(text: string): string {
|
||||
return text.replace(LEADING_GLYPH, '')
|
||||
}
|
||||
|
||||
/** A `$12.34` money token, as the Nous notice policy formats amounts. */
|
||||
const MONEY = /\$(\d+(?:\.\d{2})?)/g
|
||||
|
||||
/**
|
||||
* Used fraction of the cap derived from a "You've used $X of your $Y cap" notice
|
||||
* — the first two money tokens are (used, cap). Returns a value in [0, 1], or
|
||||
* `null` when the line has no usable pair.
|
||||
*/
|
||||
export function usageFraction(text: string | undefined): null | number {
|
||||
const amounts = [...(text ?? '').matchAll(MONEY)].map(m => Number(m[1]))
|
||||
|
||||
if (amounts.length < 2 || !(amounts[1] > 0)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return amounts[0] / amounts[1]
|
||||
}
|
||||
|
||||
/**
|
||||
* Accent color for a credit notice, as a range/severity ramp. The usage gauge
|
||||
* keys off how much of the cap is spent ($used / $cap): it stays on the toast's
|
||||
* default muted color under 75%, then escalates to orange, then red, as the cap
|
||||
* nears. `credits.depleted` is red (paused) and `credits.restored` green.
|
||||
*
|
||||
* Returns a CSS color token or `undefined` (= keep the default muted color).
|
||||
* These reuse the app's existing usage palette (`--ui-*`; see the
|
||||
* `--context-usage-*` block in styles.css) — no new colors are introduced.
|
||||
*/
|
||||
export function noticeAccent(payload: AgentNoticePayload | undefined): string | undefined {
|
||||
if (payload?.key === 'credits.depleted') {
|
||||
return 'var(--ui-red)'
|
||||
}
|
||||
|
||||
if (payload?.key === 'credits.restored') {
|
||||
return 'var(--ui-green)'
|
||||
}
|
||||
|
||||
const frac = usageFraction(payload?.text)
|
||||
|
||||
if (frac === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (frac >= 0.9) {
|
||||
return 'var(--ui-red)'
|
||||
}
|
||||
|
||||
if (frac >= 0.75) {
|
||||
return 'var(--ui-orange)'
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an agent notice to a toast input, or `null` when it carries no text.
|
||||
*
|
||||
|
|
@ -47,18 +116,44 @@ export function noticeToToast(payload: AgentNoticePayload | undefined): Notifica
|
|||
}
|
||||
|
||||
const isTtl = payload?.kind === 'ttl'
|
||||
const ttl = isTtl && typeof payload?.ttl_ms === 'number' && payload.ttl_ms > 0 ? payload.ttl_ms : undefined
|
||||
const ttl = typeof payload?.ttl_ms === 'number' && payload.ttl_ms > 0 ? payload.ttl_ms : undefined
|
||||
|
||||
// The Python notice text packs a trailing detail after a middot
|
||||
// (`… used · $220.00 cap`, `Grant spent · $12.00 top-up left`). On one CLI/TUI
|
||||
// status line that reads fine, but the toast follows the title-plus-description
|
||||
// convention (Sonner/shadcn): the primary status is the message and the detail
|
||||
// drops to a muted second line, instead of inlining a `·` separator.
|
||||
const [primary, meta] = splitMeta(stripGlyph(text))
|
||||
|
||||
return {
|
||||
// Icon + text tint by usage band (muted → orange → red); undefined keeps
|
||||
// the default muted color.
|
||||
accentColor: noticeAccent(payload),
|
||||
// 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
|
||||
message: primary,
|
||||
meta
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a notice line into its primary status and a trailing detail on the first
|
||||
* ` · ` (space-middot-space). No middot → the whole line is the primary and
|
||||
* there's no meta.
|
||||
*/
|
||||
export function splitMeta(text: string): [primary: string, meta: string | undefined] {
|
||||
const at = text.indexOf(' · ')
|
||||
|
||||
if (at === -1) {
|
||||
return [text, undefined]
|
||||
}
|
||||
|
||||
return [text.slice(0, at), text.slice(at + 3) || undefined]
|
||||
}
|
||||
|
||||
/** 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)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ export interface AppNotification {
|
|||
kind: NotificationKind
|
||||
/** When set, renders this codicon instead of the default kind icon. */
|
||||
icon?: string
|
||||
/** When set, tints the icon and message with this CSS color (severity ramp). */
|
||||
accentColor?: string
|
||||
/** Secondary detail line rendered below the message, muted (e.g. "$220.00 cap"). */
|
||||
meta?: string
|
||||
title?: string
|
||||
message: string
|
||||
detail?: string
|
||||
|
|
@ -29,6 +33,8 @@ export interface NotificationInput {
|
|||
id?: string
|
||||
kind?: NotificationKind
|
||||
icon?: string
|
||||
accentColor?: string
|
||||
meta?: string
|
||||
title?: string
|
||||
message: string
|
||||
detail?: string
|
||||
|
|
@ -130,6 +136,8 @@ export function notify(input: NotificationInput): string {
|
|||
id,
|
||||
kind,
|
||||
icon: input.icon,
|
||||
accentColor: input.accentColor,
|
||||
meta: input.meta,
|
||||
title: input.title,
|
||||
message: input.message,
|
||||
detail: input.detail,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue