feat(notifications): native OS banners for agent/chat/socket events (Phase 1c of #395) (#780)

Co-authored-by: Jwalin Shah <jshah1331@gmail.com>
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
Jwalin Shah
2026-04-22 17:50:21 -07:00
committed by GitHub
co-authored by Jwalin Shah Steven Enamakel
parent 98b47b107d
commit 3dc0d0db64
9 changed files with 278 additions and 13 deletions
+28 -1
View File
@@ -448,6 +448,32 @@ fn activate_main_window(app: AppHandle<AppRuntime>) -> Result<(), String> {
show_main_window(&app)
}
/// Tauri command: fire a native OS notification from the frontend. Used by
/// the in-app notification center to banner events (agent completions,
/// connection drops, etc.) when the window is not focused.
#[tauri::command]
fn show_native_notification(
app: AppHandle<AppRuntime>,
title: String,
body: String,
tag: Option<String>,
) -> Result<(), String> {
use tauri_plugin_notification::NotificationExt;
log::debug!(
"[notify] show_native_notification title_chars={} body_chars={} tag={:?}",
title.len(),
body.len(),
tag
);
let mut builder = app.notification().builder().title(&title);
if !body.is_empty() {
builder = builder.body(&body);
}
builder
.show()
.map_err(|e| format!("notification show failed: {e}"))
}
fn show_main_window(app: &AppHandle<AppRuntime>) -> Result<(), String> {
let window = app
.get_webview_window("main")
@@ -895,7 +921,8 @@ pub fn run() {
webview_accounts::webview_set_focused_account,
notification_settings::notification_settings_get,
notification_settings::notification_settings_set,
activate_main_window
activate_main_window,
show_native_notification
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
+2
View File
@@ -13,6 +13,7 @@ import MeshGradient from './components/MeshGradient';
import OnboardingOverlay from './components/OnboardingOverlay';
import RouteLoadingScreen from './components/RouteLoadingScreen';
import GlobalUpsellBanner from './components/upsell/GlobalUpsellBanner';
import { startNativeNotificationsService } from './lib/nativeNotifications';
import { startWebviewNotificationsService } from './lib/webviewNotifications';
import ChatRuntimeProvider from './providers/ChatRuntimeProvider';
import CoreStateProvider from './providers/CoreStateProvider';
@@ -29,6 +30,7 @@ import { isAccountsFullscreen } from './utils/accountsFullscreen';
// Idempotent — the service uses a `started` singleton guard.
startWebviewAccountService();
startWebviewNotificationsService();
startNativeNotificationsService();
function App() {
return (
@@ -64,12 +64,10 @@ const NotificationCard = ({ notification: n, onMarkRead }: Props) => {
isUnread ? 'bg-primary-50/30' : 'bg-white'
}`}>
<div className="flex items-start gap-3">
{/* Unread dot */}
<div className="mt-1.5 flex-shrink-0">
{isUnread ? (
<span className="block w-2 h-2 rounded-full bg-primary-500" />
) : (
<span className="block w-2 h-2 rounded-full bg-transparent" />
{/* Unread dot — reserve space so text stays aligned whether read or unread */}
<div className="mt-1.5 flex-shrink-0 w-2">
{isUnread && (
<span className="block w-2 h-2 rounded-full bg-primary-500" aria-hidden="true" />
)}
</div>
@@ -75,8 +75,6 @@ const NotificationCenter = () => {
}
};
const providers = allProviders;
return (
<div className="flex flex-col h-full">
{/* Header */}
@@ -101,7 +99,7 @@ const NotificationCenter = () => {
</div>
{/* Provider filter pills */}
{providers.length > 1 && (
{allProviders.length > 1 && (
<div className="flex items-center gap-2 px-4 py-2 border-b border-stone-100 overflow-x-auto">
<button
onClick={() => setSelectedProvider(undefined)}
@@ -112,7 +110,7 @@ const NotificationCenter = () => {
}`}>
All
</button>
{providers.map(p => (
{allProviders.map(p => (
<button
key={p}
onClick={() => setSelectedProvider(p === selectedProvider ? undefined : p)}
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { store } from '../../../store';
import { setPreference } from '../../../store/notificationSlice';
import { __handleChatDoneForTests, __resetForTests } from '../service';
import { showNativeNotification } from '../tauriBridge';
vi.mock('../tauriBridge', () => ({ showNativeNotification: vi.fn() }));
vi.mock('../../../services/socketService', () => ({
socketService: { on: vi.fn(), off: vi.fn() },
}));
describe('nativeNotifications service', () => {
beforeEach(() => {
__resetForTests();
vi.clearAllMocks();
// Clean slate for each test — clear any notifications persisted by prior ones.
store.dispatch({ type: 'notifications/clearAll' });
store.dispatch(setPreference({ category: 'agents', enabled: true }));
});
it('dispatches chat_done into the agents category of the center', () => {
__handleChatDoneForTests({ thread_id: 't1', request_id: 'r1', full_response: 'Hello world' });
const items = store.getState().notifications.items;
expect(items).toHaveLength(1);
expect(items[0].category).toBe('agents');
expect(items[0].title).toBe('Agent reply ready');
expect(items[0].body).toBe('Hello world');
expect(items[0].deepLink).toBe('/chat');
});
it('truncates very long responses to 160 chars', () => {
__handleChatDoneForTests({ thread_id: 't1', request_id: 'r1', full_response: 'a'.repeat(500) });
const items = store.getState().notifications.items;
expect(items[0].body.length).toBe(160);
expect(items[0].body.endsWith('…')).toBe(true);
});
it('drops events whose category preference is disabled', () => {
store.dispatch(setPreference({ category: 'agents', enabled: false }));
__handleChatDoneForTests({ thread_id: 't', full_response: 'x' });
expect(store.getState().notifications.items).toHaveLength(0);
expect(showNativeNotification).not.toHaveBeenCalled();
});
it('skips the native banner when the window is focused', () => {
vi.spyOn(document, 'hasFocus').mockReturnValue(true);
__handleChatDoneForTests({ thread_id: 't', full_response: 'focused' });
expect(showNativeNotification).not.toHaveBeenCalled();
});
it('fires the native banner when the window is unfocused', () => {
vi.spyOn(document, 'hasFocus').mockReturnValue(false);
__handleChatDoneForTests({ thread_id: 't', full_response: 'unfocused' });
expect(showNativeNotification).toHaveBeenCalledTimes(1);
expect(showNativeNotification).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Agent reply ready' })
);
});
});
+2
View File
@@ -0,0 +1,2 @@
export { startNativeNotificationsService, stopNativeNotificationsService } from './service';
export { showNativeNotification } from './tauriBridge';
+145
View File
@@ -0,0 +1,145 @@
import debug from 'debug';
import { socketService } from '../../services/socketService';
import { store } from '../../store';
import {
type NotificationCategory,
type NotificationItem,
notificationReceived,
} from '../../store/notificationSlice';
import { showNativeNotification } from './tauriBridge';
const log = debug('native-notifications');
let started = false;
// Retain listener references so stopNativeNotificationsService can remove them.
let chatDoneListener: ((...args: unknown[]) => void) | null = null;
let chatErrorListener: ((...args: unknown[]) => void) | null = null;
let disconnectListener: ((...args: unknown[]) => void) | null = null;
interface ChatDonePayload {
thread_id?: string;
request_id?: string;
full_response?: string;
rounds_used?: number;
}
interface ChatErrorPayload {
thread_id?: string;
request_id?: string;
message?: string;
}
function windowIsFocused(): boolean {
if (typeof document === 'undefined') return true;
return document.hasFocus();
}
function dispatchAndMaybeBanner(
category: NotificationCategory,
item: Omit<NotificationItem, 'category' | 'timestamp' | 'read'>
): void {
const prefs = store.getState().notifications.preferences;
if (!prefs[category]) {
log('category %s disabled, skipping', category);
return;
}
const full: NotificationItem = { ...item, category, timestamp: Date.now(), read: false };
store.dispatch(notificationReceived(full));
// Only fire OS-level banner when the user isn't already looking at the
// window — otherwise the in-app center is enough and a native toast is
// redundant noise.
if (!windowIsFocused()) {
void showNativeNotification({ title: full.title, body: full.body, tag: full.id });
}
}
function truncate(input: string, max: number): string {
if (input.length <= max) return input;
return `${input.slice(0, max - 1)}`;
}
/**
* Subscribe to socket events that should surface as notifications (agent
* completions, chat errors, connection drops). Idempotent. Safe to call at
* app boot before the socket has connected — the socketService queues
* listeners until the socket is ready.
*/
export function startNativeNotificationsService(): void {
if (started) return;
started = true;
chatDoneListener = (...args: unknown[]) => {
const p = (args[0] ?? {}) as ChatDonePayload;
dispatchAndMaybeBanner('agents', {
id: `chat_done:${p.thread_id ?? 'unknown'}:${p.request_id ?? Date.now()}`,
title: 'Agent reply ready',
body: truncate(p.full_response?.trim() || 'Agent finished processing.', 160),
deepLink: '/chat',
});
};
chatErrorListener = (...args: unknown[]) => {
const p = (args[0] ?? {}) as ChatErrorPayload;
dispatchAndMaybeBanner('system', {
id: `chat_error:${p.thread_id ?? 'unknown'}:${p.request_id ?? Date.now()}`,
title: 'Agent error',
body: truncate(p.message || 'An error occurred during inference.', 160),
deepLink: '/chat',
});
};
disconnectListener = (...args: unknown[]) => {
const reason = typeof args[0] === 'string' ? args[0] : 'unknown';
dispatchAndMaybeBanner('system', {
id: `socket_disconnect:${Date.now()}`,
title: 'Connection lost',
body: `OpenHuman lost its connection to the core service (${truncate(reason, 80)}).`,
});
};
socketService.on('chat_done', chatDoneListener);
socketService.on('chat_error', chatErrorListener);
socketService.on('disconnect', disconnectListener);
log('started — subscribed to chat_done, chat_error, disconnect');
}
export function stopNativeNotificationsService(): void {
if (!started) return;
if (chatDoneListener) {
socketService.off('chat_done', chatDoneListener);
chatDoneListener = null;
}
if (chatErrorListener) {
socketService.off('chat_error', chatErrorListener);
chatErrorListener = null;
}
if (disconnectListener) {
socketService.off('disconnect', disconnectListener);
disconnectListener = null;
}
started = false;
log('stopped — all socket listeners removed');
}
/** Exposed for tests — dispatch as if a chat_done event arrived. */
export function __handleChatDoneForTests(payload: ChatDonePayload): void {
dispatchAndMaybeBanner('agents', {
id: `chat_done:${payload.thread_id ?? 'unknown'}:${payload.request_id ?? Date.now()}`,
title: 'Agent reply ready',
body: truncate(payload.full_response?.trim() || 'Agent finished processing.', 160),
deepLink: '/chat',
});
}
/** Exposed for tests — resets module singletons between runs. */
export function __resetForTests(): void {
started = false;
chatDoneListener = null;
chatErrorListener = null;
disconnectListener = null;
}
@@ -0,0 +1,31 @@
import { invoke, isTauri } from '@tauri-apps/api/core';
import debug from 'debug';
const log = debug('native-notifications:bridge');
const errLog = debug('native-notifications:bridge:error');
export interface ShowNativeNotificationArgs {
title: string;
body: string;
tag?: string;
}
/**
* Invoke the Tauri shell to show a native OS notification. No-op when the
* app is running outside Tauri (e.g. Vitest / pure-web dev server).
*/
export async function showNativeNotification(args: ShowNativeNotificationArgs): Promise<void> {
if (!isTauri()) {
log('not running in tauri, skipping %o', args);
return;
}
try {
await invoke('show_native_notification', {
title: args.title,
body: args.body,
tag: args.tag ?? null,
});
} catch (err) {
errLog('show_native_notification failed: %O', err);
}
}
+3 -2
View File
@@ -69,13 +69,14 @@ function handleFired(payload: WebviewNotificationFired): void {
body.length
);
store.dispatch(noteWebviewNotificationFired({ accountId }));
const now = Date.now();
store.dispatch(
notificationReceived({
id: `${accountId}:${tag ?? ''}:${Date.now()}`,
id: `${accountId}:${tag ?? ''}:${now}`,
category: 'messages',
title,
body,
timestamp: Date.now(),
timestamp: now,
read: false,
accountId,
provider,