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({
+
({
+ 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({
+
+