From 8cfc9e4e27adc86e6b3d083b257bfc05cd8f06db Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 23 Jul 2026 01:13:26 -0500 Subject: [PATCH] chore(desktop): dev-only credit-notice demo hotkey Ctrl+Shift+C (and window.__creditsDemo()) steps the full credit-notice lifecycle (usage 50->75->90, grant-spent, depleted/restored) through the real gateway event fan-out via a new emitLocalGatewayEvent, so the toast/native/ billing-invalidation paths are testable without hitting real usage bands. Installed only under import.meta.env.DEV, so it's tree-shaken from production. --- .../app/contrib/dev/credits-notice-demo.ts | 128 ++++++++++++++++++ apps/desktop/src/app/contrib/wiring.tsx | 17 +++ apps/desktop/src/store/gateway.ts | 11 ++ 3 files changed, 156 insertions(+) create mode 100644 apps/desktop/src/app/contrib/dev/credits-notice-demo.ts 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/store/gateway.ts b/apps/desktop/src/store/gateway.ts index 9b0f037b2ac6..b07a322e6775 100644 --- a/apps/desktop/src/store/gateway.ts +++ b/apps/desktop/src/store/gateway.ts @@ -35,6 +35,17 @@ export function configureGatewayRegistry(cfg: RegistryConfig): void { config = cfg } +/** + * Feed a synthetic event through the exact same fan-out a real socket frame + * takes (`config.onEvent` → the desktop's `handleGatewayEvent`). Used by + * dev-only tooling to exercise the real event branches (e.g. the credit-notice + * demo) without a backend that can produce the event on demand. No-op until a + * registry is configured. + */ +export function emitLocalGatewayEvent(event: GatewayEvent): void { + config?.onEvent(event) +} + // ── Primary (window) backend ─────────────────────────────────────────────── let primaryGateway: HermesGateway | null = null let primaryProfile = 'default'