Skip to content
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

Add browser notifications #6246

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
75 changes: 75 additions & 0 deletions frontend/__tests__/services/notification.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { sendNotification } from '../../src/services/notification';

interface NotificationConstructor {
new (title: string, options?: NotificationOptions): Notification;
readonly permission: NotificationPermission;
readonly maxActions: number;
requestPermission(): Promise<NotificationPermission>;
}

describe('sendNotification', () => {
let mockNotification: Mock;

beforeEach(() => {
// Mock localStorage
Storage.prototype.getItem = vi.fn();
Storage.prototype.setItem = vi.fn();

// Mock Notification API
mockNotification = vi.fn((title: string, options?: NotificationOptions) => ({
title,
...options,
}));
Object.defineProperty(mockNotification, 'permission', {
get: () => 'granted',
configurable: true
});

// Set up the window.Notification mock
const NotificationMock = mockNotification as unknown as NotificationConstructor;
Object.defineProperty(window, 'Notification', {
value: NotificationMock,
writable: true,
});
});

it('should send notification when notifications are enabled', () => {
// Mock notifications being enabled
vi.mocked(Storage.prototype.getItem).mockReturnValue('true');

const title = 'Test Title';
const options = {
body: 'Test Body',
icon: '/test-icon.png'
};

sendNotification(title, options);

expect(mockNotification).toHaveBeenCalledWith(title, options);
});

it('should not send notification when notifications are disabled', () => {
// Mock notifications being disabled
vi.mocked(Storage.prototype.getItem).mockReturnValue('false');

sendNotification('Test Title', { body: 'Test Body' });

expect(mockNotification).not.toHaveBeenCalled();
});

it('should not send notification when permission is not granted', () => {
// Mock notifications being enabled but permission not granted
vi.mocked(Storage.prototype.getItem).mockReturnValue('true');

// Change permission to denied
Object.defineProperty(mockNotification, 'permission', {
get: () => 'denied',
configurable: true
});

sendNotification('Test Title', { body: 'Test Body' });

expect(mockNotification).not.toHaveBeenCalled();
});
});
87 changes: 87 additions & 0 deletions frontend/__tests__/services/observations.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { handleObservationMessage } from '../../src/services/observations';
import { AgentState } from '../../src/types/agent-state';
import { sendNotification } from '../../src/services/notification';
import store from '../../src/store';

vi.mock('../../src/services/notification');
vi.mock('../../src/store', () => ({
default: {
dispatch: vi.fn(),
},
}));

describe('handleObservationMessage', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('should trigger notification when agent state changes to AWAITING_USER_INPUT', () => {
const message = {
id: 1,
source: 'agent' as const,
observation: 'agent_state_changed',
extras: {
agent_state: AgentState.AWAITING_USER_INPUT,
metadata: {},
error_id: '',
},
message: 'Agent state changed',
content: '',
cause: 0,
timestamp: new Date().toISOString(),
};

handleObservationMessage(message);

expect(sendNotification).toHaveBeenCalledWith('OpenHands', {
body: 'Agent is awaiting user input...',
icon: '/favicon.ico',
});
});

it('should trigger notification when agent state changes to FINISHED', () => {
const message = {
id: 2,
source: 'agent' as const,
observation: 'agent_state_changed',
extras: {
agent_state: AgentState.FINISHED,
metadata: {},
error_id: '',
},
message: 'Agent state changed',
content: '',
cause: 0,
timestamp: new Date().toISOString(),
};

handleObservationMessage(message);

expect(sendNotification).toHaveBeenCalledWith('OpenHands', {
body: 'Task completed successfully!',
icon: '/favicon.ico',
});
});

it('should not trigger notification for other agent states', () => {
const message = {
id: 3,
source: 'agent' as const,
observation: 'agent_state_changed',
extras: {
agent_state: AgentState.RUNNING,
metadata: {},
error_id: '',
},
message: 'Agent state changed',
content: '',
cause: 0,
timestamp: new Date().toISOString(),
};

handleObservationMessage(message);

expect(sendNotification).not.toHaveBeenCalled();
});
});
21 changes: 21 additions & 0 deletions frontend/src/services/notification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export const sendNotification = (
title: string,
options?: NotificationOptions,
) => {
if (!("Notification" in window)) {
// eslint-disable-next-line no-console
console.warn("This browser does not support desktop notifications");
return;
}

const notificationsEnabled =
localStorage.getItem("notifications-enabled") === "true";
if (!notificationsEnabled) {
return;
}

if (Notification.permission === "granted") {
// eslint-disable-next-line no-new
new Notification(title, options);
}
};
12 changes: 12 additions & 0 deletions frontend/src/services/observations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
addAssistantMessage,
addAssistantObservation,
} from "#/state/chat-slice";
import { sendNotification } from "./notification";

export function handleObservationMessage(message: ObservationMessage) {
switch (message.observation) {
Expand Down Expand Up @@ -39,6 +40,17 @@ export function handleObservationMessage(message: ObservationMessage) {
break;
case ObservationType.AGENT_STATE_CHANGED:
store.dispatch(setCurrentAgentState(message.extras.agent_state));
if (message.extras.agent_state === AgentState.AWAITING_USER_INPUT) {
sendNotification("OpenHands", {
body: "Agent is awaiting user input...",
icon: "/favicon.ico",
});
} else if (message.extras.agent_state === AgentState.FINISHED) {
sendNotification("OpenHands", {
body: "Task completed successfully!",
icon: "/favicon.ico",
});
}
break;
case ObservationType.DELEGATE:
// TODO: better UI for delegation result (#2309)
Expand Down
Loading