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:
Brooklyn Nicholson 2026-07-23 01:13:18 -05:00
parent 59a735b8f3
commit d010220588
4 changed files with 225 additions and 17 deletions

View file

@ -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

View file

@ -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')
})

View file

@ -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)

View file

@ -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,