mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-01 15:47:41 +08:00
feat(editor): Show building and done status in page title for AI builder (#23987)
This commit is contained in:
@@ -430,7 +430,13 @@ export interface ITimeoutHMS {
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
export type WorkflowTitleStatus = 'EXECUTING' | 'IDLE' | 'ERROR' | 'DEBUG';
|
||||
export type WorkflowTitleStatus =
|
||||
| 'EXECUTING'
|
||||
| 'IDLE'
|
||||
| 'ERROR'
|
||||
| 'DEBUG'
|
||||
| 'AI_BUILDING'
|
||||
| 'AI_DONE';
|
||||
|
||||
export type ExtractActionKeys<T> = T extends SimplifiedNodeType ? T['name'] : never;
|
||||
|
||||
|
||||
@@ -28,4 +28,86 @@ describe('useDocumentTitle', () => {
|
||||
set('Test Title');
|
||||
expect(document.title).toBe('Test Title - n8n[BETA]');
|
||||
});
|
||||
|
||||
describe('setDocumentTitle', () => {
|
||||
beforeEach(() => {
|
||||
settings.releaseChannel = 'stable';
|
||||
});
|
||||
|
||||
it('should set document title with IDLE status', () => {
|
||||
const { setDocumentTitle } = useDocumentTitle();
|
||||
setDocumentTitle('My Workflow', 'IDLE');
|
||||
expect(document.title).toBe('▶️ My Workflow - n8n');
|
||||
});
|
||||
|
||||
it('should set document title with EXECUTING status', () => {
|
||||
const { setDocumentTitle } = useDocumentTitle();
|
||||
setDocumentTitle('My Workflow', 'EXECUTING');
|
||||
expect(document.title).toBe('🔄 My Workflow - n8n');
|
||||
});
|
||||
|
||||
it('should set document title with ERROR status', () => {
|
||||
const { setDocumentTitle } = useDocumentTitle();
|
||||
setDocumentTitle('My Workflow', 'ERROR');
|
||||
expect(document.title).toBe('⚠️ My Workflow - n8n');
|
||||
});
|
||||
|
||||
it('should set document title with DEBUG status', () => {
|
||||
const { setDocumentTitle } = useDocumentTitle();
|
||||
setDocumentTitle('My Workflow', 'DEBUG');
|
||||
expect(document.title).toBe('⚠️ My Workflow - n8n');
|
||||
});
|
||||
|
||||
it('should set document title with AI_BUILDING status', () => {
|
||||
const { setDocumentTitle } = useDocumentTitle();
|
||||
setDocumentTitle('My Workflow', 'AI_BUILDING');
|
||||
expect(document.title).toBe('[Building] My Workflow - n8n');
|
||||
});
|
||||
|
||||
it('should set document title with AI_DONE status', () => {
|
||||
const { setDocumentTitle } = useDocumentTitle();
|
||||
setDocumentTitle('My Workflow', 'AI_DONE');
|
||||
expect(document.title).toBe('[Done] My Workflow - n8n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDocumentState', () => {
|
||||
beforeEach(() => {
|
||||
settings.releaseChannel = 'stable';
|
||||
});
|
||||
|
||||
it('should return undefined initially', () => {
|
||||
const { getDocumentState } = useDocumentTitle();
|
||||
expect(getDocumentState()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return the current state after setDocumentTitle is called', () => {
|
||||
const { setDocumentTitle, getDocumentState } = useDocumentTitle();
|
||||
setDocumentTitle('My Workflow', 'AI_BUILDING');
|
||||
expect(getDocumentState()).toBe('AI_BUILDING');
|
||||
});
|
||||
|
||||
it('should track state changes', () => {
|
||||
const { setDocumentTitle, getDocumentState } = useDocumentTitle();
|
||||
|
||||
setDocumentTitle('My Workflow', 'IDLE');
|
||||
expect(getDocumentState()).toBe('IDLE');
|
||||
|
||||
setDocumentTitle('My Workflow', 'AI_BUILDING');
|
||||
expect(getDocumentState()).toBe('AI_BUILDING');
|
||||
|
||||
setDocumentTitle('My Workflow', 'AI_DONE');
|
||||
expect(getDocumentState()).toBe('AI_DONE');
|
||||
});
|
||||
|
||||
it('should return undefined after reset is called', () => {
|
||||
const { setDocumentTitle, getDocumentState, reset } = useDocumentTitle();
|
||||
|
||||
setDocumentTitle('My Workflow', 'AI_DONE');
|
||||
expect(getDocumentState()).toBe('AI_DONE');
|
||||
|
||||
reset();
|
||||
expect(getDocumentState()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { WorkflowTitleStatus } from '@/Interface';
|
||||
import { useSettingsStore } from '@/app/stores/settings.store';
|
||||
import type { Ref } from 'vue';
|
||||
import { ref, type Ref } from 'vue';
|
||||
|
||||
const DEFAULT_TITLE = 'n8n';
|
||||
const DEFAULT_TAGLINE = 'Workflow Automation';
|
||||
@@ -13,24 +13,34 @@ export function useDocumentTitle(windowRef?: Ref<Window | undefined>) {
|
||||
? DEFAULT_TITLE
|
||||
: `${DEFAULT_TITLE}[${releaseChannel.toUpperCase()}]`;
|
||||
|
||||
const currentState = ref<WorkflowTitleStatus | undefined>(undefined);
|
||||
|
||||
const set = (title: string) => {
|
||||
const sections = [title || DEFAULT_TAGLINE, suffix];
|
||||
(windowRef?.value?.document ?? document).title = sections.join(' - ');
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
currentState.value = undefined;
|
||||
set('');
|
||||
};
|
||||
|
||||
const setDocumentTitle = (workflowName: string, status: WorkflowTitleStatus) => {
|
||||
let icon = '⚠️';
|
||||
currentState.value = status;
|
||||
let prefix = '⚠️';
|
||||
if (status === 'EXECUTING') {
|
||||
icon = '🔄';
|
||||
prefix = '🔄';
|
||||
} else if (status === 'IDLE') {
|
||||
icon = '▶️';
|
||||
prefix = '▶️';
|
||||
} else if (status === 'AI_BUILDING') {
|
||||
prefix = '[Building]';
|
||||
} else if (status === 'AI_DONE') {
|
||||
prefix = '[Done]';
|
||||
}
|
||||
set(`${icon} ${workflowName}`);
|
||||
set(`${prefix} ${workflowName}`);
|
||||
};
|
||||
|
||||
return { set, reset, setDocumentTitle };
|
||||
const getDocumentState = () => currentState.value;
|
||||
|
||||
return { set, reset, setDocumentTitle, getDocumentState };
|
||||
}
|
||||
|
||||
@@ -534,7 +534,12 @@ function updateNodesIssues() {
|
||||
|
||||
async function openWorkflow(data: IWorkflowDb) {
|
||||
resetWorkspace();
|
||||
documentTitle.setDocumentTitle(data.name, 'IDLE');
|
||||
// Show AI_BUILDING status if builder is actively streaming, otherwise IDLE
|
||||
if (builderStore.streaming) {
|
||||
documentTitle.setDocumentTitle(data.name, 'AI_BUILDING');
|
||||
} else {
|
||||
documentTitle.setDocumentTitle(data.name, 'IDLE');
|
||||
}
|
||||
|
||||
await initializeWorkspace(data);
|
||||
|
||||
@@ -1504,7 +1509,6 @@ async function onSourceControlPull() {
|
||||
if (workflowId.value && !uiStore.stateIsDirty) {
|
||||
const workflowData = await workflowsStore.fetchWorkflow(workflowId.value);
|
||||
if (workflowData) {
|
||||
documentTitle.setDocumentTitle(workflowData.name, 'IDLE');
|
||||
await openWorkflow(workflowData);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,21 @@ vi.mock('@/app/composables/useWorkflowSaving', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useDocumentTitle
|
||||
let mockDocumentState: string | undefined;
|
||||
const setDocumentTitleMock = vi.fn((_workflowName: string, state: string) => {
|
||||
mockDocumentState = state;
|
||||
});
|
||||
const getDocumentStateMock = vi.fn(() => mockDocumentState);
|
||||
vi.mock('@/app/composables/useDocumentTitle', () => ({
|
||||
useDocumentTitle: () => ({
|
||||
set: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
setDocumentTitle: setDocumentTitleMock,
|
||||
getDocumentState: getDocumentStateMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
let settingsStore: ReturnType<typeof useSettingsStore>;
|
||||
let posthogStore: ReturnType<typeof usePostHog>;
|
||||
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
|
||||
@@ -121,6 +136,7 @@ let workflowState: WorkflowState;
|
||||
describe('AI Builder store', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockDocumentState = undefined;
|
||||
pinia = createTestingPinia({ stubActions: false });
|
||||
setActivePinia(pinia);
|
||||
settingsStore = useSettingsStore();
|
||||
@@ -2742,4 +2758,77 @@ describe('AI Builder store', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Page title status', () => {
|
||||
it('should set title to AI_BUILDING when streaming starts', async () => {
|
||||
const builderStore = useBuilderStore();
|
||||
workflowsStore.workflowName = 'Test Workflow';
|
||||
|
||||
// Mock the API to prevent actual calls
|
||||
apiSpy.mockImplementation(() => {});
|
||||
|
||||
await builderStore.sendChatMessage({ text: 'Build something' });
|
||||
|
||||
expect(setDocumentTitleMock).toHaveBeenCalledWith('Test Workflow', 'AI_BUILDING');
|
||||
});
|
||||
|
||||
it('should set title to AI_DONE when streaming stops and tab is hidden', () => {
|
||||
Object.defineProperty(document, 'hidden', { value: true, configurable: true });
|
||||
|
||||
const builderStore = useBuilderStore();
|
||||
workflowsStore.workflowName = 'Test Workflow';
|
||||
|
||||
// Start streaming first
|
||||
builderStore.streaming = true;
|
||||
|
||||
// Trigger abortStreaming which calls stopStreaming internally
|
||||
builderStore.abortStreaming();
|
||||
|
||||
expect(setDocumentTitleMock).toHaveBeenCalledWith('Test Workflow', 'AI_DONE');
|
||||
});
|
||||
|
||||
it('should set title to IDLE when streaming stops and tab is visible', () => {
|
||||
Object.defineProperty(document, 'hidden', { value: false, configurable: true });
|
||||
|
||||
const builderStore = useBuilderStore();
|
||||
workflowsStore.workflowName = 'Test Workflow';
|
||||
|
||||
// Start streaming first
|
||||
builderStore.streaming = true;
|
||||
|
||||
// Trigger abortStreaming which calls stopStreaming internally
|
||||
builderStore.abortStreaming();
|
||||
|
||||
expect(setDocumentTitleMock).toHaveBeenCalledWith('Test Workflow', 'IDLE');
|
||||
});
|
||||
|
||||
it('should reset title to IDLE when clearDoneIndicatorTitle is called and indicator is showing', () => {
|
||||
Object.defineProperty(document, 'hidden', { value: true, configurable: true });
|
||||
|
||||
const builderStore = useBuilderStore();
|
||||
workflowsStore.workflowName = 'Test Workflow';
|
||||
|
||||
builderStore.streaming = true;
|
||||
builderStore.abortStreaming();
|
||||
|
||||
setDocumentTitleMock.mockClear();
|
||||
|
||||
builderStore.clearDoneIndicatorTitle();
|
||||
|
||||
expect(setDocumentTitleMock).toHaveBeenCalledWith('Test Workflow', 'IDLE');
|
||||
});
|
||||
|
||||
it('should not change title when clearDoneIndicatorTitle is called and indicator is not showing', () => {
|
||||
Object.defineProperty(document, 'hidden', { value: false, configurable: true });
|
||||
|
||||
const builderStore = useBuilderStore();
|
||||
workflowsStore.workflowName = 'Test Workflow';
|
||||
|
||||
setDocumentTitleMock.mockClear();
|
||||
|
||||
builderStore.clearDoneIndicatorTitle();
|
||||
|
||||
expect(setDocumentTitleMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,6 +44,7 @@ import { useWorkflowHistoryStore } from '@/features/workflows/workflowHistory/wo
|
||||
import type { IWorkflowDb } from '@/Interface';
|
||||
import { useWorkflowSaving } from '@/app/composables/useWorkflowSaving';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
|
||||
|
||||
const INFINITE_CREDITS = -1;
|
||||
export const ENABLED_VIEWS = BUILDER_ENABLED_VIEWS;
|
||||
@@ -119,6 +120,8 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
|
||||
// Track the last user message ID for telemetry
|
||||
const lastUserMessageId = ref<string | undefined>();
|
||||
|
||||
const documentTitle = useDocumentTitle();
|
||||
|
||||
// Store dependencies
|
||||
const settings = useSettingsStore();
|
||||
const rootStore = useRootStore();
|
||||
@@ -267,6 +270,13 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
|
||||
|
||||
trackEndBuilderResponse(payload);
|
||||
currentStreamingMessage.value = undefined;
|
||||
|
||||
// Update page title on completion. We show Done when the user is not on the page
|
||||
if (document.hidden) {
|
||||
documentTitle.setDocumentTitle(workflowsStore.workflowName, 'AI_DONE');
|
||||
} else {
|
||||
documentTitle.setDocumentTitle(workflowsStore.workflowName, 'IDLE');
|
||||
}
|
||||
}
|
||||
|
||||
function abortStreaming() {
|
||||
@@ -329,6 +339,9 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
|
||||
chatMessages.value = clearRatingLogic([...chatMessages.value, userMsg]);
|
||||
addLoadingAssistantMessage(locale.baseText('aiAssistant.thinkingSteps.thinking'));
|
||||
streaming.value = true;
|
||||
|
||||
// Updates page title to show AI is building
|
||||
documentTitle.setDocumentTitle(workflowsStore.workflowName, 'AI_BUILDING');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -906,6 +919,16 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
|
||||
return updatedWorkflow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the [Done] indicator from the page title and resets to IDLE.
|
||||
* Should be called from a component that watches document visibility.
|
||||
*/
|
||||
function clearDoneIndicatorTitle() {
|
||||
if (documentTitle.getDocumentState() === 'AI_DONE') {
|
||||
documentTitle.setDocumentTitle(workflowsStore.workflowName, 'IDLE');
|
||||
}
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
// State
|
||||
@@ -945,5 +968,7 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
|
||||
// Version management
|
||||
restoreToVersion,
|
||||
clearExistingWorkflow,
|
||||
// Title management for AI builder
|
||||
clearDoneIndicatorTitle,
|
||||
};
|
||||
});
|
||||
|
||||
+39
@@ -164,6 +164,18 @@ vi.mock('@/app/composables/usePageRedirectionHelper', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useDocumentVisibility
|
||||
let onDocumentVisibleCallback: (() => void) | null = null;
|
||||
vi.mock('@/app/composables/useDocumentVisibility', () => ({
|
||||
useDocumentVisibility: () => ({
|
||||
isVisible: { value: true },
|
||||
onDocumentVisible: (callback: () => void) => {
|
||||
onDocumentVisibleCallback = callback;
|
||||
},
|
||||
onDocumentHidden: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const workflowPrompt = 'Create a workflow';
|
||||
describe('AskAssistantBuild', () => {
|
||||
const sessionId = faker.string.uuid();
|
||||
@@ -177,6 +189,7 @@ describe('AskAssistantBuild', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
onDocumentVisibleCallback = null;
|
||||
|
||||
const pinia = createTestingPinia({
|
||||
initialState: {
|
||||
@@ -1669,4 +1682,30 @@ describe('AskAssistantBuild', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('document visibility handling', () => {
|
||||
it('should register onDocumentVisible callback on mount', () => {
|
||||
renderComponent();
|
||||
|
||||
// Callback should be registered
|
||||
expect(onDocumentVisibleCallback).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should call clearDoneIndicatorTitle when document becomes visible', async () => {
|
||||
builderStore.clearDoneIndicatorTitle = vi.fn();
|
||||
|
||||
renderComponent();
|
||||
|
||||
await flushPromises();
|
||||
|
||||
// Verify callback was registered
|
||||
expect(onDocumentVisibleCallback).not.toBeNull();
|
||||
|
||||
// Simulate document becoming visible
|
||||
onDocumentVisibleCallback!();
|
||||
|
||||
// Verify clearDoneIndicatorTitle was called
|
||||
expect(builderStore.clearDoneIndicatorTitle).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+6
@@ -14,6 +14,7 @@ import { nodeViewEventBus } from '@/app/event-bus';
|
||||
import ExecuteMessage from './ExecuteMessage.vue';
|
||||
import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper';
|
||||
import { useToast } from '@/app/composables/useToast';
|
||||
import { useDocumentVisibility } from '@/app/composables/useDocumentVisibility';
|
||||
import { WORKFLOW_SUGGESTIONS } from '@/app/constants/workflowSuggestions';
|
||||
import { VIEWS } from '@/app/constants';
|
||||
import shuffle from 'lodash/shuffle';
|
||||
@@ -35,6 +36,11 @@ const route = useRoute();
|
||||
const workflowSaver = useWorkflowSaving({ router });
|
||||
const { goToUpgrade } = usePageRedirectionHelper();
|
||||
const toast = useToast();
|
||||
const { onDocumentVisible } = useDocumentVisibility();
|
||||
|
||||
onDocumentVisible(() => {
|
||||
builderStore.clearDoneIndicatorTitle();
|
||||
});
|
||||
|
||||
// Track processed workflow updates
|
||||
const processedWorkflowUpdates = ref(new Set<string>());
|
||||
|
||||
Reference in New Issue
Block a user