Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"))
}
Comment on lines +468 to +475

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

tauri-plugin-notification NotificationBuilder API set tag or identifier to dedupe

💡 Result:

In the official tauri-plugin-notification (tauri-apps), the NotificationBuilder API in Rust uses .id(i32) to set the notification identifier, which serves as the unique identifier for deduplication—using the same ID replaces or groups existing notifications depending on the platform's native behavior. In JavaScript/guest bindings, sendNotification accepts an optional numeric 'id' for the same purpose. Note there are two plugins: the official tauri-plugin-notification (with NotificationBuilder.id) and a third-party tauri-plugin-notifications (with NotificationsBuilder.id and optional 'tag?: string' in removeActive for more flexible identification).

Citations:


🏁 Script executed:

# First, find and read the relevant function in lib.rs
cd app/src-tauri/src
wc -l lib.rs

Repository: tinyhumansai/openhuman

Length of output: 76


🏁 Script executed:

# Read the function that contains the notification code (around lines 460-480)
sed -n '450,490p' app/src-tauri/src/lib.rs

Repository: tinyhumansai/openhuman

Length of output: 1390


🏁 Script executed:

# Check the function signature by looking further back
sed -n '430,475p' app/src-tauri/src/lib.rs

Repository: tinyhumansai/openhuman

Length of output: 1538


🏁 Script executed:

# Check Cargo.toml for notification plugin dependency
cat app/src-tauri/Cargo.toml | grep -A 5 notification

Repository: tinyhumansai/openhuman

Length of output: 1537


🏁 Script executed:

# Also check if there's a lock file that might show the exact version
cd app/src-tauri
if [ -f Cargo.lock ]; then grep -A 3 "name = \"tauri-plugin-notification" Cargo.lock; fi

Repository: tinyhumansai/openhuman

Length of output: 284


🏁 Script executed:

# Check the NotificationBuilder API for version 2.3.3
# Look at the tauri-plugin-notification source or docs
curl -s "https://docs.rs/tauri-plugin-notification/2.3.3/tauri_plugin_notification/struct.NotificationBuilder.html" | grep -i "fn " | head -20

Repository: tinyhumansai/openhuman

Length of output: 14484


🏁 Script executed:

# Check if the command is documented per coding guidelines
if [ -f docs/src-tauri/02-commands.md ]; then
  echo "=== File exists ===" 
  grep -n "show_native_notification\|notification" docs/src-tauri/02-commands.md | head -20
else
  echo "File not found"
fi

Repository: tinyhumansai/openhuman

Length of output: 85


🏁 Script executed:

# Search in the repo for how NotificationBuilder is used elsewhere
rg "notification.*builder" -A 3 -B 1 app/src-tauri/src/

Repository: tinyhumansai/openhuman

Length of output: 691


🏁 Script executed:

# Check if there are any other uses of the notification builder to see what methods are available
rg "\.id\(|\.tag\(|\.group\(" app/src-tauri/src/

Repository: tinyhumansai/openhuman

Length of output: 234


Forward the tag parameter to the notification builder for proper deduplication.

The command accepts an Option<String> tag (used by the frontend to dedupe/replace notifications) but logs it without passing it to the builder. The NotificationBuilder API (v2.3.3) provides .group(String) for this purpose—using the same group identifier will collapse repeat notifications instead of stacking them across platforms.

Additionally, this command should be registered and documented in docs/src-tauri/02-commands.md per the Tauri guidelines.

Proposed change
     let mut builder = app.notification().builder().title(&title);
     if !body.is_empty() {
         builder = builder.body(&body);
     }
+    if let Some(tag) = tag.as_deref().filter(|t| !t.is_empty()) {
+        builder = builder.group(tag);
+    }
     builder
         .show()
         .map_err(|e| format!("notification show failed: {e}"))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src-tauri/src/lib.rs` around lines 466 - 473, The notification command
currently logs the Option<String> tag but never forwards it to the
NotificationBuilder; update the builder chain returned from
app.notification().builder() to call .group(tag) when the tag is Some(...)
(i.e., convert the Option<String> tag into a string and call builder =
builder.group(tag_val) before building), using the NotificationBuilder API to
enable deduplication, and then keep the existing body handling and .show() error
mapping; additionally register this command in the Tauri command list and add a
short entry documenting the new tag behavior in docs/src-tauri/02-commands.md so
the frontend usage is recorded.


fn show_main_window(app: &AppHandle<AppRuntime>) -> Result<(), String> {
let window = app
.get_webview_window("main")
Expand Down Expand Up @@ -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")
Expand Down
2 changes: 2 additions & 0 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -29,6 +30,7 @@ import { isAccountsFullscreen } from './utils/accountsFullscreen';
// Idempotent — the service uses a `started` singleton guard.
startWebviewAccountService();
startWebviewNotificationsService();
startNativeNotificationsService();

function App() {
return (
Expand Down
10 changes: 4 additions & 6 deletions app/src/components/notifications/NotificationCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>

Expand Down
6 changes: 2 additions & 4 deletions app/src/components/notifications/NotificationCenter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,6 @@ const NotificationCenter = () => {
}
};

const providers = allProviders;

return (
<div className="flex flex-col h-full">
{/* Header */}
Expand All @@ -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)}
Expand All @@ -112,7 +110,7 @@ const NotificationCenter = () => {
}`}>
All
</button>
{providers.map(p => (
{allProviders.map(p => (
<button
key={p}
onClick={() => setSelectedProvider(p === selectedProvider ? undefined : p)}
Expand Down
61 changes: 61 additions & 0 deletions app/src/lib/nativeNotifications/__tests__/service.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 2 additions & 0 deletions app/src/lib/nativeNotifications/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { startNativeNotificationsService, stopNativeNotificationsService } from './service';
export { showNativeNotification } from './tauriBridge';
145 changes: 145 additions & 0 deletions app/src/lib/nativeNotifications/service.ts
Original file line number Diff line number Diff line change
@@ -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');
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

/** 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;
}
31 changes: 31 additions & 0 deletions app/src/lib/nativeNotifications/tauriBridge.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
5 changes: 3 additions & 2 deletions app/src/lib/webviewNotifications/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading