feat(editor): Add native browser notifications on AI workflow builder completion or waiting for input (#24224)

This commit is contained in:
Albert Alises
2026-01-14 13:23:54 +00:00
committed by GitHub
parent bd111bfff0
commit 5ba0bf78c1
12 changed files with 1294 additions and 4 deletions
@@ -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;
@@ -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');
}
@@ -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();
});
});
});
@@ -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%;
@@ -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();
});
});
});
@@ -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>