hermes-agent/apps/desktop/src/store/native-notifications.ts
Brooklyn Nicholson 630a4ef03c feat(desktop): native OS notifications with per-type toggles
Adds a native OS notification system (Electron Notification, routed cross-OS)
distinct from the in-app toast feed. Before this, one hardcoded cue existed
(message.complete while document.hidden) with no settings or event coverage.

- Engine (store/native-notifications.ts): localStorage-backed prefs (master
  switch + per-kind toggles) and a gated dispatcher over five kinds — approval,
  input, turnDone, turnError, backgroundDone — with a 1s per-(kind,session)
  self-evicting throttle.
- Gating: "backgrounded" = document.hidden OR !document.hasFocus(), so an
  alt-tabbed window still counts as away. Completion kinds fire only when
  backgrounded and for the active session (no spam from a busy gateway);
  attention kinds (approval/input) also break through for off-screen sessions.
- Wired into real event sites (use-message-stream.ts): message.complete, error,
  approval/clarify/sudo/secret.request; backgroundDone from composer-status at
  the running -> exited transition.
- Click focuses the window and jumps to the originating session; approval
  notifications carry Approve/Reject buttons that resolve in place over
  approval.respond, mirroring the in-app Run/Reject bar.
- Settings: new Notifications panel (master + per-kind switches, test button
  with real OS-result feedback). Full i18n (en/ja/zh/zh-hant).
2026-06-14 00:31:03 -05:00

203 lines
5.6 KiB
TypeScript

import { atom } from 'nanostores'
import { persistString, storedString } from '@/lib/storage'
import { $gateway } from './gateway'
import { clearApprovalRequest } from './prompts'
import { $activeSessionId } from './session'
// Native OS notifications (Electron `Notification`), separate from the in-app
// toast feed in `notifications.ts`. Each kind toggles independently.
export type NativeNotificationKind = 'approval' | 'backgroundDone' | 'input' | 'turnDone' | 'turnError'
export const NATIVE_NOTIFICATION_KINDS: readonly NativeNotificationKind[] = [
'approval',
'input',
'turnDone',
'turnError',
'backgroundDone'
]
// Blocking prompts — surface even while focused if they're for another session.
const ATTENTION_KINDS = new Set<NativeNotificationKind>(['approval', 'input'])
export interface NativeNotificationPrefs {
enabled: boolean
kinds: Record<NativeNotificationKind, boolean>
}
const STORAGE_KEY = 'hermes:native-notifications'
const DEFAULT_PREFS: NativeNotificationPrefs = {
enabled: true,
kinds: { approval: true, backgroundDone: true, input: true, turnDone: true, turnError: true }
}
function readPrefs(): NativeNotificationPrefs {
const raw = storedString(STORAGE_KEY)
if (!raw) {
return DEFAULT_PREFS
}
try {
const parsed = JSON.parse(raw) as Partial<NativeNotificationPrefs>
const kinds = { ...DEFAULT_PREFS.kinds }
for (const kind of NATIVE_NOTIFICATION_KINDS) {
const value = parsed.kinds?.[kind]
if (typeof value === 'boolean') {
kinds[kind] = value
}
}
return {
enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : DEFAULT_PREFS.enabled,
kinds
}
} catch {
return DEFAULT_PREFS
}
}
export const $nativeNotifyPrefs = atom<NativeNotificationPrefs>(readPrefs())
function writePrefs(next: NativeNotificationPrefs) {
$nativeNotifyPrefs.set(next)
persistString(STORAGE_KEY, JSON.stringify(next))
}
export function setNativeNotifyEnabled(enabled: boolean) {
writePrefs({ ...$nativeNotifyPrefs.get(), enabled })
}
export function setNativeNotifyKind(kind: NativeNotificationKind, on: boolean) {
const prev = $nativeNotifyPrefs.get()
writePrefs({ ...prev, kinds: { ...prev.kinds, [kind]: on } })
}
// De-dupe replayed events for the same kind+session. Self-evicting: entries
// older than the window are pruned on every dispatch, so the map can't grow.
const THROTTLE_MS = 1000
const lastFiredAt = new Map<string, number>()
function throttled(key: string, now: number): boolean {
for (const [k, at] of lastFiredAt) {
if (now - at >= THROTTLE_MS) {
lastFiredAt.delete(k)
}
}
if (lastFiredAt.has(key)) {
return true
}
lastFiredAt.set(key, now)
return false
}
// "Backgrounded" = the user isn't on Hermes. `document.hidden` only flips when
// minimized/occluded; an alt-tabbed window is visible-but-unfocused, so we also
// check `document.hasFocus()`.
function isBackgrounded(): boolean {
if (typeof document === 'undefined') {
return false
}
if (document.hidden) {
return true
}
return typeof document.hasFocus === 'function' && !document.hasFocus()
}
function shouldFire(kind: NativeNotificationKind, sessionId?: null | string): boolean {
// Attention kinds break through for an off-screen session even while focused.
if (ATTENTION_KINDS.has(kind)) {
return isBackgrounded() || (Boolean(sessionId) && sessionId !== $activeSessionId.get())
}
// Completion kinds: only the active session, only while away — so a busy
// gateway (messaging, kanban, cron) can't spam a toast per background session.
return isBackgrounded() && Boolean(sessionId) && sessionId === $activeSessionId.get()
}
export interface NativeNotificationAction {
id: string
text: string
}
export interface NativeNotificationInput {
kind: NativeNotificationKind
title: string
body?: string
sessionId?: null | string
silent?: boolean
actions?: NativeNotificationAction[]
}
export function dispatchNativeNotification(input: NativeNotificationInput): void {
const prefs = $nativeNotifyPrefs.get()
if (!prefs.enabled || !prefs.kinds[input.kind]) {
return
}
if (!shouldFire(input.kind, input.sessionId)) {
return
}
if (throttled(`${input.kind}:${input.sessionId ?? ''}`, Date.now())) {
return
}
void window.hermesDesktop?.notify({
actions: input.actions,
body: input.body,
kind: input.kind,
sessionId: input.sessionId ?? undefined,
silent: input.silent,
title: input.title
})
}
// Resolve a pending approval from a notification button, mirroring the in-app
// Run/Reject bar. Keyed by session id — a background approval has no local guard.
export async function respondToApprovalAction(sessionId: null | string, actionId: string): Promise<void> {
const choice = actionId === 'approve' ? 'once' : actionId === 'reject' ? 'deny' : null
if (!choice) {
return
}
const gateway = $gateway.get()
if (!gateway) {
return
}
try {
await gateway.request('approval.respond', { choice, session_id: sessionId ?? undefined })
clearApprovalRequest(sessionId)
} catch {
// Leave the prompt parked so the user can still resolve it in-app.
}
}
// Settings "send test" — bypasses gating. Returns whether the OS accepted it so
// the panel can flag a silent permission failure instead of looking dead.
export async function sendTestNativeNotification(title: string, body: string): Promise<boolean> {
const bridge = window.hermesDesktop
if (!bridge?.notify) {
return false
}
try {
return await bridge.notify({ body, kind: 'turnDone', title })
} catch {
return false
}
}