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).
This commit is contained in:
Brooklyn Nicholson 2026-06-14 00:31:03 -05:00
parent b4ba3f5e3b
commit 630a4ef03c
17 changed files with 877 additions and 20 deletions

View File

@ -5609,11 +5609,30 @@ ipcMain.handle('hermes:api', async (_event, request) => {
ipcMain.handle('hermes:notify', (_event, payload) => {
if (!Notification.isSupported()) return false
new Notification({
// Action buttons render only on signed macOS builds; elsewhere they're dropped
// and the body click still works.
const actions = Array.isArray(payload?.actions) ? payload.actions : []
const notification = new Notification({
title: payload?.title || 'Hermes',
body: payload?.body || '',
silent: Boolean(payload?.silent)
}).show()
silent: Boolean(payload?.silent),
actions: actions.map(action => ({ type: 'button', text: String(action?.text || '') }))
})
notification.on('click', () => {
if (!mainWindow || mainWindow.isDestroyed()) return
focusWindow(mainWindow)
if (payload?.sessionId) {
mainWindow.webContents.send('hermes:focus-session', payload.sessionId)
}
})
notification.on('action', (_actionEvent, index) => {
if (!mainWindow || mainWindow.isDestroyed()) return
const action = actions[index]
if (action?.id) {
mainWindow.webContents.send('hermes:notification-action', { sessionId: payload?.sessionId, actionId: action.id })
}
})
notification.show()
return true
})

View File

@ -94,6 +94,16 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
ipcRenderer.on('hermes:window-state-changed', listener)
return () => ipcRenderer.removeListener('hermes:window-state-changed', listener)
},
onFocusSession: callback => {
const listener = (_event, sessionId) => callback(sessionId)
ipcRenderer.on('hermes:focus-session', listener)
return () => ipcRenderer.removeListener('hermes:focus-session', listener)
},
onNotificationAction: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:notification-action', listener)
return () => ipcRenderer.removeListener('hermes:notification-action', listener)
},
onPreviewFileChanged: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:preview-file-changed', listener)

View File

@ -37,6 +37,7 @@ import {
SIDEBAR_SESSIONS_PAGE_SIZE,
unpinSession
} from '../store/layout'
import { respondToApprovalAction } from '../store/native-notifications'
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
import {
$activeGatewayProfile,
@ -269,6 +270,26 @@ export function DesktopController() {
}
}, [])
// Notification click: the main process already focused the window; jump to its session.
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onFocusSession?.(sessionId => {
if (sessionId) {
navigate(sessionRoute(sessionId))
}
})
return () => unsubscribe?.()
}, [navigate])
// Notification action button (Approve/Reject) — resolve in place, no navigation.
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onNotificationAction?.(({ actionId, sessionId }) => {
void respondToApprovalAction(sessionId ?? null, actionId)
})
return () => unsubscribe?.()
}, [])
// hermes:// deep links (e.g. a docs "Send to App" button for an automation blueprint).
// Build the equivalent /blueprint slash command from the payload and drop
// it into the composer — the user reviews/edits, then sends; the agent (or

View File

