mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
feat(editor): Add native browser notifications on AI workflow builder completion or waiting for input (#24224)
This commit is contained in:
+29
-1
@@ -534,10 +534,17 @@ defineExpose({
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="$slots.inputHeader && showBottomInput" :class="$style.inputHeaderWrapper">
|
||||
<slot name="inputHeader" />
|
||||
</div>
|
||||
<div
|
||||
v-if="showBottomInput"
|
||||
ref="inputWrapperRef"
|
||||
:class="{ [$style.inputWrapper]: true, [$style.disabledInput]: sessionEnded }"
|
||||
:class="{
|
||||
[$style.inputWrapper]: true,
|
||||
[$style.inputWrapperWithHeader]: $slots.inputHeader,
|
||||
[$style.disabledInput]: sessionEnded,
|
||||
}"
|
||||
data-test-id="chat-input-wrapper"
|
||||
>
|
||||
<div v-if="$slots.inputPlaceholder" :class="$style.inputPlaceholder">
|
||||
@@ -703,6 +710,19 @@ defineExpose({
|
||||
color: var(--color--text);
|
||||
}
|
||||
|
||||
.inputHeaderWrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
border-left: var(--border);
|
||||
border-right: var(--border);
|
||||
background-color: transparent;
|
||||
|
||||
> :first-child {
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
|
||||
.inputWrapper {
|
||||
padding: var(--spacing--4xs) var(--spacing--2xs) var(--spacing--xs);
|
||||
background-color: transparent;
|
||||
@@ -725,6 +745,14 @@ defineExpose({
|
||||
}
|
||||
}
|
||||
|
||||
.inputWrapperWithHeader {
|
||||
padding-top: 0;
|
||||
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.disabledInput {
|
||||
cursor: not-allowed;
|
||||
|
||||
|
||||
+7
@@ -81,6 +81,7 @@ exports[`AskAssistantChat > limits maximum input length when maxCharacterLength
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<!--v-if-->
|
||||
<div
|
||||
class="inputWrapper"
|
||||
data-test-id="chat-input-wrapper"
|
||||
@@ -337,6 +338,7 @@ exports[`AskAssistantChat > renders chat with messages correctly 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!--v-if-->
|
||||
<div
|
||||
class="inputWrapper"
|
||||
data-test-id="chat-input-wrapper"
|
||||
@@ -440,6 +442,7 @@ exports[`AskAssistantChat > renders default placeholder chat correctly 1`] = `
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<!--v-if-->
|
||||
<div
|
||||
class="inputWrapper"
|
||||
data-test-id="chat-input-wrapper"
|
||||
@@ -595,6 +598,7 @@ exports[`AskAssistantChat > renders end of session chat correctly 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!--v-if-->
|
||||
<div
|
||||
class="inputWrapper disabledInput"
|
||||
data-test-id="chat-input-wrapper"
|
||||
@@ -731,6 +735,7 @@ exports[`AskAssistantChat > renders error message correctly with retry button 1`
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!--v-if-->
|
||||
<div
|
||||
class="inputWrapper"
|
||||
data-test-id="chat-input-wrapper"
|
||||
@@ -867,6 +872,7 @@ exports[`AskAssistantChat > renders message with code snippet 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!--v-if-->
|
||||
<div
|
||||
class="inputWrapper"
|
||||
data-test-id="chat-input-wrapper"
|
||||
@@ -1003,6 +1009,7 @@ exports[`AskAssistantChat > renders streaming chat correctly 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!--v-if-->
|
||||
<div
|
||||
class="inputWrapper"
|
||||
data-test-id="chat-input-wrapper"
|
||||
|
||||
@@ -222,6 +222,7 @@
|
||||
"auth.signup.setupYourAccountError": "Problem setting up your account",
|
||||
"auth.signup.tokenValidationError": "Issue validating invite token",
|
||||
"aiAssistant.name": "n8n AI",
|
||||
"aiAssistant.assistant": "Assistant",
|
||||
"aiAssistant.tabs.ask": "Ask",
|
||||
"aiAssistant.tabs.build": "Build",
|
||||
"aiAssistant.builder.mode": "AI Builder",
|
||||
@@ -260,7 +261,12 @@
|
||||
"aiAssistant.builder.restoreError.title": "Failed to restore version",
|
||||
"aiAssistant.builder.disabledTooltip.autosaving": "Saving workflow...",
|
||||
"aiAssistant.builder.disabledTooltip.readOnly": "Another user is currently editing this workflow",
|
||||
"aiAssistant.assistant": "n8n AI",
|
||||
"aiAssistant.builder.notification.title": "Workflow ready - n8n",
|
||||
"aiAssistant.builder.notification.body": "Done building {workflowName}",
|
||||
"aiAssistant.builder.notification.inputNeeded.title": "Input needed - n8n",
|
||||
"aiAssistant.builder.notification.inputNeeded.body": "Waiting for your response on {workflowName}",
|
||||
"aiAssistant.builder.notificationBanner.text": "Want to be notified when the agent finishes building?",
|
||||
"aiAssistant.builder.notificationBanner.notify": "Notify",
|
||||
"aiAssistant.newSessionModal.title.part1": "Start new",
|
||||
"aiAssistant.newSessionModal.title.part2": "session",
|
||||
"aiAssistant.newSessionModal.message": "You already have an active n8n AI session. Starting a new session will clear your current conversation history.",
|
||||
@@ -4315,5 +4321,13 @@
|
||||
"commandBar.chat.deleteCurrent": "Delete current chat",
|
||||
"commandBar.chat.deleteSession": "Delete \"{title}\"",
|
||||
"commandBar.chat.open.searchPlaceholder": "Search by f title...",
|
||||
"commandBar.chat.stop": "Stop message generation"
|
||||
"commandBar.chat.stop": "Stop message generation",
|
||||
"browserNotifications.permission.request.title": "Enable desktop notifications",
|
||||
"browserNotifications.permission.request.message": "Get notified when your workflows complete or need attention.",
|
||||
"browserNotifications.permission.request.allow": "Allow notifications",
|
||||
"browserNotifications.permission.request.dismiss": "Not now",
|
||||
"browserNotifications.permission.denied.title": "Notifications blocked",
|
||||
"browserNotifications.permission.denied.message": "Desktop notifications are blocked. To enable them, click the lock icon in your browser's address bar and allow notifications.",
|
||||
"browserNotifications.permission.denied.checkAgain": "Check again",
|
||||
"browserNotifications.permission.granted.message": "Desktop notifications are enabled"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { useBrowserNotifications, __resetpermissionState } from './useBrowserNotifications';
|
||||
import { LOCAL_STORAGE_BROWSER_NOTIFICATION_METADATA } from '@/app/constants/localStorage';
|
||||
import { SEVEN_DAYS_IN_MILLIS } from '@/app/constants/durations';
|
||||
|
||||
// Mock the Notification API
|
||||
const mockRequestPermission = vi.fn();
|
||||
|
||||
// Store original Notification
|
||||
const originalNotification = global.Notification;
|
||||
|
||||
function setupNotificationMock(permission: NotificationPermission = 'default') {
|
||||
const MockNotification = vi
|
||||
.fn()
|
||||
.mockImplementation((title: string, options?: NotificationOptions) => ({
|
||||
title,
|
||||
...options,
|
||||
}));
|
||||
|
||||
Object.defineProperty(MockNotification, 'permission', {
|
||||
value: permission,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(MockNotification, 'requestPermission', {
|
||||
value: mockRequestPermission,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(global, 'Notification', {
|
||||
value: MockNotification,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
describe('useBrowserNotifications', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
|
||||
// Reset Notification mock with default permission
|
||||
mockRequestPermission.mockResolvedValue('granted');
|
||||
setupNotificationMock('default');
|
||||
|
||||
// Reset shared state after setting up the mock
|
||||
__resetpermissionState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original Notification
|
||||
Object.defineProperty(global, 'Notification', {
|
||||
value: originalNotification,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should detect notifications are supported when Notification API exists', () => {
|
||||
const { isSupported } = useBrowserNotifications();
|
||||
expect(isSupported.value).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect notifications are not supported when Notification API is undefined', () => {
|
||||
// @ts-expect-error - intentionally setting to undefined for test
|
||||
delete global.Notification;
|
||||
|
||||
const { isSupported } = useBrowserNotifications();
|
||||
expect(isSupported.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should initialize with default permission state', () => {
|
||||
const { permissionState, canPrompt } = useBrowserNotifications();
|
||||
expect(permissionState.value).toBe('default');
|
||||
expect(canPrompt.value).toBe(true);
|
||||
});
|
||||
|
||||
it('should initialize isEnabled as false when permission is default', () => {
|
||||
const { isEnabled } = useBrowserNotifications();
|
||||
expect(isEnabled.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should initialize isDenied as false when permission is default', () => {
|
||||
const { isDenied } = useBrowserNotifications();
|
||||
expect(isDenied.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('permission states', () => {
|
||||
it('should report isEnabled as true when permission is granted', () => {
|
||||
setupNotificationMock('granted');
|
||||
__resetpermissionState();
|
||||
|
||||
const { isEnabled, isDenied, canPrompt } = useBrowserNotifications();
|
||||
|
||||
expect(isEnabled.value).toBe(true);
|
||||
expect(isDenied.value).toBe(false);
|
||||
expect(canPrompt.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should report isDenied as true when permission is denied', () => {
|
||||
setupNotificationMock('denied');
|
||||
__resetpermissionState();
|
||||
|
||||
const { isEnabled, isDenied, canPrompt } = useBrowserNotifications();
|
||||
|
||||
expect(isEnabled.value).toBe(false);
|
||||
expect(isDenied.value).toBe(true);
|
||||
expect(canPrompt.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestPermission', () => {
|
||||
it('should request permission and return granted result', async () => {
|
||||
mockRequestPermission.mockResolvedValue('granted');
|
||||
|
||||
const { requestPermission, isEnabled } = useBrowserNotifications();
|
||||
const result = await requestPermission();
|
||||
|
||||
expect(mockRequestPermission).toHaveBeenCalled();
|
||||
expect(result.permission).toBe('granted');
|
||||
expect(result.wasRequested).toBe(true);
|
||||
expect(isEnabled.value).toBe(true);
|
||||
});
|
||||
|
||||
it('should request permission and return denied result', async () => {
|
||||
mockRequestPermission.mockResolvedValue('denied');
|
||||
|
||||
const { requestPermission, isDenied } = useBrowserNotifications();
|
||||
const result = await requestPermission();
|
||||
|
||||
expect(result.permission).toBe('denied');
|
||||
expect(result.wasRequested).toBe(true);
|
||||
expect(isDenied.value).toBe(true);
|
||||
});
|
||||
|
||||
it('should not request permission if already granted', async () => {
|
||||
setupNotificationMock('granted');
|
||||
__resetpermissionState();
|
||||
|
||||
const { requestPermission } = useBrowserNotifications();
|
||||
const result = await requestPermission();
|
||||
|
||||
expect(mockRequestPermission).not.toHaveBeenCalled();
|
||||
expect(result.permission).toBe('granted');
|
||||
expect(result.wasRequested).toBe(false);
|
||||
});
|
||||
|
||||
it('should not request permission if already denied', async () => {
|
||||
setupNotificationMock('denied');
|
||||
__resetpermissionState();
|
||||
|
||||
const { requestPermission } = useBrowserNotifications();
|
||||
const result = await requestPermission();
|
||||
|
||||
expect(mockRequestPermission).not.toHaveBeenCalled();
|
||||
expect(result.permission).toBe('denied');
|
||||
expect(result.wasRequested).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle requestPermission errors gracefully', async () => {
|
||||
mockRequestPermission.mockRejectedValue(new Error('User interaction required'));
|
||||
|
||||
const { requestPermission } = useBrowserNotifications();
|
||||
const result = await requestPermission();
|
||||
|
||||
expect(result.wasRequested).toBe(true);
|
||||
expect(result.permission).toBe('default');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dismissal tracking', () => {
|
||||
it('should record dismissal and update metadata', () => {
|
||||
const { recordDismissal, metadata, isInCooldown } = useBrowserNotifications();
|
||||
|
||||
expect(metadata.value.dismissCount).toBe(0);
|
||||
expect(metadata.value.lastDismissedAt).toBeNull();
|
||||
expect(isInCooldown.value).toBe(false);
|
||||
|
||||
recordDismissal();
|
||||
|
||||
expect(metadata.value.dismissCount).toBe(1);
|
||||
expect(metadata.value.lastDismissedAt).not.toBeNull();
|
||||
expect(isInCooldown.value).toBe(true);
|
||||
});
|
||||
|
||||
it('should increment dismissal count on subsequent dismissals', () => {
|
||||
const { recordDismissal, metadata } = useBrowserNotifications();
|
||||
|
||||
recordDismissal();
|
||||
recordDismissal();
|
||||
recordDismissal();
|
||||
|
||||
expect(metadata.value.dismissCount).toBe(3);
|
||||
});
|
||||
|
||||
it('should block prompting when in cooldown', () => {
|
||||
const { recordDismissal, canPrompt } = useBrowserNotifications();
|
||||
|
||||
expect(canPrompt.value).toBe(true);
|
||||
|
||||
recordDismissal();
|
||||
|
||||
expect(canPrompt.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should block prompting when max dismissals exceeded', () => {
|
||||
const { recordDismissal, canPrompt } = useBrowserNotifications({ maxDismissals: 2 });
|
||||
|
||||
expect(canPrompt.value).toBe(true);
|
||||
|
||||
recordDismissal();
|
||||
recordDismissal();
|
||||
|
||||
// Even without cooldown, max dismissals blocks prompting
|
||||
expect(canPrompt.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should not request permission when in cooldown', async () => {
|
||||
const { recordDismissal, requestPermission } = useBrowserNotifications();
|
||||
|
||||
recordDismissal();
|
||||
|
||||
const result = await requestPermission();
|
||||
|
||||
expect(mockRequestPermission).not.toHaveBeenCalled();
|
||||
expect(result.wasRequested).toBe(false);
|
||||
});
|
||||
|
||||
it('should respect custom cooldown duration', () => {
|
||||
const customCooldownMs = 1000;
|
||||
const { recordDismissal, isInCooldown } = useBrowserNotifications({
|
||||
cooldownMs: customCooldownMs,
|
||||
});
|
||||
|
||||
recordDismissal();
|
||||
|
||||
expect(isInCooldown.value).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow prompting after cooldown expires', () => {
|
||||
// Test with cooldownMs: 0 to simulate expired cooldown
|
||||
const { recordDismissal, canPrompt, isInCooldown, metadata } = useBrowserNotifications({
|
||||
cooldownMs: 0, // Immediate expiry
|
||||
maxDismissals: 10,
|
||||
});
|
||||
|
||||
recordDismissal();
|
||||
|
||||
// With 0ms cooldown, it should immediately expire
|
||||
expect(isInCooldown.value).toBe(false);
|
||||
expect(canPrompt.value).toBe(true);
|
||||
expect(metadata.value.dismissCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetMetadata', () => {
|
||||
it('should reset dismissal metadata', () => {
|
||||
const { recordDismissal, resetMetadata, metadata, isInCooldown, canPrompt } =
|
||||
useBrowserNotifications();
|
||||
|
||||
recordDismissal();
|
||||
recordDismissal();
|
||||
|
||||
expect(metadata.value.dismissCount).toBe(2);
|
||||
expect(isInCooldown.value).toBe(true);
|
||||
|
||||
resetMetadata();
|
||||
|
||||
expect(metadata.value.dismissCount).toBe(0);
|
||||
expect(metadata.value.lastDismissedAt).toBeNull();
|
||||
expect(isInCooldown.value).toBe(false);
|
||||
expect(canPrompt.value).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('showNotification', () => {
|
||||
it('should create a notification when enabled', () => {
|
||||
setupNotificationMock('granted');
|
||||
__resetpermissionState();
|
||||
|
||||
const { showNotification } = useBrowserNotifications();
|
||||
|
||||
const notification = showNotification('Test Title', { body: 'Test Body' });
|
||||
|
||||
expect(notification).not.toBeNull();
|
||||
expect(global.Notification).toHaveBeenCalledWith('Test Title', { body: 'Test Body' });
|
||||
});
|
||||
|
||||
it('should return null when notifications are not enabled', () => {
|
||||
// Permission is 'default' which means not enabled
|
||||
const { showNotification } = useBrowserNotifications();
|
||||
|
||||
const notification = showNotification('Test Title');
|
||||
|
||||
expect(notification).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when notifications are not supported', () => {
|
||||
// @ts-expect-error - intentionally setting to undefined for test
|
||||
delete global.Notification;
|
||||
__resetpermissionState();
|
||||
|
||||
const { showNotification } = useBrowserNotifications();
|
||||
|
||||
const notification = showNotification('Test Title');
|
||||
|
||||
expect(notification).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('localStorage persistence', () => {
|
||||
it('should persist metadata after recording dismissal', () => {
|
||||
const { recordDismissal, metadata } = useBrowserNotifications();
|
||||
|
||||
recordDismissal();
|
||||
|
||||
// Verify the metadata is updated in the composable
|
||||
expect(metadata.value.dismissCount).toBe(1);
|
||||
expect(metadata.value.lastDismissedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should load metadata from localStorage on initialization', () => {
|
||||
const existingMetadata = {
|
||||
lastDismissedAt: Date.now() - 1000,
|
||||
dismissCount: 2,
|
||||
};
|
||||
localStorage.setItem(
|
||||
LOCAL_STORAGE_BROWSER_NOTIFICATION_METADATA,
|
||||
JSON.stringify(existingMetadata),
|
||||
);
|
||||
|
||||
const { metadata, isInCooldown } = useBrowserNotifications();
|
||||
|
||||
expect(metadata.value.dismissCount).toBe(2);
|
||||
expect(isInCooldown.value).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shared state (singleton pattern)', () => {
|
||||
it('should share permission state across multiple instances', async () => {
|
||||
// First instance
|
||||
const instance1 = useBrowserNotifications();
|
||||
// Second instance
|
||||
const instance2 = useBrowserNotifications();
|
||||
|
||||
// Both should start with same state
|
||||
expect(instance1.permissionState.value).toBe(instance2.permissionState.value);
|
||||
|
||||
// Mock permission request to return granted
|
||||
mockRequestPermission.mockResolvedValue('granted');
|
||||
|
||||
// Request permission through instance1
|
||||
await instance1.requestPermission();
|
||||
|
||||
// Both instances should reflect the change
|
||||
expect(instance1.permissionState.value).toBe('granted');
|
||||
expect(instance2.permissionState.value).toBe('granted');
|
||||
expect(instance1.isEnabled.value).toBe(true);
|
||||
expect(instance2.isEnabled.value).toBe(true);
|
||||
});
|
||||
|
||||
it('should update canPrompt in all instances when permission changes', async () => {
|
||||
const instance1 = useBrowserNotifications();
|
||||
const instance2 = useBrowserNotifications();
|
||||
|
||||
expect(instance1.canPrompt.value).toBe(true);
|
||||
expect(instance2.canPrompt.value).toBe(true);
|
||||
|
||||
mockRequestPermission.mockResolvedValue('granted');
|
||||
await instance1.requestPermission();
|
||||
|
||||
// canPrompt should be false in both instances since permission is no longer 'default'
|
||||
expect(instance1.canPrompt.value).toBe(false);
|
||||
expect(instance2.canPrompt.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('default options', () => {
|
||||
it('should use default cooldown of 7 days by checking metadata lastDismissedAt', () => {
|
||||
const { recordDismissal, metadata } = useBrowserNotifications({ maxDismissals: 10 });
|
||||
|
||||
recordDismissal();
|
||||
|
||||
// Verify that the metadata records the dismissal time
|
||||
expect(metadata.value.lastDismissedAt).not.toBeNull();
|
||||
|
||||
// The cooldown calculation should use SEVEN_DAYS_IN_MILLIS
|
||||
// We verify this by checking the isInCooldown logic with a time-based check
|
||||
const timeSinceDismissal = Date.now() - metadata.value.lastDismissedAt!;
|
||||
expect(timeSinceDismissal).toBeLessThan(SEVEN_DAYS_IN_MILLIS);
|
||||
});
|
||||
|
||||
it('should use default max dismissals of 3', () => {
|
||||
// Use cooldownMs: 0 to bypass cooldown for this test
|
||||
const { recordDismissal, canPrompt, metadata } = useBrowserNotifications({
|
||||
cooldownMs: 0,
|
||||
});
|
||||
|
||||
recordDismissal();
|
||||
recordDismissal();
|
||||
|
||||
// Still under max dismissals (2 < 3)
|
||||
expect(metadata.value.dismissCount).toBe(2);
|
||||
expect(canPrompt.value).toBe(true);
|
||||
|
||||
recordDismissal();
|
||||
// Now at max dismissals (3 >= 3)
|
||||
expect(metadata.value.dismissCount).toBe(3);
|
||||
expect(canPrompt.value).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
import { LOCAL_STORAGE_BROWSER_NOTIFICATION_METADATA } from '@/app/constants/localStorage';
|
||||
import { SEVEN_DAYS_IN_MILLIS } from '@/app/constants/durations';
|
||||
|
||||
interface BrowserNotificationMetadata {
|
||||
lastDismissedAt: number | null;
|
||||
dismissCount: number;
|
||||
}
|
||||
|
||||
export interface PermissionRequestResult {
|
||||
permission: NotificationPermission;
|
||||
wasRequested: boolean;
|
||||
}
|
||||
|
||||
export interface UseBrowserNotificationsOptions {
|
||||
cooldownMs?: number;
|
||||
maxDismissals?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_DISMISSALS = 3;
|
||||
|
||||
const permissionState = ref<NotificationPermission>(
|
||||
typeof Notification !== 'undefined' ? Notification.permission : 'denied',
|
||||
);
|
||||
|
||||
/**
|
||||
* Reset shared permission state. Only for testing purposes.
|
||||
* @internal
|
||||
*/
|
||||
export function __resetpermissionState(): void {
|
||||
permissionState.value = typeof Notification !== 'undefined' ? Notification.permission : 'denied';
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for managing browser notification permissions.
|
||||
*
|
||||
* Provides a clean API for:
|
||||
* - Checking if notifications are supported/enabled
|
||||
* - Requesting notification permission
|
||||
* - Handling cooldown logic after dismissals
|
||||
* - Showing native browser notifications
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const { isEnabled, canPrompt, requestPermission, showNotification } = useBrowserNotifications();
|
||||
*
|
||||
* if (canPrompt.value) {
|
||||
* await requestPermission();
|
||||
* }
|
||||
*
|
||||
* if (isEnabled.value) {
|
||||
* showNotification('Workflow Complete', { body: 'Your workflow finished successfully' });
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useBrowserNotifications(options: UseBrowserNotificationsOptions = {}) {
|
||||
const { cooldownMs = SEVEN_DAYS_IN_MILLIS, maxDismissals = DEFAULT_MAX_DISMISSALS } = options;
|
||||
|
||||
const metadata = useLocalStorage<BrowserNotificationMetadata>(
|
||||
LOCAL_STORAGE_BROWSER_NOTIFICATION_METADATA,
|
||||
{ lastDismissedAt: null, dismissCount: 0 },
|
||||
{ writeDefaults: false },
|
||||
);
|
||||
|
||||
const isSupported = computed(() => typeof Notification !== 'undefined');
|
||||
const isEnabled = computed(() => permissionState.value === 'granted');
|
||||
const isDenied = computed(() => permissionState.value === 'denied');
|
||||
|
||||
const isInCooldown = computed(() => {
|
||||
if (!metadata.value.lastDismissedAt) return false;
|
||||
const timeSinceDismissal = Date.now() - metadata.value.lastDismissedAt;
|
||||
return timeSinceDismissal < cooldownMs;
|
||||
});
|
||||
|
||||
const hasExceededMaxDismissals = computed(() => {
|
||||
return metadata.value.dismissCount >= maxDismissals;
|
||||
});
|
||||
|
||||
const canPrompt = computed(() => {
|
||||
return (
|
||||
isSupported.value &&
|
||||
permissionState.value === 'default' &&
|
||||
!isInCooldown.value &&
|
||||
!hasExceededMaxDismissals.value
|
||||
);
|
||||
});
|
||||
|
||||
function refreshPermissionState(): void {
|
||||
if (typeof Notification !== 'undefined') {
|
||||
permissionState.value = Notification.permission;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request notification permission from the browser.
|
||||
* Respects cooldown and denial states.
|
||||
*
|
||||
* @returns Result with permission state and whether request was actually made
|
||||
*/
|
||||
async function requestPermission(): Promise<PermissionRequestResult> {
|
||||
refreshPermissionState();
|
||||
|
||||
if (permissionState.value === 'granted') {
|
||||
return { permission: 'granted', wasRequested: false };
|
||||
}
|
||||
|
||||
if (permissionState.value === 'denied') {
|
||||
return { permission: 'denied', wasRequested: false };
|
||||
}
|
||||
|
||||
if (isInCooldown.value || hasExceededMaxDismissals.value) {
|
||||
return { permission: permissionState.value, wasRequested: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await Notification.requestPermission();
|
||||
permissionState.value = result;
|
||||
return { permission: result, wasRequested: true };
|
||||
} catch {
|
||||
refreshPermissionState();
|
||||
return { permission: permissionState.value, wasRequested: true };
|
||||
}
|
||||
}
|
||||
|
||||
function recordDismissal(): void {
|
||||
metadata.value = {
|
||||
lastDismissedAt: Date.now(),
|
||||
dismissCount: metadata.value.dismissCount + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function resetMetadata(): void {
|
||||
metadata.value = { lastDismissedAt: null, dismissCount: 0 };
|
||||
}
|
||||
|
||||
function showNotification(
|
||||
title: string,
|
||||
notificationOptions?: NotificationOptions,
|
||||
): Notification | null {
|
||||
if (!isEnabled.value || !isSupported.value) {
|
||||
return null;
|
||||
}
|
||||
return new Notification(title, notificationOptions);
|
||||
}
|
||||
|
||||
return {
|
||||
permissionState,
|
||||
|
||||
isSupported,
|
||||
isEnabled,
|
||||
isDenied,
|
||||
isInCooldown,
|
||||
canPrompt,
|
||||
|
||||
requestPermission,
|
||||
recordDismissal,
|
||||
resetMetadata,
|
||||
refreshPermissionState,
|
||||
showNotification,
|
||||
|
||||
metadata: computed(() => metadata.value),
|
||||
};
|
||||
}
|
||||
@@ -28,3 +28,4 @@ export const LOCAL_STORAGE_CHAT_HUB_STATIC_SIDEBAR = (userId: string) =>
|
||||
export const LOCAL_STORAGE_CHAT_HUB_SELECTED_TOOLS = (userId: string) =>
|
||||
`${userId}_N8N_CHAT_HUB_SELECTED_TOOLS`;
|
||||
export const LOCAL_STORAGE_SIDEBAR_WIDTH = 'N8N_SIDEBAR_WIDTH';
|
||||
export const LOCAL_STORAGE_BROWSER_NOTIFICATION_METADATA = 'N8N_BROWSER_NOTIFICATION_METADATA';
|
||||
|
||||
@@ -96,6 +96,18 @@ vi.mock('@/app/composables/useDocumentTitle', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useBrowserNotifications
|
||||
const mockShowNotification = vi.fn();
|
||||
const mockIsNotificationsEnabled = { value: false };
|
||||
vi.mock('@/app/composables/useBrowserNotifications', () => ({
|
||||
useBrowserNotifications: () => ({
|
||||
showNotification: mockShowNotification,
|
||||
isEnabled: mockIsNotificationsEnabled,
|
||||
canPrompt: { value: false },
|
||||
requestPermission: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
let settingsStore: ReturnType<typeof useSettingsStore>;
|
||||
let posthogStore: ReturnType<typeof usePostHog>;
|
||||
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
|
||||
@@ -1444,6 +1456,23 @@ describe('AI Builder store', () => {
|
||||
|
||||
expect(track).not.toHaveBeenCalledWith('End of response from builder', expect.anything());
|
||||
});
|
||||
|
||||
it('includes tab_visible in abort telemetry', async () => {
|
||||
const builderStore = useBuilderStore();
|
||||
|
||||
apiSpy.mockImplementationOnce(() => {});
|
||||
await builderStore.sendChatMessage({ text: 'test' });
|
||||
|
||||
track.mockClear();
|
||||
builderStore.abortStreaming();
|
||||
|
||||
expect(track).toHaveBeenCalledWith(
|
||||
'End of response from builder',
|
||||
expect.objectContaining({
|
||||
tab_visible: expect.any(Boolean),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('workflowTodos', () => {
|
||||
@@ -2221,4 +2250,145 @@ describe('AI Builder store', () => {
|
||||
expect(setDocumentTitleMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Browser notifications', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let capturedOnMessageCallback: ((data: any) => void) | null = null;
|
||||
let capturedDoneCallback: (() => void) | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
mockShowNotification.mockClear();
|
||||
mockShowNotification.mockReturnValue({
|
||||
onclick: null,
|
||||
close: vi.fn(),
|
||||
});
|
||||
capturedOnMessageCallback = null;
|
||||
capturedDoneCallback = null;
|
||||
|
||||
// Mock chatWithBuilder to capture the onMessage and done callbacks
|
||||
apiSpy.mockImplementation((_context, _options, onMessage, onDone) => {
|
||||
capturedOnMessageCallback = onMessage;
|
||||
capturedDoneCallback = onDone;
|
||||
});
|
||||
});
|
||||
|
||||
const triggerSuccessfulStreamingComplete = async () => {
|
||||
const builderStore = useBuilderStore();
|
||||
workflowsStore.workflowName = 'Test Workflow';
|
||||
workflowsStore.workflowId = 'test-workflow-123';
|
||||
workflowsStore.isNewWorkflow = false;
|
||||
workflowsStore.workflowVersionId = 'version-1';
|
||||
|
||||
// Trigger sendChatMessage to start streaming and capture callbacks
|
||||
await builderStore.sendChatMessage({ text: 'test message' });
|
||||
|
||||
// Simulate a workflow-updated message to indicate successful build
|
||||
if (capturedOnMessageCallback) {
|
||||
capturedOnMessageCallback({
|
||||
messages: [
|
||||
{
|
||||
type: 'workflow-updated',
|
||||
role: 'assistant',
|
||||
codeSnippet: '{"nodes":[],"connections":{}}',
|
||||
},
|
||||
],
|
||||
sessionId: 'test-session',
|
||||
});
|
||||
}
|
||||
|
||||
// Simulate successful completion by calling the done callback
|
||||
if (capturedDoneCallback) {
|
||||
capturedDoneCallback();
|
||||
}
|
||||
};
|
||||
|
||||
it('should show browser notification when streaming completes successfully and tab is hidden', async () => {
|
||||
Object.defineProperty(document, 'hidden', { value: true, configurable: true });
|
||||
mockIsNotificationsEnabled.value = true;
|
||||
|
||||
await triggerSuccessfulStreamingComplete();
|
||||
|
||||
expect(mockShowNotification).toHaveBeenCalledWith(
|
||||
'aiAssistant.builder.notification.title',
|
||||
expect.objectContaining({
|
||||
body: 'aiAssistant.builder.notification.body',
|
||||
icon: '/favicon.ico',
|
||||
requireInteraction: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT show browser notification when build is aborted', () => {
|
||||
Object.defineProperty(document, 'hidden', { value: true, configurable: true });
|
||||
mockIsNotificationsEnabled.value = true;
|
||||
|
||||
const builderStore = useBuilderStore();
|
||||
workflowsStore.workflowName = 'Test Workflow';
|
||||
|
||||
builderStore.streaming = true;
|
||||
builderStore.abortStreaming();
|
||||
|
||||
expect(mockShowNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should NOT show browser notification when tab is visible', async () => {
|
||||
Object.defineProperty(document, 'hidden', { value: false, configurable: true });
|
||||
mockIsNotificationsEnabled.value = true;
|
||||
|
||||
await triggerSuccessfulStreamingComplete();
|
||||
|
||||
expect(mockShowNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should NOT show browser notification when notifications are not enabled', async () => {
|
||||
Object.defineProperty(document, 'hidden', { value: true, configurable: true });
|
||||
mockIsNotificationsEnabled.value = false;
|
||||
|
||||
await triggerSuccessfulStreamingComplete();
|
||||
|
||||
expect(mockShowNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use workflow-specific tag to prevent duplicate notifications from the same workflow', async () => {
|
||||
Object.defineProperty(document, 'hidden', { value: true, configurable: true });
|
||||
mockIsNotificationsEnabled.value = true;
|
||||
|
||||
await triggerSuccessfulStreamingComplete();
|
||||
|
||||
expect(mockShowNotification).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
tag: 'workflow-build-test-workflow-123',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should set onclick handler that focuses window when notification is created', async () => {
|
||||
Object.defineProperty(document, 'hidden', { value: true, configurable: true });
|
||||
mockIsNotificationsEnabled.value = true;
|
||||
|
||||
const mockNotification = {
|
||||
onclick: null as ((ev: Event) => void) | null,
|
||||
close: vi.fn(),
|
||||
};
|
||||
mockShowNotification.mockReturnValue(mockNotification);
|
||||
|
||||
const windowFocusSpy = vi.spyOn(window, 'focus').mockImplementation(() => {});
|
||||
|
||||
await triggerSuccessfulStreamingComplete();
|
||||
|
||||
// Verify onclick handler was set
|
||||
expect(mockNotification.onclick).not.toBeNull();
|
||||
|
||||
// Simulate clicking the notification
|
||||
if (mockNotification.onclick) {
|
||||
mockNotification.onclick(new Event('click'));
|
||||
}
|
||||
|
||||
expect(windowFocusSpy).toHaveBeenCalled();
|
||||
expect(mockNotification.close).toHaveBeenCalled();
|
||||
|
||||
windowFocusSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,6 +39,7 @@ import type { IWorkflowDb } from '@/Interface';
|
||||
import { useWorkflowSaving } from '@/app/composables/useWorkflowSaving';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
|
||||
import { useBrowserNotifications } from '@/app/composables/useBrowserNotifications';
|
||||
|
||||
const INFINITE_CREDITS = -1;
|
||||
export const ENABLED_VIEWS = BUILDER_ENABLED_VIEWS;
|
||||
@@ -50,7 +51,11 @@ export type WorkflowBuilderJourneyEventType =
|
||||
| 'user_clicked_todo'
|
||||
| 'field_focus_placeholder_in_ndv'
|
||||
| 'no_placeholder_values_left'
|
||||
| 'revert_version_from_builder';
|
||||
| 'revert_version_from_builder'
|
||||
| 'browser_notification_ask_permission'
|
||||
| 'browser_notification_accept'
|
||||
| 'browser_notification_dismiss'
|
||||
| 'browser_generation_done_notified';
|
||||
|
||||
interface WorkflowBuilderJourneyEventProperties {
|
||||
node_type?: string;
|
||||
@@ -58,6 +63,7 @@ interface WorkflowBuilderJourneyEventProperties {
|
||||
revert_user_message_id?: string;
|
||||
revert_version_id?: string;
|
||||
no_versions_reverted?: number;
|
||||
completion_type?: 'workflow-ready' | 'input-needed';
|
||||
}
|
||||
|
||||
interface WorkflowBuilderJourneyPayload extends ITelemetryTrackProperties {
|
||||
@@ -247,12 +253,69 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
|
||||
user_message_id: userMessageId,
|
||||
workflow_id: workflowsStore.workflowId,
|
||||
session_id: trackingSessionId.value,
|
||||
tab_visible: document.visibilityState === 'visible',
|
||||
...getWorkflowModifications(currentStreamingMessage.value),
|
||||
...payload,
|
||||
...getTodosToTrack(),
|
||||
});
|
||||
}
|
||||
|
||||
type CompletionType = 'workflow-ready' | 'input-needed';
|
||||
|
||||
/**
|
||||
* Checks if the current streaming response included a workflow update.
|
||||
* Used to determine whether to show "workflow ready" or "input needed" notification.
|
||||
*/
|
||||
function hasWorkflowUpdateInCurrentBatch(userMessageId: string): boolean {
|
||||
return chatMessages.value.some(
|
||||
(msg) => msg.type === 'workflow-updated' && msg.id?.startsWith(userMessageId),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a browser notification when the AI builder completes.
|
||||
* Only shows if browser notifications are enabled.
|
||||
* Clicking the notification focuses the window and closes it.
|
||||
*/
|
||||
function notifyOnCompletion(completionType: CompletionType) {
|
||||
const { showNotification, isEnabled } = useBrowserNotifications();
|
||||
if (!isEnabled.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workflowName = workflowsStore.workflowName;
|
||||
|
||||
const titleKey =
|
||||
completionType === 'workflow-ready'
|
||||
? 'aiAssistant.builder.notification.title'
|
||||
: 'aiAssistant.builder.notification.inputNeeded.title';
|
||||
|
||||
const bodyKey =
|
||||
completionType === 'workflow-ready'
|
||||
? 'aiAssistant.builder.notification.body'
|
||||
: 'aiAssistant.builder.notification.inputNeeded.body';
|
||||
|
||||
const notification = showNotification(locale.baseText(titleKey), {
|
||||
body: locale.baseText(bodyKey, {
|
||||
interpolate: { workflowName },
|
||||
}),
|
||||
icon: '/favicon.ico',
|
||||
tag: `workflow-build-${workflowsStore.workflowId}`,
|
||||
requireInteraction: false,
|
||||
});
|
||||
|
||||
if (notification) {
|
||||
trackWorkflowBuilderJourney('browser_generation_done_notified', {
|
||||
completion_type: completionType,
|
||||
});
|
||||
|
||||
notification.onclick = () => {
|
||||
window.focus();
|
||||
notification.close();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function stopStreaming(payload?: StopStreamingPayload) {
|
||||
streaming.value = false;
|
||||
if (streamingAbortController.value) {
|
||||
@@ -261,11 +324,23 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
|
||||
}
|
||||
|
||||
trackEndBuilderResponse(payload);
|
||||
|
||||
// Capture userMessageId before clearing currentStreamingMessage
|
||||
const userMessageId = currentStreamingMessage.value?.userMessageId;
|
||||
currentStreamingMessage.value = undefined;
|
||||
|
||||
const wasAborted = payload && 'aborted' in payload && payload.aborted;
|
||||
|
||||
// Update page title on completion. We show Done when the user is not on the page
|
||||
// Browser notifications are only shown when the tab is hidden
|
||||
if (document.hidden) {
|
||||
documentTitle.setDocumentTitle(workflowsStore.workflowName, 'AI_DONE');
|
||||
if (!wasAborted && userMessageId) {
|
||||
const completionType = hasWorkflowUpdateInCurrentBatch(userMessageId)
|
||||
? 'workflow-ready'
|
||||
: 'input-needed';
|
||||
notifyOnCompletion(completionType);
|
||||
}
|
||||
} else {
|
||||
documentTitle.setDocumentTitle(workflowsStore.workflowName, 'IDLE');
|
||||
}
|
||||
|
||||
+107
@@ -45,6 +45,17 @@ vi.mock('./ExecuteMessage.vue', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock NotificationPermissionBanner component
|
||||
vi.mock('./NotificationPermissionBanner.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'NotificationPermissionBanner',
|
||||
setup() {
|
||||
return () =>
|
||||
h('div', { 'data-test-id': 'notification-permission-banner' }, 'Notification Banner');
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock AskAssistantChat component
|
||||
vi.mock('@n8n/design-system/components/AskAssistantChat/AskAssistantChat.vue', () => ({
|
||||
default: defineComponent({
|
||||
@@ -98,6 +109,8 @@ vi.mock('@n8n/design-system/components/AskAssistantChat/AskAssistantChat.vue', (
|
||||
}),
|
||||
]
|
||||
: null,
|
||||
// Render inputHeader slot if it exists (for notification banner)
|
||||
slots.inputHeader?.(),
|
||||
// Render messagesFooter slot if it exists
|
||||
slots.messagesFooter?.(),
|
||||
]);
|
||||
@@ -180,6 +193,16 @@ vi.mock('@/app/composables/usePageRedirectionHelper', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useBrowserNotifications
|
||||
const mockCanPrompt = { value: true };
|
||||
vi.mock('@/app/composables/useBrowserNotifications', () => ({
|
||||
useBrowserNotifications: () => ({
|
||||
canPrompt: mockCanPrompt,
|
||||
requestPermission: vi.fn().mockResolvedValue({ permission: 'granted', wasRequested: true }),
|
||||
recordDismissal: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useDocumentVisibility
|
||||
let onDocumentVisibleCallback: (() => void) | null = null;
|
||||
vi.mock('@/app/composables/useDocumentVisibility', () => ({
|
||||
@@ -208,6 +231,7 @@ describe('AskAssistantBuild', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
onDocumentVisibleCallback = null;
|
||||
mockCanPrompt.value = true;
|
||||
|
||||
// Reset the updateWorkflow mock before each test
|
||||
updateWorkflowMock.mockReset();
|
||||
@@ -1460,4 +1484,87 @@ describe('AskAssistantBuild', () => {
|
||||
expect(builderStore.clearDoneIndicatorTitle).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('notification banner visibility', () => {
|
||||
it('should not show notification banner before streaming starts', async () => {
|
||||
builderStore.$patch({
|
||||
streaming: false,
|
||||
chatMessages: [],
|
||||
});
|
||||
mockCanPrompt.value = true;
|
||||
|
||||
const { queryByTestId } = renderComponent();
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(queryByTestId('notification-permission-banner')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show notification banner when streaming starts and canPrompt is true', async () => {
|
||||
mockCanPrompt.value = true;
|
||||
|
||||
const { queryByTestId } = renderComponent();
|
||||
|
||||
// Start streaming
|
||||
builderStore.$patch({ streaming: true });
|
||||
await flushPromises();
|
||||
|
||||
expect(queryByTestId('notification-permission-banner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show notification banner when streaming starts but canPrompt is false', async () => {
|
||||
mockCanPrompt.value = false;
|
||||
|
||||
const { queryByTestId } = renderComponent();
|
||||
|
||||
// Start streaming
|
||||
builderStore.$patch({ streaming: true });
|
||||
await flushPromises();
|
||||
|
||||
expect(queryByTestId('notification-permission-banner')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should keep notification banner visible after streaming ends', async () => {
|
||||
mockCanPrompt.value = true;
|
||||
|
||||
const { queryByTestId } = renderComponent();
|
||||
|
||||
// Start streaming - banner should appear
|
||||
builderStore.$patch({ streaming: true });
|
||||
await flushPromises();
|
||||
|
||||
expect(queryByTestId('notification-permission-banner')).toBeInTheDocument();
|
||||
|
||||
// End streaming - banner should remain visible
|
||||
builderStore.$patch({ streaming: false });
|
||||
await flushPromises();
|
||||
|
||||
expect(queryByTestId('notification-permission-banner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show notification banner for existing chat sessions without streaming', async () => {
|
||||
// Simulate returning to an existing chat session with messages but no streaming
|
||||
builderStore.$patch({
|
||||
streaming: false,
|
||||
chatMessages: [
|
||||
{ id: '1', role: 'user', type: 'text', content: 'Create a workflow' },
|
||||
{
|
||||
id: '2',
|
||||
role: 'assistant',
|
||||
type: 'workflow-updated',
|
||||
codeSnippet: JSON.stringify({ nodes: [], connections: {} }),
|
||||
},
|
||||
],
|
||||
});
|
||||
builderStore.hasMessages = true;
|
||||
mockCanPrompt.value = true;
|
||||
|
||||
const { queryByTestId } = renderComponent();
|
||||
|
||||
await flushPromises();
|
||||
|
||||
// Banner should NOT be shown since streaming hasn't started in this session
|
||||
expect(queryByTestId('notification-permission-banner')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+42
@@ -15,7 +15,9 @@ import type { RatingFeedback, WorkflowSuggestion } from '@n8n/design-system/type
|
||||
import { isTaskAbortedMessage, isWorkflowUpdatedMessage } from '@n8n/design-system/types/assistant';
|
||||
import { nodeViewEventBus } from '@/app/event-bus';
|
||||
import ExecuteMessage from './ExecuteMessage.vue';
|
||||
import NotificationPermissionBanner from './NotificationPermissionBanner.vue';
|
||||
import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper';
|
||||
import { useBrowserNotifications } from '@/app/composables/useBrowserNotifications';
|
||||
import { useToast } from '@/app/composables/useToast';
|
||||
import { useDocumentVisibility } from '@/app/composables/useDocumentVisibility';
|
||||
import { WORKFLOW_SUGGESTIONS } from '@/app/constants/workflowSuggestions';
|
||||
@@ -51,6 +53,7 @@ const { handleError } = useErrorHandler({
|
||||
titleKey: 'aiAssistant.builder.error.title',
|
||||
});
|
||||
const { onDocumentVisible } = useDocumentVisibility();
|
||||
const { canPrompt } = useBrowserNotifications();
|
||||
|
||||
onDocumentVisible(() => {
|
||||
builderStore.clearDoneIndicatorTitle();
|
||||
@@ -61,6 +64,27 @@ const processedWorkflowUpdates = ref(new Set<string>());
|
||||
const accumulatedNodeIdsToTidyUp = ref<string[]>([]);
|
||||
const n8nChatRef = ref<InstanceType<typeof N8nAskAssistantChat>>();
|
||||
|
||||
const notificationsPermissionsBannerTriggered = ref(false);
|
||||
|
||||
watch(
|
||||
() => builderStore.streaming,
|
||||
(isStreaming) => {
|
||||
if (isStreaming && canPrompt.value) {
|
||||
notificationsPermissionsBannerTriggered.value = true;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const shouldShowNotificationBanner = computed(() => {
|
||||
return notificationsPermissionsBannerTriggered.value && canPrompt.value;
|
||||
});
|
||||
|
||||
watch(shouldShowNotificationBanner, (isShown) => {
|
||||
if (isShown) {
|
||||
builderStore.trackWorkflowBuilderJourney('browser_notification_ask_permission');
|
||||
}
|
||||
});
|
||||
|
||||
const user = computed(() => ({
|
||||
firstName: usersStore.currentUser?.firstName ?? '',
|
||||
lastName: usersStore.currentUser?.lastName ?? '',
|
||||
@@ -153,6 +177,7 @@ function onNewWorkflow() {
|
||||
builderStore.resetBuilderChat();
|
||||
processedWorkflowUpdates.value.clear();
|
||||
accumulatedNodeIdsToTidyUp.value = [];
|
||||
notificationsPermissionsBannerTriggered.value = false;
|
||||
}
|
||||
|
||||
function onFeedback(feedback: RatingFeedback) {
|
||||
@@ -387,6 +412,11 @@ defineExpose({
|
||||
<template #header>
|
||||
<slot name="header" />
|
||||
</template>
|
||||
<template #inputHeader>
|
||||
<Transition name="slide">
|
||||
<NotificationPermissionBanner v-if="shouldShowNotificationBanner" />
|
||||
</Transition>
|
||||
</template>
|
||||
<template #messagesFooter>
|
||||
<ExecuteMessage v-if="showExecuteMessage" @workflow-executed="onWorkflowExecuted" />
|
||||
</template>
|
||||
@@ -399,6 +429,18 @@ defineExpose({
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: transform var(--animation--duration) var(--animation--easing);
|
||||
}
|
||||
|
||||
.slide-enter-from,
|
||||
.slide-leave-to {
|
||||
transform: translateY(8px);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
height: 100%;
|
||||
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import NotificationPermissionBanner from './NotificationPermissionBanner.vue';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
|
||||
// Mock useBrowserNotifications
|
||||
const mockRequestPermission = vi.fn();
|
||||
const mockRecordDismissal = vi.fn();
|
||||
const mockResetMetadata = vi.fn();
|
||||
|
||||
vi.mock('@/app/composables/useBrowserNotifications', () => ({
|
||||
useBrowserNotifications: () => ({
|
||||
requestPermission: mockRequestPermission,
|
||||
recordDismissal: mockRecordDismissal,
|
||||
resetMetadata: mockResetMetadata,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock i18n
|
||||
vi.mock('@n8n/i18n', () => ({
|
||||
useI18n: () => ({
|
||||
baseText: (key: string) => key,
|
||||
}),
|
||||
i18n: {
|
||||
baseText: (key: string) => key,
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock builder store
|
||||
const mockTrackWorkflowBuilderJourney = vi.fn();
|
||||
|
||||
vi.mock('../../builder.store', () => ({
|
||||
useBuilderStore: () => ({
|
||||
trackWorkflowBuilderJourney: mockTrackWorkflowBuilderJourney,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('NotificationPermissionBanner', () => {
|
||||
beforeEach(() => {
|
||||
mockRequestPermission.mockReset();
|
||||
mockRequestPermission.mockResolvedValue({ permission: 'granted', wasRequested: true });
|
||||
mockRecordDismissal.mockClear();
|
||||
mockResetMetadata.mockClear();
|
||||
mockTrackWorkflowBuilderJourney.mockClear();
|
||||
});
|
||||
|
||||
const mountComponent = () => {
|
||||
return mount(NotificationPermissionBanner, {
|
||||
global: {
|
||||
plugins: [createTestingPinia()],
|
||||
stubs: {
|
||||
N8nIcon: {
|
||||
template: '<span class="n8n-icon" :data-icon="icon" @click="$emit(\'click\')"></span>',
|
||||
props: ['icon', 'size'],
|
||||
},
|
||||
N8nButton: {
|
||||
template: '<button class="n8n-button" @click="$emit(\'click\')"><slot /></button>',
|
||||
props: ['type', 'size'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
it('renders the banner with correct elements', () => {
|
||||
const wrapper = mountComponent();
|
||||
|
||||
expect(wrapper.find('[data-test-id="notification-permission-banner"]').exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain('aiAssistant.builder.notificationBanner.text');
|
||||
expect(wrapper.text()).toContain('aiAssistant.builder.notificationBanner.notify');
|
||||
});
|
||||
|
||||
it('renders bell icon', () => {
|
||||
const wrapper = mountComponent();
|
||||
|
||||
const bellIcon = wrapper.find('[data-icon="bell"]');
|
||||
expect(bellIcon.exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('renders close icon', () => {
|
||||
const wrapper = mountComponent();
|
||||
|
||||
const closeIcon = wrapper.find('[data-test-id="notification-banner-dismiss"]');
|
||||
expect(closeIcon.exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('renders notify button', () => {
|
||||
const wrapper = mountComponent();
|
||||
|
||||
const notifyButton = wrapper.find('[data-test-id="notification-banner-notify"]');
|
||||
expect(notifyButton.exists()).toBe(true);
|
||||
});
|
||||
|
||||
describe('interactions', () => {
|
||||
it('calls requestPermission when notify button is clicked', async () => {
|
||||
const wrapper = mountComponent();
|
||||
|
||||
const notifyButton = wrapper.find('[data-test-id="notification-banner-notify"]');
|
||||
await notifyButton.trigger('click');
|
||||
|
||||
expect(mockRequestPermission).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls recordDismissal when close icon is clicked', async () => {
|
||||
const wrapper = mountComponent();
|
||||
|
||||
const closeIcon = wrapper.find('[data-test-id="notification-banner-dismiss"]');
|
||||
await closeIcon.trigger('click');
|
||||
|
||||
expect(mockRecordDismissal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call recordDismissal when notify button is clicked', async () => {
|
||||
const wrapper = mountComponent();
|
||||
|
||||
const notifyButton = wrapper.find('[data-test-id="notification-banner-notify"]');
|
||||
await notifyButton.trigger('click');
|
||||
|
||||
expect(mockRecordDismissal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call requestPermission when close icon is clicked', async () => {
|
||||
const wrapper = mountComponent();
|
||||
|
||||
const closeIcon = wrapper.find('[data-test-id="notification-banner-dismiss"]');
|
||||
await closeIcon.trigger('click');
|
||||
|
||||
expect(mockRequestPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tracks browser_notification_accept when notify button is clicked', async () => {
|
||||
const wrapper = mountComponent();
|
||||
|
||||
const notifyButton = wrapper.find('[data-test-id="notification-banner-notify"]');
|
||||
await notifyButton.trigger('click');
|
||||
|
||||
expect(mockTrackWorkflowBuilderJourney).toHaveBeenCalledWith('browser_notification_accept');
|
||||
});
|
||||
|
||||
it('tracks browser_notification_dismiss when close icon is clicked', async () => {
|
||||
const wrapper = mountComponent();
|
||||
|
||||
const closeIcon = wrapper.find('[data-test-id="notification-banner-dismiss"]');
|
||||
await closeIcon.trigger('click');
|
||||
|
||||
expect(mockTrackWorkflowBuilderJourney).toHaveBeenCalledWith('browser_notification_dismiss');
|
||||
});
|
||||
|
||||
it('calls resetMetadata when permission is granted', async () => {
|
||||
mockRequestPermission.mockResolvedValueOnce({ permission: 'granted', wasRequested: true });
|
||||
const wrapper = mountComponent();
|
||||
|
||||
const notifyButton = wrapper.find('[data-test-id="notification-banner-notify"]');
|
||||
await notifyButton.trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(mockResetMetadata).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls resetMetadata when permission is denied', async () => {
|
||||
mockRequestPermission.mockResolvedValueOnce({ permission: 'denied', wasRequested: true });
|
||||
const wrapper = mountComponent();
|
||||
|
||||
const notifyButton = wrapper.find('[data-test-id="notification-banner-notify"]');
|
||||
await notifyButton.trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(mockResetMetadata).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call resetMetadata when permission is default', async () => {
|
||||
mockRequestPermission.mockResolvedValue({ permission: 'default', wasRequested: false });
|
||||
const wrapper = mountComponent();
|
||||
|
||||
const notifyButton = wrapper.find('[data-test-id="notification-banner-notify"]');
|
||||
await notifyButton.trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(mockResetMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
<script lang="ts" setup>
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { N8nIcon, N8nButton } from '@n8n/design-system';
|
||||
import { useBrowserNotifications } from '@/app/composables/useBrowserNotifications';
|
||||
import { useBuilderStore } from '../../builder.store';
|
||||
|
||||
const { requestPermission, recordDismissal, resetMetadata } = useBrowserNotifications();
|
||||
const i18n = useI18n();
|
||||
const builderStore = useBuilderStore();
|
||||
|
||||
async function onNotifyClick() {
|
||||
const { permission } = await requestPermission();
|
||||
|
||||
if (permission === 'denied' || permission === 'granted') {
|
||||
resetMetadata();
|
||||
}
|
||||
|
||||
builderStore.trackWorkflowBuilderJourney('browser_notification_accept');
|
||||
}
|
||||
|
||||
function onDismissClick() {
|
||||
builderStore.trackWorkflowBuilderJourney('browser_notification_dismiss');
|
||||
recordDismissal();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.banner" data-test-id="notification-permission-banner">
|
||||
<N8nIcon icon="bell" size="medium" :class="$style.icon" />
|
||||
<span :class="$style.text">
|
||||
{{ i18n.baseText('aiAssistant.builder.notificationBanner.text') }}
|
||||
</span>
|
||||
<N8nButton
|
||||
type="primary"
|
||||
size="mini"
|
||||
data-test-id="notification-banner-notify"
|
||||
@click="onNotifyClick"
|
||||
>
|
||||
{{ i18n.baseText('aiAssistant.builder.notificationBanner.notify') }}
|
||||
</N8nButton>
|
||||
<N8nIcon
|
||||
icon="x"
|
||||
size="small"
|
||||
:class="$style.closeIcon"
|
||||
data-test-id="notification-banner-dismiss"
|
||||
@click="onDismissClick"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--xs);
|
||||
padding: var(--spacing--sm) var(--spacing--xs);
|
||||
background: var(--callout--color--background--info);
|
||||
border: 1px solid var(--callout--border-color--info);
|
||||
border-radius: var(--radius--lg) var(--radius--lg) 0 0;
|
||||
border-bottom: none;
|
||||
margin: 0 var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.icon {
|
||||
align-self: flex-start;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: var(--callout--color--text--info);
|
||||
font-size: var(--font-size--2xs);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.closeIcon {
|
||||
color: var(--callout--color--text--info);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user