diff --git a/apps/desktop/src/components/notifications.tsx b/apps/desktop/src/components/notifications.tsx index ec6051843cd..465e501f54c 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 && ( = {}): 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') }) diff --git a/apps/desktop/src/store/agent-notices.ts b/apps/desktop/src/store/agent-notices.ts index c076ee9854e..683855428ee 100644 --- a/apps/desktop/src/store/agent-notices.ts +++ b/apps/desktop/src/store/agent-notices.ts @@ -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 = { 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) diff --git a/apps/desktop/src/store/notifications.ts b/apps/desktop/src/store/notifications.ts index 5393d4a2623..92f41163506 100644 --- a/apps/desktop/src/store/notifications.ts +++ b/apps/desktop/src/store/notifications.ts @@ -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,
{notification.message}
{renderMessage(notification.message, accent)}
{notification.meta}