@ -2,6 +2,7 @@ import type { QueryClient } from '@tanstack/react-query'
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer'
import { translateNow } from '@/i18n'
import {
appendAssistantTextPart,
appendReasoningPart,
@ -28,6 +29,7 @@ import { parseTodos } from '@/lib/todos'
import { setClarifyRequest } from '@/store/clarify'
import { refreshBackgroundProcesses } from '@/store/composer-status'
import { $gateway } from '@/store/gateway'
import { dispatchNativeNotification } from '@/store/native-notifications'
import { notify } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
@ -641,14 +643,14 @@ export function useMessageStream({
void hydrateFromStoredSession(3, completedState.storedSessionId, sessionId)
}
if (document.hidden && sessionId === activeSessionIdRef.current) {
void window.hermesDesktop?.notify({
title: 'Hermes finished',
body: text.slice(0, 140) || 'The response is ready.'
dispatchNativeNotification({
body: text.slice(0, 140) || translateNow('notifications.native.turnDoneBody'),
kind: 'turnDone',
sessionId,
title: translateNow('notifications.native.turnDoneTitle')
})
}
},
[activeSessionIdRef, hydrateFromStoredSession, refreshSessions, updateSessionState]
[hydrateFromStoredSession, refreshSessions, updateSessionState]
)
const failAssistantMessage = useCallback(
@ -957,6 +959,13 @@ export function useMessageStream({
if (sessionId) {
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
}
dispatchNativeNotification({
body: question,
kind: 'input',
sessionId,
title: translateNow('notifications.native.inputTitle')
})
}
} else if (event.type === 'approval.request') {
// Dangerous-command / execute_code approval. The Python side is blocked
@ -965,17 +974,31 @@ export function useMessageStream({
// Park it per-session (like clarify) so a *background* profile's turn can
// raise it and wait — the sidebar flags "needs input" and the inline bar
// surfaces once the user focuses that chat.
const command = typeof payload?.command === 'string' ? payload.command : ''
const description = typeof payload?.description === 'string' ? payload.description : 'dangerous command'
setApprovalRequest({
// false only when a tirith warning forbids it; backend omits the field otherwise.
allowPermanent: payload?.allow_permanent !== false,
command: typeof payload?.command === 'string' ? payload.command : '',
description: typeof payload?.description === 'string' ? payload.description : 'dangerous command',
command,
description,
sessionId: sessionId ?? null
})
if (sessionId) {
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
}
dispatchNativeNotification({
actions: [
{ id: 'approve', text: translateNow('notifications.native.approveAction') },
{ id: 'reject', text: translateNow('notifications.native.rejectAction') }
],
body: command || description,
kind: 'approval',
sessionId,
title: translateNow('notifications.native.approvalTitle')
})
} else if (event.type === 'sudo.request') {
// Sudo password capture (tools/terminal_tool.py). Blocked on
// sudo.respond {request_id, password}.
@ -987,6 +1010,13 @@ export function useMessageStream({
if (sessionId) {
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
}
dispatchNativeNotification({
body: translateNow('notifications.native.inputBody'),
kind: 'input',
sessionId,
title: translateNow('notifications.native.inputTitle')
})
}
} else if (event.type === 'secret.request') {
// Skill credential capture (tools/skills_tool.py). Blocked on
@ -994,16 +1024,26 @@ export function useMessageStream({
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
if (requestId) {
const envVar = typeof payload?.env_var === 'string' ? payload.env_var : ''
const promptText = typeof payload?.prompt === 'string' ? payload.prompt : ''
setSecretRequest({
requestId,
envVar: typeof payload?.env_var === 'string' ? payload.env_var : '',
prompt: typeof payload?.prompt === 'string' ? payload.prompt : '',
envVar,
prompt: promptText,
sessionId: sessionId ?? null
})
if (sessionId) {
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
}
dispatchNativeNotification({
body: promptText || envVar || translateNow('notifications.native.inputBody'),
kind: 'input',
sessionId,
title: translateNow('notifications.native.inputTitle')
})
}
} else if (event.type === 'terminal.read.request') {
// read_terminal tool: serialize the renderer's xterm buffer and answer
@ -1037,6 +1077,13 @@ export function useMessageStream({
clearAllPrompts(sessionId)
}
dispatchNativeNotification({
body: errorMessage,
kind: 'turnError',
sessionId,
title: translateNow('notifications.native.turnErrorTitle')
})
if (looksLikeProviderSetup) {
requestDesktopOnboarding(errorMessage)
} else if (isActiveEvent) {

View File

@ -5,7 +5,7 @@ import { Tip } from '@/components/ui/tooltip'
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Archive, Globe, Info, KeyRound, Settings2, Sparkles, Wrench, Zap } from '@/lib/icons'
import { Archive, Bell, Globe, Info, KeyRound, Settings2, Sparkles, Wrench, Zap } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
@ -20,6 +20,7 @@ import { SECTIONS } from './constants'
import { GatewaySettings } from './gateway-settings'
import { KEYS_VIEWS, KeysSettings, type KeysView } from './keys-settings'
import { McpSettings } from './mcp-settings'
import { NotificationsSettings } from './notifications-settings'
import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings'
import { SessionsSettings } from './sessions-settings'
import type { SettingsPageProps, SettingsView as SettingsViewId } from './types'
@ -30,6 +31,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
'gateway',
'keys',
'mcp',
'notifications',
'sessions',
'about'
]
@ -101,6 +103,12 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
/>
)
})}
<OverlayNavItem
active={activeView === 'notifications'}
icon={Bell}
label={t.settings.nav.notifications}
onClick={() => setActiveView('notifications')}
/>
<div className="my-2 h-px bg-border/30" />
<OverlayNavItem
active={activeView === 'providers'}
@ -225,6 +233,8 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
<KeysSettings view={keysView} />
) : activeView === 'mcp' ? (
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} />
) : activeView === 'notifications' ? (
<NotificationsSettings />
) : (
<SessionsSettings />
)}

View File

@ -0,0 +1,98 @@
import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Bell } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$nativeNotifyPrefs,
NATIVE_NOTIFICATION_KINDS,
sendTestNativeNotification,
setNativeNotifyEnabled,
setNativeNotifyKind
} from '@/store/native-notifications'
import { notify } from '@/store/notifications'
import { ListRow, SectionHeading, SettingsContent } from './primitives'
const CAPTION = 'text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)'
function Caption({ children, className }: { children: ReactNode; className?: string }) {
return <p className={cn(CAPTION, className)}>{children}</p>
}
function ToggleRow(props: {
checked: boolean
description: string
disabled?: boolean
label: string
onChange: (on: boolean) => void
}) {
return (
<ListRow
action={
<Switch
aria-label={props.label}
checked={props.checked}
disabled={props.disabled}
onCheckedChange={on => {
triggerHaptic('selection')
props.onChange(on)
}}
/>
}
description={props.description}
title={props.label}
/>
)
}
export function NotificationsSettings() {
const { t } = useI18n()
const prefs = useStore($nativeNotifyPrefs)
const copy = t.settings.notifications
const runTest = async () => {
triggerHaptic('open')
const ok = await sendTestNativeNotification(copy.testTitle, copy.testBody)
notify({ kind: ok ? 'info' : 'error', message: ok ? copy.testSent : copy.testUnsupported })
}
return (
<SettingsContent>
<SectionHeading icon={Bell} title={copy.title} />
<Caption className="mb-2 leading-(--conversation-caption-line-height)">{copy.intro}</Caption>
<ToggleRow
checked={prefs.enabled}
description={copy.enableAllDesc}
label={copy.enableAll}
onChange={setNativeNotifyEnabled}
/>
<div className="my-1 h-px bg-border/30" />
{NATIVE_NOTIFICATION_KINDS.map(kind => (
<ToggleRow
checked={prefs.enabled && prefs.kinds[kind]}
description={copy.kinds[kind].description}
disabled={!prefs.enabled}
key={kind}
label={copy.kinds[kind].label}
onChange={on => setNativeNotifyKind(kind, on)}
/>
))}
<div className="mt-4 flex flex-col gap-2">
<Button className="self-start" onClick={() => void runTest()} size="sm" type="button" variant="outline">
<Bell />
{copy.test}
</Button>
<Caption>{copy.focusedHint}</Caption>
</div>
</SettingsContent>
)
}

