feat(editor): Show building and done status in page title for AI builder (#23987)

This commit is contained in:
Albert Alises
2026-01-08 21:43:29 +01:00
committed by GitHub
parent ea3e156624
commit 4879db8f43
8 changed files with 270 additions and 9 deletions
+7 -1
View File
@@ -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,
};
});
@@ -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();
});
});
});
@@ -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>());