mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 05:12:33 +00:00
Co-authored-by: Jwalin Shah <jshah1331@gmail.com> Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Jwalin Shah
Steven Enamakel
parent
98b47b107d
commit
3dc0d0db64
@@ -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")
|
||||
|
||||
@@ -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' })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export { startNativeNotificationsService, stopNativeNotificationsService } from './service';
|
||||
export { showNativeNotification } from './tauriBridge';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user