mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-15 09:21:36 +00:00
Merge pull request #45866 from NousResearch/bb/desktop-notifications
feat(desktop): native OS notifications with per-type toggles
This commit is contained in:
commit
cdf30a7ac6
18 changed files with 946 additions and 88 deletions
|
|
@ -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
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
)}
|
||||
|
|
|
|||
150
apps/desktop/src/app/settings/notifications-settings.tsx
Normal file
150
apps/desktop/src/app/settings/notifications-settings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
5
apps/desktop/src/global.d.ts
vendored
5
apps/desktop/src/global.d.ts
vendored
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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)を貼り付けると、その配色テーマをデスクトップ用パレットに変換します。',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
192
apps/desktop/src/store/native-notifications.test.ts
Normal file
192
apps/desktop/src/store/native-notifications.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
203
apps/desktop/src/store/native-notifications.ts
Normal file
203
apps/desktop/src/store/native-notifications.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue