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.
This commit is contained in:
Brooklyn Nicholson 2026-07-23 01:13:26 -05:00
parent 664b131c30
commit 8cfc9e4e27
3 changed files with 156 additions and 0 deletions

View file

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

View file

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

View file

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