Repository navigation
feat(notifications): native OS banners for agent/chat/socket events (Phase 1c of #395) #780
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
senamakel
merged 3 commits into
tinyhumansai:main
from
jwalin-shah:feat/native-notifications
Apr 23, 2026
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
9d6788b
feat(notifications): in-app notification center (Phase 1b of #395)
jwalinsshah fa6a5e0
feat(notifications): native banners for agent/chat/socket events (Pha…
jwalinsshah 5ed99a4
fix(notifications): resolve merge conflicts + review fixes (PR #780)
senamakel File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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' }) | ||
| ); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| export { startNativeNotificationsService, stopNativeNotificationsService } from './service'; | ||
| export { showNativeNotification } from './tauriBridge'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'); | ||
| } | ||
|
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; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 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:
Repository: tinyhumansai/openhuman
Length of output: 76
🏁 Script executed:
Repository: tinyhumansai/openhuman
Length of output: 1390
🏁 Script executed:
Repository: tinyhumansai/openhuman
Length of output: 1538
🏁 Script executed:
Repository: tinyhumansai/openhuman
Length of output: 1537
🏁 Script executed:
Repository: tinyhumansai/openhuman
Length of output: 284
🏁 Script executed:
Repository: tinyhumansai/openhuman
Length of output: 14484
🏁 Script executed:
Repository: tinyhumansai/openhuman
Length of output: 85
🏁 Script executed:
Repository: tinyhumansai/openhuman
Length of output: 691
🏁 Script executed:
Repository: tinyhumansai/openhuman
Length of output: 234
Forward the
tagparameter 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. TheNotificationBuilderAPI (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.mdper 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