From 5ba0bf78c1955dc22e6f8ffbde3d5ce22ac10935 Mon Sep 17 00:00:00 2001 From: Albert Alises Date: Wed, 14 Jan 2026 14:23:54 +0100 Subject: [PATCH] feat(editor): Add native browser notifications on AI workflow builder completion or waiting for input (#24224) --- .../AskAssistantChat/AskAssistantChat.vue | 30 +- .../AskAssistantChat.test.ts.snap | 7 + .../frontend/@n8n/i18n/src/locales/en.json | 18 +- .../useBrowserNotifications.test.ts | 416 ++++++++++++++++++ .../composables/useBrowserNotifications.ts | 164 +++++++ .../src/app/constants/localStorage.ts | 1 + .../ai/assistant/builder.store.test.ts | 170 +++++++ .../features/ai/assistant/builder.store.ts | 77 +++- .../Agent/AskAssistantBuild.test.ts | 107 +++++ .../components/Agent/AskAssistantBuild.vue | 42 ++ .../NotificationPermissionBanner.test.ts | 182 ++++++++ .../Agent/NotificationPermissionBanner.vue | 84 ++++ 12 files changed, 1294 insertions(+), 4 deletions(-) create mode 100644 packages/frontend/editor-ui/src/app/composables/useBrowserNotifications.test.ts create mode 100644 packages/frontend/editor-ui/src/app/composables/useBrowserNotifications.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/NotificationPermissionBanner.test.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/NotificationPermissionBanner.vue diff --git a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.vue b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.vue index 07391212145..3a666233971 100644 --- a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.vue +++ b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.vue @@ -534,10 +534,17 @@ defineExpose({ +
+ +
@@ -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; diff --git a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/__snapshots__/AskAssistantChat.test.ts.snap b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/__snapshots__/AskAssistantChat.test.ts.snap index a29834b2507..14089aee74c 100644 --- a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/__snapshots__/AskAssistantChat.test.ts.snap +++ b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/__snapshots__/AskAssistantChat.test.ts.snap @@ -81,6 +81,7 @@ exports[`AskAssistantChat > limits maximum input length when maxCharacterLength
+
renders chat with messages correctly 1`] = `
+
renders default placeholder chat correctly 1`] = `
+
renders end of session chat correctly 1`] = `
+
renders error message correctly with retry button 1`
+
renders message with code snippet 1`] = `
+
renders streaming chat correctly 1`] = `
+
({ + 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); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/app/composables/useBrowserNotifications.ts b/packages/frontend/editor-ui/src/app/composables/useBrowserNotifications.ts new file mode 100644 index 00000000000..277e38e14fa --- /dev/null +++ b/packages/frontend/editor-ui/src/app/composables/useBrowserNotifications.ts @@ -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( + 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( + 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 { + 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), + }; +} diff --git a/packages/frontend/editor-ui/src/app/constants/localStorage.ts b/packages/frontend/editor-ui/src/app/constants/localStorage.ts index 68b5081e637..ce0a01a4186 100644 --- a/packages/frontend/editor-ui/src/app/constants/localStorage.ts +++ b/packages/frontend/editor-ui/src/app/constants/localStorage.ts @@ -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'; diff --git a/packages/frontend/editor-ui/src/features/ai/assistant/builder.store.test.ts b/packages/frontend/editor-ui/src/features/ai/assistant/builder.store.test.ts index 4c5be201313..bb969bc0cc5 100644 --- a/packages/frontend/editor-ui/src/features/ai/assistant/builder.store.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/assistant/builder.store.test.ts @@ -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; let posthogStore: ReturnType; let workflowsStore: ReturnType>; @@ -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(); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/features/ai/assistant/builder.store.ts b/packages/frontend/editor-ui/src/features/ai/assistant/builder.store.ts index 82fdb92e21a..e91cc15493c 100644 --- a/packages/frontend/editor-ui/src/features/ai/assistant/builder.store.ts +++ b/packages/frontend/editor-ui/src/features/ai/assistant/builder.store.ts @@ -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'); } diff --git a/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/AskAssistantBuild.test.ts b/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/AskAssistantBuild.test.ts index 5882618ac78..863207ce36f 100644 --- a/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/AskAssistantBuild.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/AskAssistantBuild.test.ts @@ -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(); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/AskAssistantBuild.vue b/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/AskAssistantBuild.vue index 08ec6483dfa..e4bb29636c3 100644 --- a/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/AskAssistantBuild.vue +++ b/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/AskAssistantBuild.vue @@ -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()); const accumulatedNodeIdsToTidyUp = ref([]); const n8nChatRef = ref>(); +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({ + @@ -399,6 +429,18 @@ defineExpose({
+ +