From 630a4ef03c8e50181026cad50232979da7627592 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 14 Jun 2026 00:31:03 -0500 Subject: [PATCH 1/2] feat(desktop): native OS notifications with per-type toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a native OS notification system (Electron Notification, routed cross-OS) distinct from the in-app toast feed. Before this, one hardcoded cue existed (message.complete while document.hidden) with no settings or event coverage. - Engine (store/native-notifications.ts): localStorage-backed prefs (master switch + per-kind toggles) and a gated dispatcher over five kinds — approval, input, turnDone, turnError, backgroundDone — with a 1s per-(kind,session) self-evicting throttle. - Gating: "backgrounded" = document.hidden OR !document.hasFocus(), so an alt-tabbed window still counts as away. Completion kinds fire only when backgrounded and for the active session (no spam from a busy gateway); attention kinds (approval/input) also break through for off-screen sessions. - Wired into real event sites (use-message-stream.ts): message.complete, error, approval/clarify/sudo/secret.request; backgroundDone from composer-status at the running -> exited transition. - Click focuses the window and jumps to the originating session; approval notifications carry Approve/Reject buttons that resolve in place over approval.respond, mirroring the in-app Run/Reject bar. - Settings: new Notifications panel (master + per-kind switches, test button with real OS-result feedback). Full i18n (en/ja/zh/zh-hant). --- apps/desktop/electron/main.cjs | 25 ++- apps/desktop/electron/preload.cjs | 10 + apps/desktop/src/app/desktop-controller.tsx | 21 ++ .../app/session/hooks/use-message-stream.ts | 69 +++++- apps/desktop/src/app/settings/index.tsx | 12 +- .../app/settings/notifications-settings.tsx | 98 +++++++++ apps/desktop/src/app/settings/types.ts | 10 +- apps/desktop/src/global.d.ts | 5 + apps/desktop/src/i18n/en.ts | 50 ++++- apps/desktop/src/i18n/ja.ts | 51 ++++- apps/desktop/src/i18n/types.ts | 31 +++ apps/desktop/src/i18n/zh-hant.ts | 49 ++++- apps/desktop/src/i18n/zh.ts | 49 ++++- apps/desktop/src/lib/icons.ts | 2 + apps/desktop/src/store/composer-status.ts | 20 ++ .../src/store/native-notifications.test.ts | 192 +++++++++++++++++ .../desktop/src/store/native-notifications.ts | 203 ++++++++++++++++++ 17 files changed, 877 insertions(+), 20 deletions(-) create mode 100644 apps/desktop/src/app/settings/notifications-settings.tsx create mode 100644 apps/desktop/src/store/native-notifications.test.ts create mode 100644 apps/desktop/src/store/native-notifications.ts diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 1d04aca9555..64008d06e79 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -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 }) diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index 11292e4cd89..544037e7869 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -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) diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 1a97583c444..f585cfec579 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -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 diff --git a/apps/desktop/src/app/session/hooks/use-message-stream.ts b/apps/desktop/src/app/session/hooks/use-message-stream.ts index 447fc150d82..bd2f6b64ccd 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream.ts @@ -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) { diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx index 93ab0c8ecca..6c832799eb2 100644 --- a/apps/desktop/src/app/settings/index.tsx +++ b/apps/desktop/src/app/settings/index.tsx @@ -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 /> ) })} + setActiveView('notifications')} + />
) : activeView === 'mcp' ? ( + ) : activeView === 'notifications' ? ( + ) : ( )} diff --git a/apps/desktop/src/app/settings/notifications-settings.tsx b/apps/desktop/src/app/settings/notifications-settings.tsx new file mode 100644 index 00000000000..94bb0bc4d24 --- /dev/null +++ b/apps/desktop/src/app/settings/notifications-settings.tsx @@ -0,0 +1,98 @@ +import { useStore } from '@nanostores/react' +import type { ReactNode } from 'react' + +import { Button } from '@/components/ui/button' +import { Switch } from '@/components/ui/switch' +import { useI18n } from '@/i18n' +import { triggerHaptic } from '@/lib/haptics' +import { Bell } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { + $nativeNotifyPrefs, + NATIVE_NOTIFICATION_KINDS, + sendTestNativeNotification, + setNativeNotifyEnabled, + setNativeNotifyKind +} from '@/store/native-notifications' +import { notify } from '@/store/notifications' + +import { ListRow, SectionHeading, SettingsContent } from './primitives' + +const CAPTION = 'text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)' + +function Caption({ children, className }: { children: ReactNode; className?: string }) { + return

{children}

+} + +function ToggleRow(props: { + checked: boolean + description: string + disabled?: boolean + label: string + onChange: (on: boolean) => void +}) { + return ( + { + triggerHaptic('selection') + props.onChange(on) + }} + /> + } + description={props.description} + title={props.label} + /> + ) +} + +export function NotificationsSettings() { + const { t } = useI18n() + const prefs = useStore($nativeNotifyPrefs) + const copy = t.settings.notifications + + const runTest = async () => { + triggerHaptic('open') + const ok = await sendTestNativeNotification(copy.testTitle, copy.testBody) + notify({ kind: ok ? 'info' : 'error', message: ok ? copy.testSent : copy.testUnsupported }) + } + + return ( + + + {copy.intro} + + + +
+ + {NATIVE_NOTIFICATION_KINDS.map(kind => ( + setNativeNotifyKind(kind, on)} + /> + ))} + +
+ + {copy.focusedHint} +
+ + ) +} diff --git a/apps/desktop/src/app/settings/types.ts b/apps/desktop/src/app/settings/types.ts index 33c88e761c1..fba38a23c19 100644 --- a/apps/desktop/src/app/settings/types.ts +++ b/apps/desktop/src/app/settings/types.ts @@ -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> export interface SettingsPageProps { diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index a4eac6011e7..8c20dcffaac 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -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 { diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 38aab748b08..e0256f2cc88 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -131,6 +131,18 @@ export const en: Translations = { transcriptionUnavailable: 'Voice transcription is not available yet.', tryRecordingAgain: 'Try recording again.', unavailable: 'Voice unavailable' + }, + native: { + approvalTitle: 'Approval needed', + approveAction: 'Approve', + rejectAction: 'Reject', + inputTitle: 'Input needed', + inputBody: 'Hermes is waiting for your response.', + turnDoneTitle: 'Hermes finished', + turnDoneBody: 'The response is ready.', + turnErrorTitle: 'Turn failed', + backgroundDoneTitle: 'Background task finished', + backgroundFailedTitle: 'Background task failed' } }, @@ -263,7 +275,43 @@ export const en: Translations = { keysSettings: 'Settings', mcp: 'MCP', archivedChats: 'Archived Chats', - about: 'About' + about: 'About', + notifications: 'Notifications' + }, + notifications: { + title: 'Notifications', + intro: + 'Native desktop notifications, separate from in-app toasts. These are device-local — each computer keeps its own settings.', + enableAll: 'Enable notifications', + enableAllDesc: 'Master switch. Turn this off to silence every notification below.', + focusedHint: 'Completion alerts only fire while Hermes is in the background.', + kinds: { + approval: { + label: 'Approval needed', + description: 'A command is waiting for you to approve or reject it.' + }, + input: { + label: 'Input needed', + description: 'Hermes asked a question or needs a password or secret.' + }, + turnDone: { + label: 'Response ready', + description: 'A turn finished while Hermes was in the background.' + }, + turnError: { + label: 'Turn failed', + description: 'A turn ended with an error.' + }, + backgroundDone: { + label: 'Background task finished', + description: 'A backgrounded terminal command completed.' + } + }, + test: 'Send test notification', + testTitle: 'Hermes', + testBody: 'Notifications are working.', + testSent: 'Test sent. If nothing appears, check your OS notification permissions and Focus/Do Not Disturb.', + testUnsupported: 'This system does not support native notifications.' }, sections: { model: 'Model', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 6dcbd073cdb..cbb6d848148 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -132,6 +132,18 @@ export const ja = defineLocale({ transcriptionUnavailable: '音声文字起こしはまだ利用できません。', tryRecordingAgain: 'もう一度録音してください。', unavailable: '音声は利用できません' + }, + native: { + approvalTitle: '承認が必要です', + approveAction: '承認', + rejectAction: '拒否', + inputTitle: '入力が必要です', + inputBody: 'Hermes が応答を待っています。', + turnDoneTitle: 'Hermes が完了しました', + turnDoneBody: '応答の準備ができました。', + turnErrorTitle: 'ターンが失敗しました', + backgroundDoneTitle: 'バックグラウンドタスクが完了しました', + backgroundFailedTitle: 'バックグラウンドタスクが失敗しました' } }, @@ -177,7 +189,44 @@ export const ja = defineLocale({ keysSettings: '設定', mcp: 'MCP', archivedChats: 'アーカイブ済みチャット', - about: '情報' + about: '情報', + notifications: '通知' + }, + notifications: { + title: '通知', + intro: + 'アプリ内トーストとは別の、ネイティブのデスクトップ通知です。設定は端末ごとに保存されます。', + enableAll: '通知を有効にする', + enableAllDesc: 'マスタースイッチ。オフにすると以下のすべての通知を無効にします。', + focusedHint: '完了通知は Hermes がバックグラウンドにあるときのみ表示されます。', + kinds: { + approval: { + label: '承認が必要', + description: 'コマンドが承認または拒否を待っています。' + }, + input: { + label: '入力が必要', + description: 'Hermes が質問したか、パスワードやシークレットを必要としています。' + }, + turnDone: { + label: '応答完了', + description: 'Hermes がバックグラウンドのときにターンが完了しました。' + }, + turnError: { + label: 'ターン失敗', + description: 'ターンがエラーで終了しました。' + }, + backgroundDone: { + label: 'バックグラウンドタスク完了', + description: 'バックグラウンドのターミナルコマンドが完了しました。' + } + }, + test: 'テスト通知を送信', + testTitle: 'Hermes', + testBody: '通知は正常に動作しています。', + testSent: + 'テストを送信しました。表示されない場合は、OS の通知許可と集中モード/おやすみモードを確認してください。', + testUnsupported: 'このシステムはネイティブ通知に対応していません。' }, sections: { model: 'モデル', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 4d676172270..4ce52f1a124 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -143,6 +143,20 @@ export interface Translations { tryRecordingAgain: string unavailable: string } + // Native OS notification copy (titles + generic fallback bodies). Dynamic + // bodies (the agent's reply, a command, an error) are passed through raw. + native: { + approvalTitle: string + approveAction: string + rejectAction: string + inputTitle: string + inputBody: string + turnDoneTitle: string + turnDoneBody: string + turnErrorTitle: string + backgroundDoneTitle: string + backgroundFailedTitle: string + } } titlebar: { @@ -202,6 +216,23 @@ export interface Translations { mcp: string archivedChats: string about: string + notifications: string + } + notifications: { + title: string + intro: string + enableAll: string + enableAllDesc: string + focusedHint: string + kinds: Record< + 'approval' | 'backgroundDone' | 'input' | 'turnDone' | 'turnError', + { label: string; description: string } + > + test: string + testTitle: string + testBody: string + testSent: string + testUnsupported: string } sections: Record searchPlaceholder: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions', string> diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 73a5d086c77..06ba9da0fb9 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -127,6 +127,18 @@ export const zhHant = defineLocale({ transcriptionUnavailable: '語音轉寫暫不可用。', tryRecordingAgain: '請再錄製一次。', unavailable: '語音不可用' + }, + native: { + approvalTitle: '需要核准', + approveAction: '核准', + rejectAction: '拒絕', + inputTitle: '需要輸入', + inputBody: 'Hermes 正在等待你的回應。', + turnDoneTitle: 'Hermes 已完成', + turnDoneBody: '回覆已就緒。', + turnErrorTitle: '本輪失敗', + backgroundDoneTitle: '背景工作已完成', + backgroundFailedTitle: '背景工作失敗' } }, @@ -172,7 +184,42 @@ export const zhHant = defineLocale({ keysSettings: '設定', mcp: 'MCP', archivedChats: '已封存聊天', - about: '關於' + about: '關於', + notifications: '通知' + }, + notifications: { + title: '通知', + intro: '原生桌面通知,與應用程式內提示不同。設定會依裝置保存,每台電腦各自獨立。', + enableAll: '啟用通知', + enableAllDesc: '總開關。關閉後會靜音下方所有通知。', + focusedHint: '完成提醒僅在 Hermes 位於背景時觸發。', + kinds: { + approval: { + label: '需要核准', + description: '有指令正在等待你核准或拒絕。' + }, + input: { + label: '需要輸入', + description: 'Hermes 提出了問題,或需要密碼或密鑰。' + }, + turnDone: { + label: '回覆就緒', + description: 'Hermes 在背景時完成了一輪對話。' + }, + turnError: { + label: '本輪失敗', + description: '本輪以錯誤結束。' + }, + backgroundDone: { + label: '背景工作完成', + description: '背景終端機指令已完成。' + } + }, + test: '傳送測試通知', + testTitle: 'Hermes', + testBody: '通知運作正常。', + testSent: '測試已傳送。若沒有出現,請檢查系統通知權限與專注模式/勿擾模式。', + testUnsupported: '此系統不支援原生通知。' }, sections: { model: '模型', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index bd8b3eee40b..9e930dcecc3 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -127,6 +127,18 @@ export const zh: Translations = { transcriptionUnavailable: '语音转写暂不可用。', tryRecordingAgain: '请再录一次。', unavailable: '语音不可用' + }, + native: { + approvalTitle: '需要批准', + approveAction: '批准', + rejectAction: '拒绝', + inputTitle: '需要输入', + inputBody: 'Hermes 正在等待你的回应。', + turnDoneTitle: 'Hermes 已完成', + turnDoneBody: '回复已就绪。', + turnErrorTitle: '本轮失败', + backgroundDoneTitle: '后台任务已完成', + backgroundFailedTitle: '后台任务失败' } }, @@ -259,7 +271,42 @@ export const zh: Translations = { keysSettings: '设置', mcp: 'MCP', archivedChats: '已归档对话', - about: '关于' + about: '关于', + notifications: '通知' + }, + notifications: { + title: '通知', + intro: '原生桌面通知,区别于应用内提示。设置按设备保存,每台电脑各自独立。', + enableAll: '启用通知', + enableAllDesc: '总开关。关闭后将静音下方所有通知。', + focusedHint: '完成提醒仅在 Hermes 处于后台时触发。', + kinds: { + approval: { + label: '需要批准', + description: '有命令正在等待你批准或拒绝。' + }, + input: { + label: '需要输入', + description: 'Hermes 提出了问题,或需要密码或密钥。' + }, + turnDone: { + label: '回复就绪', + description: 'Hermes 在后台时完成了一轮对话。' + }, + turnError: { + label: '本轮失败', + description: '本轮以错误结束。' + }, + backgroundDone: { + label: '后台任务完成', + description: '后台终端命令已完成。' + } + }, + test: '发送测试通知', + testTitle: 'Hermes', + testBody: '通知工作正常。', + testSent: '测试已发送。如果没有出现,请检查系统通知权限和专注模式/勿扰模式。', + testUnsupported: '此系统不支持原生通知。' }, sections: { model: '模型', diff --git a/apps/desktop/src/lib/icons.ts b/apps/desktop/src/lib/icons.ts index 729dde84aa2..a2cd4ec7b0b 100644 --- a/apps/desktop/src/lib/icons.ts +++ b/apps/desktop/src/lib/icons.ts @@ -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, diff --git a/apps/desktop/src/store/composer-status.ts b/apps/desktop/src/store/composer-status.ts index 9991ca57adc..d084eaa44ec 100644 --- a/apps/desktop/src/store/composer-status.ts +++ b/apps/desktop/src/store/composer-status.ts @@ -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) diff --git a/apps/desktop/src/store/native-notifications.test.ts b/apps/desktop/src/store/native-notifications.test.ts new file mode 100644 index 00000000000..48650df1217 --- /dev/null +++ b/apps/desktop/src/store/native-notifications.test.ts @@ -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) + }) + + 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() + }) +}) diff --git a/apps/desktop/src/store/native-notifications.ts b/apps/desktop/src/store/native-notifications.ts new file mode 100644 index 00000000000..1c058c803e1 --- /dev/null +++ b/apps/desktop/src/store/native-notifications.ts @@ -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(['approval', 'input']) + +export interface NativeNotificationPrefs { + enabled: boolean + kinds: Record +} + +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 + 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(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() + +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 { + 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 { + const bridge = window.hermesDesktop + + if (!bridge?.notify) { + return false + } + + try { + return await bridge.notify({ body, kind: 'turnDone', title }) + } catch { + return false + } +} From b0288ae9b6ea75cb6b9aa5b9c021f95f7c37216f Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 14 Jun 2026 00:31:09 -0500 Subject: [PATCH 2/2] feat(desktop): move completion-sound picker into Notifications settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The turn-end sound is a notification concern, not an appearance one — relocate the variant picker + preview from the Appearance tab to the Notifications tab (its i18n keys move from settings.appearance to settings.notifications with it). --- .../src/app/settings/appearance-settings.tsx | 55 +------------------ .../app/settings/notifications-settings.tsx | 54 +++++++++++++++++- apps/desktop/src/i18n/en.ts | 8 +-- apps/desktop/src/i18n/ja.ts | 8 +-- apps/desktop/src/i18n/types.ts | 6 +- apps/desktop/src/i18n/zh-hant.ts | 8 +-- apps/desktop/src/i18n/zh.ts | 8 +-- 7 files changed, 74 insertions(+), 73 deletions(-) diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx index 4568c550bb4..80b74090f33 100644 --- a/apps/desktop/src/app/settings/appearance-settings.tsx +++ b/apps/desktop/src/app/settings/appearance-settings.tsx @@ -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} /> - - - - - -
- } - description={a.completionSoundDesc} - title={a.completionSoundTitle} - />
diff --git a/apps/desktop/src/app/settings/notifications-settings.tsx b/apps/desktop/src/app/settings/notifications-settings.tsx index 94bb0bc4d24..8f23eecd60f 100644 --- a/apps/desktop/src/app/settings/notifications-settings.tsx +++ b/apps/desktop/src/app/settings/notifications-settings.tsx @@ -2,11 +2,14 @@ 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 } from '@/lib/icons' +import { Bell, Play } from '@/lib/icons' import { cn } from '@/lib/utils' +import { $completionSoundVariantId, setCompletionSoundVariantId } from '@/store/completion-sound' import { $nativeNotifyPrefs, NATIVE_NOTIFICATION_KINDS, @@ -16,6 +19,7 @@ import { } 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)' @@ -53,6 +57,7 @@ function ToggleRow(props: { export function NotificationsSettings() { const { t } = useI18n() const prefs = useStore($nativeNotifyPrefs) + const completionSoundVariantId = useStore($completionSoundVariantId) const copy = t.settings.notifications const runTest = async () => { @@ -86,6 +91,53 @@ export function NotificationsSettings() { /> ))} +
+ + + + + +
+ } + description={copy.completionSoundDesc} + title={copy.completionSoundTitle} + /> +