View File

@ -4,7 +4,15 @@ import type { HermesGateway } from '@/hermes'
import type { IconComponent } from '@/lib/icons'
import type { EnvVarInfo } from '@/types/hermes'
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'providers' | 'sessions' | `config:${string}`
export type SettingsView =
| 'about'
| 'gateway'
| 'keys'
| 'mcp'
| 'notifications'
| 'providers'
| 'sessions'
| `config:${string}`
export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
export interface SettingsPageProps {

View File

@ -88,6 +88,8 @@ declare global {
) => () => void
signalDeepLinkReady?: () => Promise<{ ok: boolean }>
onWindowStateChanged?: (callback: (payload: HermesWindowState) => void) => () => void
onFocusSession?: (callback: (sessionId: string) => void) => () => void
onNotificationAction?: (callback: (payload: { actionId: string; sessionId?: string }) => void) => () => void
onPreviewFileChanged: (callback: (payload: HermesPreviewFileChanged) => void) => () => void
onBackendExit: (callback: (payload: BackendExit) => void) => () => void
onPowerResume?: (callback: () => void) => () => void
@ -413,6 +415,9 @@ export interface HermesNotification {
title?: string
body?: string
silent?: boolean
kind?: string
sessionId?: string
actions?: { id: string; text: string }[]
}
export interface HermesPreviewTarget {

View File

@ -131,6 +131,18 @@ export const en: Translations = {
transcriptionUnavailable: 'Voice transcription is not available yet.',
tryRecordingAgain: 'Try recording again.',
unavailable: 'Voice unavailable'
},
native: {
approvalTitle: 'Approval needed',
approveAction: 'Approve',
rejectAction: 'Reject',
inputTitle: 'Input needed',
inputBody: 'Hermes is waiting for your response.',
turnDoneTitle: 'Hermes finished',
turnDoneBody: 'The response is ready.',
turnErrorTitle: 'Turn failed',
backgroundDoneTitle: 'Background task finished',
backgroundFailedTitle: 'Background task failed'
}
},
@ -263,7 +275,43 @@ export const en: Translations = {
keysSettings: 'Settings',
mcp: 'MCP',
archivedChats: 'Archived Chats',
about: 'About'
about: 'About',
notifications: 'Notifications'
},
notifications: {
title: 'Notifications',
intro:
'Native desktop notifications, separate from in-app toasts. These are device-local — each computer keeps its own settings.',
enableAll: 'Enable notifications',
enableAllDesc: 'Master switch. Turn this off to silence every notification below.',
focusedHint: 'Completion alerts only fire while Hermes is in the background.',
kinds: {
approval: {
label: 'Approval needed',
description: 'A command is waiting for you to approve or reject it.'
},
input: {
label: 'Input needed',
description: 'Hermes asked a question or needs a password or secret.'
},
turnDone: {
label: 'Response ready',
description: 'A turn finished while Hermes was in the background.'
},
turnError: {
label: 'Turn failed',
description: 'A turn ended with an error.'
},
backgroundDone: {
label: 'Background task finished',
description: 'A backgrounded terminal command completed.'
}
},
test: 'Send test notification',
testTitle: 'Hermes',
testBody: 'Notifications are working.',
testSent: 'Test sent. If nothing appears, check your OS notification permissions and Focus/Do Not Disturb.',
testUnsupported: 'This system does not support native notifications.'
},
sections: {
model: 'Model',

View File

@ -132,6 +132,18 @@ export const ja = defineLocale({
transcriptionUnavailable: '音声文字起こしはまだ利用できません。',
tryRecordingAgain: 'もう一度録音してください。',
unavailable: '音声は利用できません'
},
native: {
approvalTitle: '承認が必要です',
approveAction: '承認',
rejectAction: '拒否',
inputTitle: '入力が必要です',
inputBody: 'Hermes が応答を待っています。',
turnDoneTitle: 'Hermes が完了しました',
turnDoneBody: '応答の準備ができました。',
turnErrorTitle: 'ターンが失敗しました',
backgroundDoneTitle: 'バックグラウンドタスクが完了しました',
backgroundFailedTitle: 'バックグラウンドタスクが失敗しました'
}
},
@ -177,7 +189,44 @@ export const ja = defineLocale({
keysSettings: '設定',
mcp: 'MCP',
archivedChats: 'アーカイブ済みチャット',
about: '情報'
about: '情報',
notifications: '通知'
},
notifications: {
title: '通知',
intro:
'アプリ内トーストとは別の、ネイティブのデスクトップ通知です。設定は端末ごとに保存されます。',
enableAll: '通知を有効にする',
enableAllDesc: 'マスタースイッチ。オフにすると以下のすべての通知を無効にします。',
focusedHint: '完了通知は Hermes がバックグラウンドにあるときのみ表示されます。',
kinds: {
approval: {
label: '承認が必要',
description: 'コマンドが承認または拒否を待っています。'
},
input: {
label: '入力が必要',
description: 'Hermes が質問したか、パスワードやシークレットを必要としています。'
},
turnDone: {
label: '応答完了',
description: 'Hermes がバックグラウンドのときにターンが完了しました。'
},
turnError: {
label: 'ターン失敗',
description: 'ターンがエラーで終了しました。'
},
backgroundDone: {
label: 'バックグラウンドタスク完了',
description: 'バックグラウンドのターミナルコマンドが完了しました。'
}
},
test: 'テスト通知を送信',
testTitle: 'Hermes',
testBody: '通知は正常に動作しています。',
testSent:
'テストを送信しました。表示されない場合は、OS の通知許可と集中モード/おやすみモードを確認してください。',
testUnsupported: 'このシステムはネイティブ通知に対応していません。'
},
sections: {
model: 'モデル',

View File

@ -143,6 +143,20 @@ export interface Translations {
tryRecordingAgain: string
unavailable: string
}
// Native OS notification copy (titles + generic fallback bodies). Dynamic
// bodies (the agent's reply, a command, an error) are passed through raw.
native: {
approvalTitle: string
approveAction: string
rejectAction: string
inputTitle: string
inputBody: string
turnDoneTitle: string
turnDoneBody: string
turnErrorTitle: string
backgroundDoneTitle: string
backgroundFailedTitle: string
}
}
titlebar: {
@ -202,6 +216,23 @@ export interface Translations {
mcp: string
archivedChats: string
about: string
notifications: string
}
notifications: {
title: string
intro: string
enableAll: string
enableAllDesc: string
focusedHint: string
kinds: Record<
'approval' | 'backgroundDone' | 'input' | 'turnDone' | 'turnError',
{ label: string; description: string }
>
test: string
testTitle: string
testBody: string
testSent: string
testUnsupported: string
}
sections: Record<string, string>
searchPlaceholder: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions', string>

View File

@ -127,6 +127,18 @@ export const zhHant = defineLocale({
transcriptionUnavailable: '語音轉寫暫不可用。',
tryRecordingAgain: '請再錄製一次。',
unavailable: '語音不可用'
},
native: {
approvalTitle: '需要核准',
approveAction: '核准',
rejectAction: '拒絕',
inputTitle: '需要輸入',
inputBody: 'Hermes 正在等待你的回應。',
turnDoneTitle: 'Hermes 已完成',
turnDoneBody: '回覆已就緒。',
turnErrorTitle: '本輪失敗',
backgroundDoneTitle: '背景工作已完成',
backgroundFailedTitle: '背景工作失敗'
}
},
@ -172,7 +184,42 @@ export const zhHant = defineLocale({
keysSettings: '設定',
mcp: 'MCP',
archivedChats: '已封存聊天',
about: '關於'
about: '關於',
notifications: '通知'
},
notifications: {
title: '通知',
intro: '原生桌面通知,與應用程式內提示不同。設定會依裝置保存,每台電腦各自獨立。',
enableAll: '啟用通知',
enableAllDesc: '總開關。關閉後會靜音下方所有通知。',
focusedHint: '完成提醒僅在 Hermes 位於背景時觸發。',
kinds: {
approval: {
label: '需要核准',
description: '有指令正在等待你核准或拒絕。'
},
input: {
label: '需要輸入',
description: 'Hermes 提出了問題,或需要密碼或密鑰。'
},
turnDone: {
label: '回覆就緒',
description: 'Hermes 在背景時完成了一輪對話。'
},
turnError: {
label: '本輪失敗',
description: '本輪以錯誤結束。'
},
backgroundDone: {
label: '背景工作完成',
description: '背景終端機指令已完成。'
}
},
test: '傳送測試通知',
testTitle: 'Hermes',
testBody: '通知運作正常。',
testSent: '測試已傳送。若沒有出現,請檢查系統通知權限與專注模式/勿擾模式。',
testUnsupported: '此系統不支援原生通知。'
},
sections: {
model: '模型',

View File

@ -127,6 +127,18 @@ export const zh: Translations = {
transcriptionUnavailable: '语音转写暂不可用。',
tryRecordingAgain: '请再录一次。',
unavailable: '语音不可用'
},
native: {
approvalTitle: '需要批准',
approveAction: '批准',
rejectAction: '拒绝',
inputTitle: '需要输入',
inputBody: 'Hermes 正在等待你的回应。',
turnDoneTitle: 'Hermes 已完成',
turnDoneBody: '回复已就绪。',
turnErrorTitle: '本轮失败',
backgroundDoneTitle: '后台任务已完成',
backgroundFailedTitle: '后台任务失败'
}
},
@ -259,7 +271,42 @@ export const zh: Translations = {
keysSettings: '设置',
mcp: 'MCP',
archivedChats: '已归档对话',
about: '关于'
about: '关于',
notifications: '通知'
},
notifications: {
title: '通知',
intro: '原生桌面通知,区别于应用内提示。设置按设备保存,每台电脑各自独立。',
enableAll: '启用通知',
enableAllDesc: '总开关。关闭后将静音下方所有通知。',
focusedHint: '完成提醒仅在 Hermes 处于后台时触发。',
kinds: {
approval: {
label: '需要批准',
description: '有命令正在等待你批准或拒绝。'
},
input: {
label: '需要输入',
description: 'Hermes 提出了问题,或需要密码或密钥。'
},
turnDone: {
label: '回复就绪',
description: 'Hermes 在后台时完成了一轮对话。'
},
turnError: {
label: '本轮失败',
description: '本轮以错误结束。'
},
backgroundDone: {
label: '后台任务完成',
description: '后台终端命令已完成。'
}
},
test: '发送测试通知',
testTitle: 'Hermes',
testBody: '通知工作正常。',
testSent: '测试已发送。如果没有出现,请检查系统通知权限和专注模式/勿扰模式。',
testUnsupported: '此系统不支持原生通知。'
},
sections: {
model: '模型',

View File

@ -9,6 +9,7 @@ import {
IconAt as AtSign,
IconWaveSine as AudioLines,
IconChartBar as BarChart3,
IconBell as Bell,
IconBrain as Brain,
IconBug as Bug,
IconCheck as Check,
@ -110,6 +111,7 @@ export {
AtSign,
AudioLines,
BarChart3,
Bell,
Brain,
Bug,
Check,

View File

@ -1,8 +1,10 @@
import { atom, computed } from 'nanostores'
import { translateNow } from '@/i18n'
import type { TodoItem, TodoStatus } from '@/lib/todos'
import { $gateway } from './gateway'
import { dispatchNativeNotification } from './native-notifications'
import { $subagentsBySession, type SubagentProgress } from './subagents'
import { $todosBySession } from './todos'
@ -161,6 +163,24 @@ export function reconcileBackgroundProcesses(sid: string, procs: GatewayProcessE
const prev = $backgroundStatusBySession.get()[sid] ?? []
// running → exited since the last snapshot = a background process just finished.
const prevState = new Map(prev.map(item => [item.id, item.state]))
for (const [id, item] of fresh) {
if (item.state !== 'running' && prevState.get(id) === 'running') {
dispatchNativeNotification({
body: item.title,
kind: 'backgroundDone',
sessionId: sid,
title: translateNow(
item.state === 'failed'
? 'notifications.native.backgroundFailedTitle'
: 'notifications.native.backgroundDoneTitle'
)
})
}
}
const kept = prev.flatMap(old => {
const next = fresh.get(old.id)
fresh.delete(old.id)

View File

@ -0,0 +1,192 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $gateway } from './gateway'
import {
dispatchNativeNotification,
NATIVE_NOTIFICATION_KINDS,
respondToApprovalAction,
sendTestNativeNotification,
setNativeNotifyEnabled,
setNativeNotifyKind
} from './native-notifications'
import { $approvalRequest, setApprovalRequest } from './prompts'
import { $activeSessionId, setActiveSessionId } from './session'
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
const initialHermesDesktop = desktopWindow.hermesDesktop
const notify = vi.fn().mockResolvedValue(true)
function setWindowState({ focused = true, hidden = false }: { focused?: boolean; hidden?: boolean }) {
Object.defineProperty(document, 'hidden', { configurable: true, value: hidden })
Object.defineProperty(document, 'hasFocus', { configurable: true, value: () => focused })
}
let counter = 0
// Unique session id per call dodges the per-(kind,session) throttle so each
// assertion starts clean.
function freshSession(): string {
counter += 1
return `session-${counter}`
}
beforeEach(() => {
notify.mockClear()
desktopWindow.hermesDesktop = { notify } as unknown as Window['hermesDesktop']
setNativeNotifyEnabled(true)
for (const kind of NATIVE_NOTIFICATION_KINDS) {
setNativeNotifyKind(kind, true)
}
setActiveSessionId(null)
setWindowState({ focused: false, hidden: true })
})
afterEach(() => {
if (initialHermesDesktop) {
desktopWindow.hermesDesktop = initialHermesDesktop
} else {
delete desktopWindow.hermesDesktop
}
})
describe('dispatchNativeNotification focus gating', () => {
it('fires a completion notification for the active session when the window is hidden', () => {
const sessionId = freshSession()
setActiveSessionId(sessionId)
dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done' })
expect(notify).toHaveBeenCalledTimes(1)
})
it('fires a completion notification when the window is visible but unfocused (alt-tab)', () => {
const sessionId = freshSession()
setActiveSessionId(sessionId)
setWindowState({ focused: false, hidden: false })
dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done' })
expect(notify).toHaveBeenCalledTimes(1)
})
it('suppresses a completion notification when the window is focused', () => {
const sessionId = freshSession()
setActiveSessionId(sessionId)
setWindowState({ focused: true, hidden: false })
dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done' })
expect(notify).not.toHaveBeenCalled()
})
it('suppresses a completion notification for a non-active background session (no gateway spam)', () => {
setActiveSessionId('on-screen')
dispatchNativeNotification({ kind: 'turnDone', sessionId: 'busy-bot-session', title: 'done' })
expect(notify).not.toHaveBeenCalled()
})
it('fires an attention notification for an off-screen session even when focused', () => {
setWindowState({ focused: true, hidden: false })
setActiveSessionId('on-screen')
dispatchNativeNotification({ kind: 'approval', sessionId: 'background', title: 'approve' })
expect(notify).toHaveBeenCalledTimes(1)
})
it('suppresses an attention notification for the active session when focused', () => {
setWindowState({ focused: true, hidden: false })
setActiveSessionId('on-screen')
dispatchNativeNotification({ kind: 'approval', sessionId: 'on-screen', title: 'approve' })
expect(notify).not.toHaveBeenCalled()
})
})
describe('dispatchNativeNotification preferences', () => {
it('suppresses everything when the master switch is off', () => {
setNativeNotifyEnabled(false)
dispatchNativeNotification({ kind: 'approval', sessionId: freshSession(), title: 'approve' })
dispatchNativeNotification({ kind: 'turnDone', sessionId: freshSession(), title: 'done' })
expect(notify).not.toHaveBeenCalled()
})
it('suppresses only the disabled kind', () => {
const sessionId = freshSession()
setActiveSessionId(sessionId)
setNativeNotifyKind('turnDone', false)
dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done' })
expect(notify).not.toHaveBeenCalled()
dispatchNativeNotification({ kind: 'turnError', sessionId, title: 'boom' })
expect(notify).toHaveBeenCalledTimes(1)
})
it('forwards kind and sessionId to the bridge', () => {
setActiveSessionId('abc')
dispatchNativeNotification({ body: 'hi', kind: 'turnError', sessionId: 'abc', title: 'boom' })
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ body: 'hi', kind: 'turnError', sessionId: 'abc', title: 'boom' })
)
})
})
describe('dispatchNativeNotification throttle', () => {
it('collapses duplicate kind+session within the throttle window', () => {
const sessionId = freshSession()
setActiveSessionId(sessionId)
dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done' })
dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done again' })
expect(notify).toHaveBeenCalledTimes(1)
})
})
describe('sendTestNativeNotification', () => {
it('fires regardless of focus or active session', () => {
setWindowState({ focused: true, hidden: false })
setActiveSessionId('on-screen')
sendTestNativeNotification('Hermes', 'works')
expect(notify).toHaveBeenCalledTimes(1)
})
})
describe('$activeSessionId wiring', () => {
it('reflects the setter used for gating', () => {
setActiveSessionId('xyz')
expect($activeSessionId.get()).toBe('xyz')
})
})
describe('respondToApprovalAction', () => {
const request = vi.fn().mockResolvedValue({ resolved: true })
beforeEach(() => {
request.mockClear()
$gateway.set({ request } as unknown as ReturnType<typeof $gateway.get>)
})
afterEach(() => {
$gateway.set(null)
})
it('approves via approval.respond {choice: "once"} and clears the prompt', async () => {
setActiveSessionId('bg')
setApprovalRequest({ command: 'rm -rf /', description: 'dangerous', sessionId: 'bg' })
await respondToApprovalAction('bg', 'approve')
expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'once', session_id: 'bg' })
expect($approvalRequest.get()).toBeNull()
})
it('rejects via approval.respond {choice: "deny"}', async () => {
await respondToApprovalAction('bg', 'reject')
expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'deny', session_id: 'bg' })
})
it('ignores unknown action ids', async () => {
await respondToApprovalAction('bg', 'snooze')
expect(request).not.toHaveBeenCalled()
})
it('no-ops without a gateway', async () => {
$gateway.set(null)
await respondToApprovalAction('bg', 'approve')
expect(request).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,203 @@
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
}
}