Merge pull request #45866 from NousResearch/bb/desktop-notifications

feat(desktop): native OS notifications with per-type toggles
This commit is contained in:
brooklyn! 2026-06-14 00:36:38 -05:00 committed by GitHub
commit cdf30a7ac6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 946 additions and 88 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

@ -2,15 +2,11 @@ import { useStore } from '@nanostores/react'
import { useState } from 'react'
import { LanguageSwitcher } from '@/components/language-switcher'
import { Button } from '@/components/ui/button'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useI18n } from '@/i18n'
import { COMPLETION_SOUND_VARIANTS, previewCompletionSound } from '@/lib/completion-sound'
import { triggerHaptic } from '@/lib/haptics'
import { Check, Download, Loader2, Palette, Play, Trash2 } from '@/lib/icons'
import { Check, Download, Loader2, Palette, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $completionSoundVariantId, setCompletionSoundVariantId } from '@/store/completion-sound'
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
import { $translucency, setTranslucency } from '@/store/translucency'
@ -18,7 +14,7 @@ import { useTheme } from '@/themes/context'
import { installVscodeThemeFromMarketplace } from '@/themes/install'
import { isUserTheme, removeUserTheme, resolveTheme } from '@/themes/user-themes'
import { CONTROL_TEXT, MODE_OPTIONS } from './constants'
import { MODE_OPTIONS } from './constants'
import { ListRow, SectionHeading, SettingsContent } from './primitives'
function ThemePreview({ name }: { name: string }) {
@ -139,7 +135,6 @@ function VscodeThemeInstaller() {
export function AppearanceSettings() {
const { t, isSavingLocale } = useI18n()
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
const completionSoundVariantId = useStore($completionSoundVariantId)
const toolViewMode = useStore($toolViewMode)
const translucency = useStore($translucency)
const profiles = useStore($profiles)
@ -304,52 +299,6 @@ export function AppearanceSettings() {
description={a.toolViewDesc}
title={a.toolViewTitle}
/>
<ListRow
action={
<div className="flex flex-wrap items-center justify-end gap-2">
<Select
onValueChange={value => {
const variantId = Number.parseInt(value, 10)
setCompletionSoundVariantId(variantId)
previewCompletionSound(variantId)
triggerHaptic('selection')
}}
value={String(completionSoundVariantId)}
>
<SelectTrigger className={cn('min-w-56', CONTROL_TEXT)}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{COMPLETION_SOUND_VARIANTS.map(variant => (
<SelectItem key={variant.id} value={String(variant.id)}>
{variant.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
className="gap-1.5"
onClick={() => {
previewCompletionSound()
triggerHaptic('crisp')
}}
size="sm"
type="button"
variant="outline"
>
<Play className="size-3.5" />
{a.completionSoundPreview}
</Button>
</div>
}
description={a.completionSoundDesc}
title={a.completionSoundTitle}
/>
</div>
</div>
</SettingsContent>

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,150 @@
import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { useI18n } from '@/i18n'
import { COMPLETION_SOUND_VARIANTS, previewCompletionSound } from '@/lib/completion-sound'
import { triggerHaptic } from '@/lib/haptics'
import { Bell, Play } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $completionSoundVariantId, setCompletionSoundVariantId } from '@/store/completion-sound'
import {
$nativeNotifyPrefs,
NATIVE_NOTIFICATION_KINDS,
sendTestNativeNotification,
setNativeNotifyEnabled,
setNativeNotifyKind
} from '@/store/native-notifications'
import { notify } from '@/store/notifications'
import { CONTROL_TEXT } from './constants'
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 completionSoundVariantId = useStore($completionSoundVariantId)
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="my-1 h-px bg-border/30" />
<ListRow
action={
<div className="flex flex-wrap items-center justify-end gap-2">
<Select
onValueChange={value => {
const variantId = Number.parseInt(value, 10)
setCompletionSoundVariantId(variantId)
previewCompletionSound(variantId)
triggerHaptic('selection')
}}
value={String(completionSoundVariantId)}
>
<SelectTrigger className={cn('min-w-56', CONTROL_TEXT)}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{COMPLETION_SOUND_VARIANTS.map(variant => (
<SelectItem key={variant.id} value={String(variant.id)}>
{variant.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
className="gap-1.5"
onClick={() => {
previewCompletionSound()
triggerHaptic('crisp')
}}
size="sm"
type="button"
variant="outline"
>
<Play className="size-3.5" />
{copy.completionSoundPreview}
</Button>
</div>
}
description={copy.completionSoundDesc}
title={copy.completionSoundTitle}
/>
<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,46 @@ 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.',
completionSoundTitle: 'Completion Sound',
completionSoundDesc: 'Plays when an agent turn finishes. Pick a preset and preview it here.',
completionSoundPreview: 'Preview'
},
sections: {
model: 'Model',
@ -305,9 +356,6 @@ export const en: Translations = {
themeTitle: 'Theme',
themeDesc: 'Desktop palettes only. The selected mode is applied on top.',
themeProfileNote: profile => `Saved for the ${profile} profile — each profile keeps its own theme.`,
completionSoundTitle: 'Completion Sound',
completionSoundDesc: 'Plays when an agent turn finishes. Pick a preset and preview it here.',
completionSoundPreview: 'Preview',
installTitle: 'Install from VS Code',
installDesc:
'Paste a Marketplace extension id (e.g. dracula-theme.theme-dracula) to convert its color theme into a desktop palette.',

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,47 @@ 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: 'このシステムはネイティブ通知に対応していません。',
completionSoundTitle: '完了サウンド',
completionSoundDesc: 'エージェントのターン終了時に再生されます。プリセットを選んでここで試聴できます。',
completionSoundPreview: '試聴'
},
sections: {
model: 'モデル',
@ -220,9 +272,6 @@ export const ja = defineLocale({
themeDesc: 'デスクトップ専用のパレットです。選択したモードの上に適用されます。',
themeProfileNote: profile =>
`${profile}」プロファイルに保存されます。プロファイルごとに個別のテーマを保持します。`,
completionSoundTitle: '完了サウンド',
completionSoundDesc: 'エージェントのターン終了時に再生されます。プリセットを選んでここで試聴できます。',
completionSoundPreview: '試聴',
installTitle: 'VS Code から導入',
installDesc:
'Marketplace の拡張機能 ID例: dracula-theme.theme-draculaを貼り付けると、その配色テーマをデスクトップ用パレットに変換します。',

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,26 @@ 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
completionSoundTitle: string
completionSoundDesc: string
completionSoundPreview: string
}
sections: Record<string, string>
searchPlaceholder: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions', string>
@ -222,9 +256,6 @@ export interface Translations {
themeTitle: string
themeDesc: string
themeProfileNote: (profile: string) => string
completionSoundTitle: string
completionSoundDesc: string
completionSoundPreview: string
installTitle: string
installDesc: string
installPlaceholder: 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,45 @@ 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: '此系統不支援原生通知。',
completionSoundTitle: '完成提示音',
completionSoundDesc: '代理回合結束時播放。可在此選擇預設並預覽。',
completionSoundPreview: '預覽'
},
sections: {
model: '模型',
@ -213,9 +263,6 @@ export const zhHant = defineLocale({
themeTitle: '主題',
themeDesc: '僅限桌面端的調色盤。所選模式會套用在其上。',
themeProfileNote: profile => `已為「${profile}」設定檔儲存——每個設定檔保留各自的主題。`,
completionSoundTitle: '完成提示音',
completionSoundDesc: '代理回合結束時播放。可在此選擇預設並預覽。',
completionSoundPreview: '預覽',
installTitle: '從 VS Code 安裝',
installDesc: '貼上 Marketplace 擴充功能 ID例如 dracula-theme.theme-dracula將其配色主題轉換為桌面調色盤。',
installPlaceholder: 'publisher.extension',

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,45 @@ 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: '此系统不支持原生通知。',
completionSoundTitle: '完成提示音',
completionSoundDesc: '智能体回合结束时播放。可在此选择预设并预览。',
completionSoundPreview: '预览'
},
sections: {
model: '模型',
@ -300,9 +350,6 @@ export const zh: Translations = {
themeTitle: '主题',
themeDesc: '仅桌面端调色板。所选模式叠加其上。',
themeProfileNote: profile => `已为「${profile}」配置文件保存——每个配置文件保留各自的主题。`,
completionSoundTitle: '完成提示音',
completionSoundDesc: '智能体回合结束时播放。可在此选择预设并预览。',
completionSoundPreview: '预览',
installTitle: '从 VS Code 安装',
installDesc: '粘贴 Marketplace 扩展 ID例如 dracula-theme.theme-dracula将其配色主题转换为桌面调色板。',
installPlaceholder: 'publisher.extension',

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