diff --git a/packages/@n8n/mcp-browser-extension/manifest.json b/packages/@n8n/mcp-browser-extension/manifest.json index 79a7757f9a5..bf1c2bc4cb5 100644 --- a/packages/@n8n/mcp-browser-extension/manifest.json +++ b/packages/@n8n/mcp-browser-extension/manifest.json @@ -35,6 +35,7 @@ }, "action": { "default_title": "n8n Browser Use", + "default_popup": "drawer.html", "default_icon": { "16": "icons/icon-16.png", "32": "icons/icon-32.png", diff --git a/packages/@n8n/mcp-browser-extension/src/background.test.ts b/packages/@n8n/mcp-browser-extension/src/background.test.ts index 75432bc4a93..88ac0941055 100644 --- a/packages/@n8n/mcp-browser-extension/src/background.test.ts +++ b/packages/@n8n/mcp-browser-extension/src/background.test.ts @@ -18,6 +18,7 @@ const CONNECT_URL = `${EXT_ORIGIN}connect.html`; const tabUpdatedListeners: TabUpdatedHandler[] = []; const tabRemovedListeners: Array<(tabId: number) => void> = []; const externalMessageListeners: ExternalMessageHandler[] = []; +const actionClickedListeners: Array<() => void> = []; const chromeMock = { runtime: { @@ -30,6 +31,7 @@ const chromeMock = { }, tabs: { query: vi.fn().mockResolvedValue([]), + get: vi.fn().mockResolvedValue(undefined), update: vi.fn().mockResolvedValue(undefined), remove: vi.fn().mockResolvedValue(undefined), reload: vi.fn().mockResolvedValue(undefined), @@ -56,9 +58,12 @@ const chromeMock = { }, webNavigation: { onCreatedNavigationTarget: { addListener: vi.fn() } }, action: { - onClicked: { addListener: vi.fn() }, setBadgeText: vi.fn(), setBadgeBackgroundColor: vi.fn(), + setPopup: vi.fn(), + onClicked: { + addListener: vi.fn((fn: () => void) => actionClickedListeners.push(fn)), + }, }, }; @@ -231,10 +236,10 @@ describe('external messages (direct connect flow)', () => { expect(chromeMock.windows.create).toHaveBeenCalledWith({ url: connectUrlWithRelay(RELAY_URL), type: 'popup', - width: 620, - height: 640, - left: 650, - top: 220, + width: 540, + height: 700, + left: 690, + top: 190, }); expect(response()).toEqual({ accepted: true }); }); @@ -331,6 +336,26 @@ describe('external messages (direct connect flow)', () => { expect(result()).toEqual({ connected: false }); }); + it('disables the drawer while the flow is pending and re-enables it on settle', async () => { + await simulateExternalMessage({ type: 'connect', relayUrl: RELAY_URL }, ALLOWED_ORIGIN); + expect(chromeMock.action.setPopup).toHaveBeenCalledWith({ popup: '' }); + + await simulateTabRemoved(POPUP_TAB_ID); + + expect(chromeMock.action.setPopup).toHaveBeenLastCalledWith({ popup: 'drawer.html' }); + }); + + it('focuses the pending connect page when the extension icon is clicked', async () => { + chromeMock.tabs.get.mockResolvedValue({ id: POPUP_TAB_ID, windowId: 7 }); + await simulateExternalMessage({ type: 'connect', relayUrl: RELAY_URL }, ALLOWED_ORIGIN); + + for (const fn of actionClickedListeners) fn(); + await flush(); + + expect(chromeMock.tabs.update).toHaveBeenCalledWith(POPUP_TAB_ID, { active: true }); + expect(chromeMock.windows.update).toHaveBeenCalledWith(7, { focused: true }); + }); + it('resolves a superseded flow when a newer connect request arrives', async () => { await simulateExternalMessage({ type: 'connect', relayUrl: RELAY_URL }, ALLOWED_ORIGIN); const firstResult = await simulateExternalMessage( diff --git a/packages/@n8n/mcp-browser-extension/src/background.ts b/packages/@n8n/mcp-browser-extension/src/background.ts index 6d0dcac3f5f..c2b0406489b 100644 --- a/packages/@n8n/mcp-browser-extension/src/background.ts +++ b/packages/@n8n/mcp-browser-extension/src/background.ts @@ -12,7 +12,6 @@ import type { ExtensionMessage, ExternalConnectResponse, ExternalConnectResultResponse, - TabManagementSettings, } from './types'; import { isExternalMessage } from './types'; @@ -25,22 +24,6 @@ interface ConnectionState { let activeConnection: ConnectionState | null = null; -// --------------------------------------------------------------------------- -// Settings -// --------------------------------------------------------------------------- - -const SETTINGS_KEY = 'tabManagementSettings'; - -const DEFAULT_SETTINGS: TabManagementSettings = { - allowTabCreation: true, - allowTabClosing: false, -}; - -async function loadSettings(): Promise { - const result = await chrome.storage.local.get(SETTINGS_KEY); - return (result[SETTINGS_KEY] as TabManagementSettings) ?? DEFAULT_SETTINGS; -} - // --------------------------------------------------------------------------- // Relay URL storage (for deduplicating connect.html tabs) // --------------------------------------------------------------------------- @@ -48,6 +31,41 @@ async function loadSettings(): Promise { const CONNECT_PAGE = 'connect.html'; const RELAY_URL_KEY = 'pendingRelayUrl'; +// --------------------------------------------------------------------------- +// Action drawer — clicking the extension icon opens drawer.html. While a +// connect confirmation page is open, the drawer is disabled so the icon click +// falls through to onClicked, which focuses the pending page instead of +// showing the same connect view twice. +// --------------------------------------------------------------------------- + +const DRAWER_PAGE = 'drawer.html'; + +function setDrawerEnabled(enabled: boolean): void { + void chrome.action.setPopup({ popup: enabled ? DRAWER_PAGE : '' }); +} + +// The disabled state persists across service-worker restarts while the pending +// flow does not — reset on startup so the drawer can't get stuck disabled. +setDrawerEnabled(true); + +chrome.action.onClicked.addListener(() => { + void focusPendingConnectPage(); +}); + +async function focusPendingConnectPage(): Promise { + const tabId = pendingConnectFlow?.tabId; + if (tabId === null || tabId === undefined) return; + try { + const tab = await chrome.tabs.get(tabId); + await chrome.tabs.update(tabId, { active: true }); + if (tab.windowId !== undefined) { + await chrome.windows.update(tab.windowId, { focused: true }); + } + } catch { + // Pending page already gone — the next settle re-enables the drawer + } +} + // --------------------------------------------------------------------------- // Message handling from connect.html UI // --------------------------------------------------------------------------- @@ -83,19 +101,9 @@ async function handleMessage(message: ExtensionMessage): Promise { return { connected: activeConnection !== null, tabIds: activeConnection?.relay.getControlledIds() ?? [], + relayUrl: activeConnection?.relayUrl, }; - case 'updateSettings': { - await chrome.storage.local.set({ [SETTINGS_KEY]: message.settings }); - if (activeConnection) { - activeConnection.relay.setSettings(message.settings); - } - return { success: true }; - } - - case 'getSettings': - return await loadSettings(); - case 'getRelayUrl': { const stored = await chrome.storage.session.get(RELAY_URL_KEY); return (stored[RELAY_URL_KEY] as string) ?? null; @@ -191,8 +199,8 @@ async function deliverRelayUrl( // --------------------------------------------------------------------------- const EXTERNAL_CONNECT_THROTTLE_MS = 1000; -const CONNECT_POPUP_WIDTH = 620; -const CONNECT_POPUP_HEIGHT = 640; +const CONNECT_POPUP_WIDTH = 540; +const CONNECT_POPUP_HEIGHT = 700; let lastExternalConnectAt = 0; @@ -211,6 +219,7 @@ function settleConnectFlow(connected: boolean): void { pendingConnectFlow.notify?.({ connected }); } finally { pendingConnectFlow = null; + setDrawerEnabled(true); } } @@ -266,6 +275,7 @@ async function handleExternalConnect(relayUrl: string): Promise { const relay = activeConnection.relay; const sourceIsControlled = relay.isControlledTab(details.sourceTabId); - const tabCreationAllowed = relay.isTabCreationAllowed(); log.debug( '[onCreatedNavigationTarget] tabId:', @@ -342,11 +351,9 @@ chrome.webNavigation.onCreatedNavigationTarget.addListener((details) => { details.url, 'sourceIsControlled:', sourceIsControlled, - 'tabCreationAllowed:', - tabCreationAllowed, ); - if (!sourceIsControlled || !tabCreationAllowed) return; + if (!sourceIsControlled) return; // Mark as agent-created so onUpdated listener also tracks URL changes relay.markAsAgentCreated(details.tabId); @@ -442,10 +449,6 @@ async function connectToRelay( try { // Eagerly attach debugger to selected tabs and resolve CDP targetIds await relay.registerSelectedTabs(selectedTabIds); - - // Load and apply settings - const settings = await loadSettings(); - relay.setSettings(settings); } catch (error) { relay.close('network_error'); throw error; @@ -493,7 +496,8 @@ function disconnect(): void { function broadcastStatusChange(): void { const connected = activeConnection !== null; const tabIds = activeConnection?.relay.getControlledIds() ?? []; - chrome.runtime.sendMessage({ type: 'statusChanged', connected, tabIds }).catch(() => { + const relayUrl = activeConnection?.relayUrl; + chrome.runtime.sendMessage({ type: 'statusChanged', connected, tabIds, relayUrl }).catch(() => { // No receivers — this is fine if the popup/tab is not open }); } @@ -507,25 +511,3 @@ function updateBadge(tabCount: number): void { void chrome.action.setBadgeText({ text }); void chrome.action.setBadgeBackgroundColor({ color: tabCount > 0 ? '#4CAF50' : '#999' }); } - -// --------------------------------------------------------------------------- -// Extension icon click — open or focus the connect tab -// --------------------------------------------------------------------------- - -chrome.action.onClicked.addListener(() => { - void openOrFocusConnectTab(); -}); - -async function openOrFocusConnectTab(): Promise { - const connectUrl = chrome.runtime.getURL(CONNECT_PAGE); - const existing = await chrome.tabs.query({ url: `${connectUrl}*` }); - - if (existing.length > 0 && existing[0].id !== undefined) { - await chrome.tabs.update(existing[0].id, { active: true }); - if (existing[0].windowId !== undefined) { - await chrome.windows.update(existing[0].windowId, { focused: true }); - } - } else { - await chrome.tabs.create({ url: connectUrl }); - } -} diff --git a/packages/@n8n/mcp-browser-extension/src/relayConnection.test.ts b/packages/@n8n/mcp-browser-extension/src/relayConnection.test.ts index c41aeb17dd6..18d0fec488f 100644 --- a/packages/@n8n/mcp-browser-extension/src/relayConnection.test.ts +++ b/packages/@n8n/mcp-browser-extension/src/relayConnection.test.ts @@ -534,7 +534,6 @@ describe('RelayConnection', () => { }); it('should return true for agent-created tabs after createTab', async () => { - relay.setSettings({ allowTabCreation: true, allowTabClosing: false }); (globalThis.chrome.tabs.create as ReturnType).mockResolvedValueOnce({ id: 999, title: 'New', @@ -660,28 +659,10 @@ describe('RelayConnection', () => { expect(ws.closed).toBe(false); }); - it('should reject createTab when tab creation is disabled', async () => { - relay.setSettings({ allowTabCreation: false, allowTabClosing: false }); - - ws.onmessage?.({ - data: JSON.stringify({ - id: 5, - method: 'createTab', - params: { url: 'https://example.com' }, - }), - }); - await tick(); - - expect(parseSent(ws)).toEqual( - expect.objectContaining({ error: expect.stringContaining('Tab creation is disabled') }), - ); - }); - - it('should reject closeTab when tab closing is disabled', async () => { + it('should reject closeTab', async () => { chrome.debugger.getTargets.mockResolvedValueOnce([mockTarget(42)]); await relay.addTab(42, 'Test', 'https://test.com'); const addedId = relay.getControlledIds()[0].targetId; - relay.setSettings({ allowTabCreation: true, allowTabClosing: false }); ws.sent.length = 0; ws.onmessage?.({ @@ -694,8 +675,9 @@ describe('RelayConnection', () => { await tick(); expect(parseSent(ws)).toEqual( - expect.objectContaining({ error: expect.stringContaining('Tab closing is disabled') }), + expect.objectContaining({ error: expect.stringContaining('does not allow closing tabs') }), ); + expect(relay.getControlledIds()).toHaveLength(1); }); describe('spawned tab helpers', () => { @@ -713,14 +695,6 @@ describe('RelayConnection', () => { expect(relay.isControlledTab(42)).toBe(true); }); - it('isTabCreationAllowed reflects current settings', () => { - expect(relay.isTabCreationAllowed()).toBe(true); - relay.setSettings({ allowTabCreation: false, allowTabClosing: false }); - expect(relay.isTabCreationAllowed()).toBe(false); - relay.setSettings({ allowTabCreation: true, allowTabClosing: false }); - expect(relay.isTabCreationAllowed()).toBe(true); - }); - it('markAsAgentCreated causes isAgentCreatedTab to return true', () => { expect(relay.isAgentCreatedTab(50)).toBe(false); relay.markAsAgentCreated(50); @@ -1107,24 +1081,6 @@ describe('RelayConnection', () => { expect(ws.closeReason).toBe('debugger_detached'); }); - - it('should close with extension_disconnected when last tab is closed via closeTab', async () => { - chrome.debugger.getTargets.mockResolvedValueOnce([mockTarget(42)]); - await relay.addTab(42, 'Test', 'https://test.com'); - relay.setSettings({ allowTabCreation: true, allowTabClosing: true }); - ws.sent.length = 0; - - ws.onmessage?.({ - data: JSON.stringify({ - id: 1, - method: 'closeTab', - params: { id: targetIdForTab(42) }, - }), - }); - await tick(); - - expect(ws.closeReason).toBe('extension_disconnected'); - }); }); describe('document preparation', () => { /** Both attach paths must prepare the tab; only one of them used to. */ diff --git a/packages/@n8n/mcp-browser-extension/src/relayConnection.ts b/packages/@n8n/mcp-browser-extension/src/relayConnection.ts index 283151a6c0a..0e170f9591b 100644 --- a/packages/@n8n/mcp-browser-extension/src/relayConnection.ts +++ b/packages/@n8n/mcp-browser-extension/src/relayConnection.ts @@ -10,7 +10,6 @@ import { DocumentPreparation } from './documentPreparation'; import { ForeignFrames } from './foreignFrames'; import { createLogger } from './logger'; -import type { TabManagementSettings } from './types'; interface ProtocolCommand { id: number; @@ -26,11 +25,6 @@ interface ProtocolResponse { error?: string; } -const DEFAULT_SETTINGS: TabManagementSettings = { - allowTabCreation: true, - allowTabClosing: false, -}; - const log = createLogger('relay'); /** URL prefixes to exclude from auto-attach */ @@ -96,7 +90,6 @@ export class RelayConnection { private readonly documentPrep = new DocumentPreparation(); private closed = false; private keepaliveInterval: ReturnType | undefined; - private settings: TabManagementSettings = DEFAULT_SETTINGS; onclose?: () => void; ontabcreated?: () => void; @@ -189,10 +182,6 @@ export class RelayConnection { this.closeIfIdle('extension_disconnected'); } - setSettings(settings: TabManagementSettings): void { - this.settings = settings; - } - /** Return controlled tab identifiers (both CDP targetId and Chrome tab ID). */ getControlledIds(): Array<{ targetId: string; chromeTabId: number }> { return [...this.tabs.entries()].map(([targetId, entry]) => ({ @@ -206,10 +195,6 @@ export class RelayConnection { return this.chromeTabIdToId.has(chromeTabId); } - isTabCreationAllowed(): boolean { - return this.settings.allowTabCreation; - } - isAgentCreatedTab(chromeTabId: number): boolean { return this.agentCreatedChromeTabIds.has(chromeTabId); } @@ -518,7 +503,7 @@ export class RelayConnection { case 'createTab': return await this.handleCreateTab(message.params ?? {}); case 'closeTab': - return await this.handleCloseTab(message.params ?? {}); + throw new Error('The n8n browser extension does not allow closing tabs.'); case 'attachTab': return await this.handleAttachTab(message.params ?? {}); case 'listTabs': @@ -655,12 +640,6 @@ export class RelayConnection { } private async handleCreateTab(params: Record): Promise { - if (!this.settings.allowTabCreation) { - throw new Error( - 'Tab creation is disabled. Enable it in the n8n Browser Bridge extension settings.', - ); - } - const url = (params.url as string) ?? undefined; log.debug(`createTab: url=${url ?? '(none)'}`); @@ -700,46 +679,6 @@ export class RelayConnection { }; } - private async handleCloseTab(params: Record): Promise { - if (!this.settings.allowTabClosing) { - throw new Error( - 'Tab closing is disabled. Enable it in the n8n Browser Bridge extension settings.', - ); - } - - const id = params.id as string; - if (!id) throw new Error('id is required'); - - const entry = this.tabs.get(id); - if (!entry) throw new Error(`Tab ${id} is not registered`); - - log.debug(`closeTab: ${id} (chromeTabId=${entry.chromeTabId})`); - - // Detach debugger if attached - if (entry.attached) { - await chrome.debugger.detach({ tabId: entry.chromeTabId }).catch(() => {}); - } - - // Clean up maps - this.tabs.delete(id); - this.chromeTabIdToId.delete(entry.chromeTabId); - this.agentCreatedChromeTabIds.delete(entry.chromeTabId); - this.foreignFrames.forget(entry.chromeTabId); - - // Close the tab - await chrome.tabs.remove(entry.chromeTabId); - - // Update primary - if (id === this.primaryId) { - const remaining = [...this.tabs.keys()]; - this.primaryId = remaining.length > 0 ? remaining[0] : undefined; - } - - this.closeIfIdle('extension_disconnected'); - - return { closed: true, id }; - } - private async handleAttachTab(params: Record): Promise { const id = params.id as string; if (!id) throw new Error('id is required'); diff --git a/packages/@n8n/mcp-browser-extension/src/types.ts b/packages/@n8n/mcp-browser-extension/src/types.ts index 8d56343d1e9..acaeda8dfc0 100644 --- a/packages/@n8n/mcp-browser-extension/src/types.ts +++ b/packages/@n8n/mcp-browser-extension/src/types.ts @@ -17,11 +17,8 @@ export interface ConnectResponse { export interface StatusResponse { connected?: boolean; tabIds?: ControlledTabId[]; -} - -export interface TabManagementSettings { - allowTabCreation: boolean; - allowTabClosing: boolean; + /** Relay URL of the live session, so any view can name the instance it is bound to. */ + relayUrl?: string; } // --------------------------------------------------------------------------- @@ -46,15 +43,6 @@ export interface GetStatusMessage { type: 'getStatus'; } -export interface UpdateSettingsMessage { - type: 'updateSettings'; - settings: TabManagementSettings; -} - -export interface GetSettingsMessage { - type: 'getSettings'; -} - export interface GetRelayUrlMessage { type: 'getRelayUrl'; } @@ -68,8 +56,6 @@ export type ExtensionMessage = | ConnectMessage | DisconnectMessage | GetStatusMessage - | UpdateSettingsMessage - | GetSettingsMessage | GetRelayUrlMessage | ClearRelayUrlMessage; @@ -110,6 +96,7 @@ export interface StatusChangedMessage { type: 'statusChanged'; connected: boolean; tabIds?: ControlledTabId[]; + relayUrl?: string; } export type BackgroundPushMessage = RelayUrlReadyMessage | StatusChangedMessage; @@ -138,6 +125,7 @@ export function isStatusResponse(raw: unknown): raw is StatusResponse { if (raw === null || typeof raw !== 'object') return false; const obj = raw as Record; if ('connected' in obj && typeof obj.connected !== 'boolean') return false; + if (obj.relayUrl !== undefined && typeof obj.relayUrl !== 'string') return false; if ('tabIds' in obj) { if (!Array.isArray(obj.tabIds)) return false; for (const item of obj.tabIds) { @@ -153,14 +141,3 @@ export function isStatusResponse(raw: unknown): raw is StatusResponse { } return true; } - -export function isTabManagementSettings(raw: unknown): raw is TabManagementSettings { - return ( - raw !== null && - typeof raw === 'object' && - 'allowTabCreation' in raw && - 'allowTabClosing' in raw && - typeof (raw as Record).allowTabCreation === 'boolean' && - typeof (raw as Record).allowTabClosing === 'boolean' - ); -} diff --git a/packages/@n8n/mcp-browser-extension/src/ui/App.vue b/packages/@n8n/mcp-browser-extension/src/ui/App.vue index 72bbbab6044..07d5e004419 100644 --- a/packages/@n8n/mcp-browser-extension/src/ui/App.vue +++ b/packages/@n8n/mcp-browser-extension/src/ui/App.vue @@ -1,151 +1,242 @@ - - - -

{{ errorMessage }}

+ diff --git a/packages/@n8n/mcp-browser-extension/src/ui/components/InfoRow.vue b/packages/@n8n/mcp-browser-extension/src/ui/components/InfoRow.vue new file mode 100644 index 00000000000..f0c1e95ed95 --- /dev/null +++ b/packages/@n8n/mcp-browser-extension/src/ui/components/InfoRow.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/packages/@n8n/mcp-browser-extension/src/ui/components/SettingsPanel.vue b/packages/@n8n/mcp-browser-extension/src/ui/components/SettingsPanel.vue deleted file mode 100644 index 06cfbf3cc12..00000000000 --- a/packages/@n8n/mcp-browser-extension/src/ui/components/SettingsPanel.vue +++ /dev/null @@ -1,131 +0,0 @@ - - - - - diff --git a/packages/@n8n/mcp-browser-extension/src/ui/components/StatusBadge.vue b/packages/@n8n/mcp-browser-extension/src/ui/components/StatusBadge.vue deleted file mode 100644 index 213fe6e73c7..00000000000 --- a/packages/@n8n/mcp-browser-extension/src/ui/components/StatusBadge.vue +++ /dev/null @@ -1,76 +0,0 @@ - - - - - diff --git a/packages/@n8n/mcp-browser-extension/src/ui/components/TabItem.vue b/packages/@n8n/mcp-browser-extension/src/ui/components/TabItem.vue index d478995b02f..aefe905c83b 100644 --- a/packages/@n8n/mcp-browser-extension/src/ui/components/TabItem.vue +++ b/packages/@n8n/mcp-browser-extension/src/ui/components/TabItem.vue @@ -32,23 +32,26 @@ function hideFavicon(event: Event): void { @@ -56,10 +59,9 @@ function hideFavicon(event: Event): void { .tab-item { display: flex; align-items: center; - gap: var(--spacing--xs); - padding: var(--spacing--xs); + gap: var(--spacing--sm); + padding: var(--spacing--xs) 0; border-radius: var(--radius--lg); - border: var(--border-width) var(--border-style) transparent; } .tab-item--selectable { @@ -70,9 +72,15 @@ function hideFavicon(event: Event): void { } } +.tab-marker { + flex: 0 0 var(--row-marker-size); + display: flex; + justify-content: center; +} + .tab-favicon { - width: 16px; - height: 16px; + width: 24px; + height: 24px; border-radius: var(--radius--sm); } @@ -95,4 +103,9 @@ function hideFavicon(event: Event): void { overflow: hidden; text-overflow: ellipsis; } + +.tab-checkbox { + flex-shrink: 0; + margin: 0; +} diff --git a/packages/@n8n/mcp-browser-extension/src/ui/components/TabList.vue b/packages/@n8n/mcp-browser-extension/src/ui/components/TabList.vue index 17ec68ef782..47c24d67048 100644 --- a/packages/@n8n/mcp-browser-extension/src/ui/components/TabList.vue +++ b/packages/@n8n/mcp-browser-extension/src/ui/components/TabList.vue @@ -1,6 +1,4 @@ diff --git a/packages/@n8n/mcp-browser-extension/src/ui/composables/useConnection.test.ts b/packages/@n8n/mcp-browser-extension/src/ui/composables/useConnection.test.ts index 99b48d9d868..b654d8c6754 100644 --- a/packages/@n8n/mcp-browser-extension/src/ui/composables/useConnection.test.ts +++ b/packages/@n8n/mcp-browser-extension/src/ui/composables/useConnection.test.ts @@ -35,6 +35,8 @@ const chromeMock = { }, tabs: { get: vi.fn(), + getCurrent: vi.fn(), + remove: vi.fn(), onCreated: { addListener: vi.fn((fn: TabCreatedHandler) => tabCreatedListeners.push(fn)), removeListener: vi.fn((fn: TabCreatedHandler) => { @@ -129,7 +131,6 @@ beforeEach(() => { // Default background responses chromeMock.runtime.sendMessage.mockImplementation(async (msg: { type: string }) => { - if (msg.type === 'getSettings') return { allowTabCreation: true, allowTabClosing: false }; if (msg.type === 'getRelayUrl') return null; if (msg.type === 'getStatus') return { connected: false, tabIds: [] }; if (msg.type === 'getTabs') return [makeTab(1)]; @@ -163,7 +164,6 @@ describe('useConnection', () => { it('loads available tabs from background on mount', async () => { chromeMock.runtime.sendMessage.mockImplementation(async (msg: { type: string }) => { if (msg.type === 'getTabs') return [makeTab(10), makeTab(11)]; - if (msg.type === 'getSettings') return { allowTabCreation: true, allowTabClosing: false }; if (msg.type === 'getRelayUrl') return null; if (msg.type === 'getStatus') return { connected: false, tabIds: [] }; return await Promise.resolve({}); @@ -181,7 +181,6 @@ describe('useConnection', () => { const controlled = [makeControlledTabId(5), makeControlledTabId(6)]; chromeMock.runtime.sendMessage.mockImplementation(async (msg: { type: string }) => { if (msg.type === 'getStatus') return { connected: true, tabIds: controlled }; - if (msg.type === 'getSettings') return { allowTabCreation: true, allowTabClosing: false }; if (msg.type === 'getRelayUrl') return null; if (msg.type === 'getTabs') return [makeTab(1)]; return await Promise.resolve({}); @@ -197,12 +196,58 @@ describe('useConnection', () => { }); }); + describe('connected instance', () => { + const RELAY_URL = 'wss://acme.app.n8n.cloud/relay'; + + it('names the instance reported by the background on mount', async () => { + chromeMock.runtime.sendMessage.mockImplementation(async (msg: { type: string }) => { + if (msg.type === 'getStatus') return { connected: true, tabIds: [], relayUrl: RELAY_URL }; + if (msg.type === 'getRelayUrl') return null; + if (msg.type === 'getTabs') return [makeTab(1)]; + return await Promise.resolve({}); + }); + + const { wrapper, result } = mountComposable(); + await flush(); + + expect(result().relayHost.value).toBe('acme.app.n8n.cloud'); + + wrapper.unmount(); + }); + + it('names the instance when a connection starts while the view is open', async () => { + const { wrapper, result } = mountComposable(); + await flush(); + expect(result().relayHost.value).toBeNull(); + + pushMessage({ type: 'statusChanged', connected: true, tabIds: [], relayUrl: RELAY_URL }); + await flush(); + + expect(result().relayHost.value).toBe('acme.app.n8n.cloud'); + + wrapper.unmount(); + }); + + it('forgets the instance once disconnected', async () => { + const { wrapper, result } = mountComposable(); + await flush(); + + pushMessage({ type: 'statusChanged', connected: true, tabIds: [], relayUrl: RELAY_URL }); + await flush(); + await result().disconnect(); + await flush(); + + expect(result().relayHost.value).toBeNull(); + + wrapper.unmount(); + }); + }); + describe('statusChanged broadcast', () => { it('shows controlled tabs immediately when statusChanged arrives with new tab', async () => { // Tab 42 must be in the registry (returned by getTabs) for controlledTabDetails to resolve it chromeMock.runtime.sendMessage.mockImplementation(async (msg: { type: string }) => { if (msg.type === 'getTabs') return [makeTab(1), makeTab(42)]; - if (msg.type === 'getSettings') return { allowTabCreation: true, allowTabClosing: false }; if (msg.type === 'getRelayUrl') return null; if (msg.type === 'getStatus') return { connected: false, tabIds: [] }; return await Promise.resolve({}); @@ -243,7 +288,6 @@ describe('useConnection', () => { it('updates controlled tabs when a second tab is added', async () => { chromeMock.runtime.sendMessage.mockImplementation(async (msg: { type: string }) => { if (msg.type === 'getTabs') return [makeTab(1), makeTab(2)]; - if (msg.type === 'getSettings') return { allowTabCreation: true, allowTabClosing: false }; if (msg.type === 'getRelayUrl') return null; if (msg.type === 'getStatus') return { connected: false, tabIds: [] }; return await Promise.resolve({}); @@ -375,7 +419,6 @@ describe('useConnection', () => { it('transitions to connected and populates controlled tabs', async () => { const controlled = [makeControlledTabId(7)]; chromeMock.runtime.sendMessage.mockImplementation(async (msg: { type: string }) => { - if (msg.type === 'getSettings') return { allowTabCreation: true, allowTabClosing: false }; if (msg.type === 'getRelayUrl') return 'ws://localhost:1234'; if (msg.type === 'getStatus') return { connected: true, tabIds: controlled }; if (msg.type === 'getTabs') return [makeTab(1), makeTab(7)]; @@ -398,7 +441,6 @@ describe('useConnection', () => { it('stays disconnected and shows error on connect failure', async () => { chromeMock.runtime.sendMessage.mockImplementation(async (msg: { type: string }) => { - if (msg.type === 'getSettings') return { allowTabCreation: true, allowTabClosing: false }; if (msg.type === 'getRelayUrl') return 'ws://localhost:1234'; if (msg.type === 'getStatus') return { connected: false, tabIds: [] }; if (msg.type === 'getTabs') return [makeTab(1)]; @@ -423,7 +465,6 @@ describe('useConnection', () => { type: windowType, } as chrome.windows.Window); chromeMock.runtime.sendMessage.mockImplementation(async (msg: { type: string }) => { - if (msg.type === 'getSettings') return { allowTabCreation: true, allowTabClosing: false }; if (msg.type === 'getRelayUrl') return 'ws://localhost:1234'; if (msg.type === 'getStatus') return { connected: false, tabIds: [] }; if (msg.type === 'getTabs') return [makeTab(1)]; @@ -455,7 +496,6 @@ describe('useConnection', () => { it('does nothing when no relay URL is available', async () => { chromeMock.runtime.sendMessage.mockImplementation(async (msg: { type: string }) => { - if (msg.type === 'getSettings') return { allowTabCreation: true, allowTabClosing: false }; if (msg.type === 'getRelayUrl') return null; if (msg.type === 'getStatus') return { connected: false, tabIds: [] }; if (msg.type === 'getTabs') return [makeTab(1)]; @@ -506,7 +546,6 @@ describe('useConnection', () => { '/?autoConnect=1&mcpRelayUrl=' + encodeURIComponent('ws://127.0.0.1:9000/ext'), ); chromeMock.runtime.sendMessage.mockImplementation(async (msg: { type: string }) => { - if (msg.type === 'getSettings') return { allowTabCreation: true, allowTabClosing: false }; if (msg.type === 'getRelayUrl') return null; if (msg.type === 'getStatus') return { connected: false, tabIds: [] }; if (msg.type === 'getTabs') return [makeTab(1), makeTab(2)]; @@ -533,7 +572,6 @@ describe('useConnection', () => { '/?autoConnect=1&mcpRelayUrl=' + encodeURIComponent('wss://attacker.example/relay'), ); chromeMock.runtime.sendMessage.mockImplementation(async (msg: { type: string }) => { - if (msg.type === 'getSettings') return { allowTabCreation: true, allowTabClosing: false }; if (msg.type === 'getRelayUrl') return null; if (msg.type === 'getStatus') return { connected: false, tabIds: [] }; if (msg.type === 'getTabs') return [makeTab(1)]; @@ -561,7 +599,6 @@ describe('useConnection', () => { '/?autoConnect=1&mcpRelayUrl=' + encodeURIComponent(`ws://${host}/ext`), ); chromeMock.runtime.sendMessage.mockImplementation(async (msg: { type: string }) => { - if (msg.type === 'getSettings') return { allowTabCreation: true, allowTabClosing: false }; if (msg.type === 'getRelayUrl') return null; if (msg.type === 'getStatus') return { connected: false, tabIds: [] }; if (msg.type === 'getTabs') return [makeTab(1)]; @@ -587,7 +624,6 @@ describe('useConnection', () => { '/?mcpRelayUrl=' + encodeURIComponent('ws://127.0.0.1:9000/ext'), ); chromeMock.runtime.sendMessage.mockImplementation(async (msg: { type: string }) => { - if (msg.type === 'getSettings') return { allowTabCreation: true, allowTabClosing: false }; if (msg.type === 'getRelayUrl') return null; if (msg.type === 'getStatus') return { connected: false, tabIds: [] }; if (msg.type === 'getTabs') return [makeTab(1)]; @@ -614,35 +650,46 @@ describe('useConnection', () => { result().toggleTab(1); expect(result().selectedTabIds.has(1)).toBe(true); - expect(result().someSelected.value).toBe(true); result().toggleTab(1); expect(result().selectedTabIds.has(1)).toBe(false); - expect(result().someSelected.value).toBe(false); wrapper.unmount(); }); + }); - it('toggleAll selects all available tabs', async () => { - chromeMock.runtime.sendMessage.mockImplementation(async (msg: { type: string }) => { - if (msg.type === 'getTabs') return [makeTab(1), makeTab(2), makeTab(3)]; - if (msg.type === 'getSettings') return { allowTabCreation: true, allowTabClosing: false }; - if (msg.type === 'getRelayUrl') return null; - if (msg.type === 'getStatus') return { connected: false, tabIds: [] }; - return await Promise.resolve({}); - }); + describe('decline', () => { + it('clears the relay URL and closes the current tab', async () => { + chromeMock.tabs.getCurrent.mockResolvedValue(makeTab(33)); const { wrapper, result } = mountComposable(); await flush(); - result().toggleAll(); - expect(result().allSelected.value).toBe(true); - expect(result().selectedTabIds.size).toBe(3); + pushMessage({ type: 'relayUrlReady', relayUrl: 'ws://localhost:9999' }); + await flush(); - result().toggleAll(); - expect(result().allSelected.value).toBe(false); - expect(result().selectedTabIds.size).toBe(0); + await result().decline(); + expect(chromeMock.runtime.sendMessage).toHaveBeenCalledWith({ type: 'clearRelayUrl' }); + expect(result().relayUrl.value).toBeNull(); + expect(chromeMock.tabs.remove).toHaveBeenCalledWith(33); + + wrapper.unmount(); + }); + + it('closes the window when the page is not a tab (action popover)', async () => { + chromeMock.tabs.getCurrent.mockResolvedValue(undefined); + const closeSpy = vi.spyOn(window, 'close').mockImplementation(() => {}); + + const { wrapper, result } = mountComposable(); + await flush(); + + await result().decline(); + + expect(chromeMock.tabs.remove).not.toHaveBeenCalled(); + expect(closeSpy).toHaveBeenCalled(); + + closeSpy.mockRestore(); wrapper.unmount(); }); }); diff --git a/packages/@n8n/mcp-browser-extension/src/ui/composables/useConnection.ts b/packages/@n8n/mcp-browser-extension/src/ui/composables/useConnection.ts index fe1089a16e5..f5ce1eb9c65 100644 --- a/packages/@n8n/mcp-browser-extension/src/ui/composables/useConnection.ts +++ b/packages/@n8n/mcp-browser-extension/src/ui/composables/useConnection.ts @@ -6,25 +6,22 @@ import { isEligibleTab } from '../../relayConnection'; import type { ConnectionStatus, ControlledTabId, - TabManagementSettings, BackgroundPushMessage, + StatusResponse, } from '../../types'; -import { isConnectResponse, isStatusResponse, isTabManagementSettings } from '../../types'; +import { isConnectResponse, isStatusResponse } from '../../types'; const log = createLogger('ui'); -const DEFAULT_SETTINGS: TabManagementSettings = { - allowTabCreation: true, - allowTabClosing: false, -}; - export function useConnection() { // ── Core connection state ───────────────────────────────────────────────── const status = ref('disconnected'); const controlledTabIds = ref([]); const errorMessage = ref(''); - const settings = ref({ ...DEFAULT_SETTINGS }); const relayUrl = ref(null); + // Reported by the background, which owns the live session. Lets a view that never + // saw the connect request — the drawer — still name the instance it is bound to. + const connectedRelayUrl = ref(null); // Set when the page is opened with `?autoConnect=1` AND the relay URL // points to localhost. Skips the manual click: every available tab is @@ -58,19 +55,18 @@ export function useConnection() { // ── Computeds ───────────────────────────────────────────────────────────── const hasRelayUrl = computed(() => !!relayUrl.value); - const relayHost = computed(() => getRelayHost(relayUrl.value)); + const relayHost = computed(() => getRelayHost(connectedRelayUrl.value ?? relayUrl.value)); const isRelayAllowed = computed(() => isAllowedRelayUrl(relayUrl.value)); - const allSelected = computed( - () => - availableTabs.value.length > 0 && - availableTabs.value.every((t) => t.id !== undefined && selectedTabIds.has(t.id)), - ); - - const someSelected = computed(() => selectedTabIds.size > 0); - // ── Private helpers ─────────────────────────────────────────────────────── + function applyStatus(update: StatusResponse): void { + const connected = update.connected === true; + status.value = connected ? 'connected' : 'disconnected'; + controlledTabIds.value = connected ? (update.tabIds ?? []) : []; + connectedRelayUrl.value = connected ? (update.relayUrl ?? null) : null; + } + async function initTabRegistry(): Promise { const result: unknown = await chrome.runtime.sendMessage({ type: 'getTabs' }); const tabs = Array.isArray(result) ? (result as chrome.tabs.Tab[]) : []; @@ -110,16 +106,6 @@ export function useConnection() { } } - function toggleAll(): void { - if (allSelected.value) { - selectedTabIds.clear(); - } else { - for (const t of availableTabs.value) { - if (t.id !== undefined) selectedTabIds.add(t.id); - } - } - } - async function connect(): Promise { if (!relayUrl.value) { errorMessage.value = 'No active session. Ask n8n AI to connect to your browser.'; @@ -150,7 +136,7 @@ export function useConnection() { // Fetch controlled IDs — controlledTabDetails computed auto-resolves from registry const statusResponse: unknown = await chrome.runtime.sendMessage({ type: 'getStatus' }); if (isStatusResponse(statusResponse)) { - controlledTabIds.value = statusResponse.tabIds ?? []; + applyStatus(statusResponse); } const currentWindow = await chrome.windows.getCurrent(); if (currentWindow.type === 'popup') { @@ -168,22 +154,34 @@ export function useConnection() { async function disconnect(): Promise { log.debug('disconnect'); await chrome.runtime.sendMessage({ type: 'disconnect' }); - status.value = 'disconnected'; - controlledTabIds.value = []; // controlledTabDetails auto-computes to [] + applyStatus({ connected: false }); relayUrl.value = null; } - async function updateSettings(partial: Partial): Promise { - settings.value = { ...settings.value, ...partial }; - await chrome.runtime.sendMessage({ - type: 'updateSettings', - settings: settings.value, - }); + async function decline(): Promise { + log.debug('decline'); + await chrome.runtime.sendMessage({ type: 'clearRelayUrl' }); + relayUrl.value = null; + // In the action popover the page is not a tab — getCurrent returns undefined + const currentTab = await chrome.tabs.getCurrent(); + if (currentTab?.id !== undefined) { + await chrome.tabs.remove(currentTab.id); + } else { + window.close(); + } } // ── Background push message listener ───────────────────────────────────── - async function onBackgroundMessage(message: BackgroundPushMessage): Promise { + // Must be a sync listener: an async listener returns a Promise, which Chrome + // treats as "will respond" — with several extension views open (drawer + + // connect page) it then races the background's real sendMessage response + // with `undefined` and the caller sees a bogus error. + function onBackgroundMessage(message: BackgroundPushMessage): void { + void handleBackgroundMessage(message); + } + + async function handleBackgroundMessage(message: BackgroundPushMessage): Promise { if (message.type === 'relayUrlReady' && message.relayUrl) { log.debug('relayUrlReady received:', message.relayUrl); relayUrl.value = message.relayUrl; @@ -191,23 +189,18 @@ export function useConnection() { // relayUrl + session storage, so a manual reload reads the fresh URL, not the old token. window.history.replaceState(null, '', window.location.pathname); if (status.value === 'connected') { - status.value = 'disconnected'; - controlledTabIds.value = []; // controlledTabDetails auto-computes to [] + applyStatus({ connected: false }); await initTabRegistry(); } } if (message.type === 'statusChanged') { log.debug('statusChanged received: connected=', message.connected); + applyStatus(message); if (message.connected) { - status.value = 'connected'; - const incoming = message.tabIds ?? []; - const missing = incoming.filter((e) => !tabRegistry.has(e.chromeTabId)); + const missing = (message.tabIds ?? []).filter((e) => !tabRegistry.has(e.chromeTabId)); if (missing.length > 0) await initTabRegistry(); - controlledTabIds.value = incoming; // controlledTabDetails auto-computes } else { - status.value = 'disconnected'; - controlledTabIds.value = []; // controlledTabDetails auto-computes to [] relayUrl.value = null; } } @@ -228,13 +221,6 @@ export function useConnection() { // ── Initialization ──────────────────────────────────────────────────────── onMounted(async () => { - const savedSettings: unknown = await chrome.runtime.sendMessage({ type: 'getSettings' }); - log.debug('saved settings:', savedSettings); - if (isTabManagementSettings(savedSettings)) { - const validated: TabManagementSettings = savedSettings; - settings.value = validated; - } - // Read relay URL directly from the page's own query string first. // This is more reliable than session storage, which can race with the UI mount // (the background script writes it asynchronously when the tab is created). @@ -264,8 +250,7 @@ export function useConnection() { const currentStatus: unknown = await chrome.runtime.sendMessage({ type: 'getStatus' }); log.debug('initial status:', currentStatus); if (isStatusResponse(currentStatus) && currentStatus.connected) { - status.value = 'connected'; - controlledTabIds.value = currentStatus.tabIds ?? []; + applyStatus(currentStatus); } await initTabRegistry(); @@ -295,19 +280,15 @@ export function useConnection() { tabs: availableTabs, selectedTabIds, errorMessage, - settings, relayUrl, hasRelayUrl, relayHost, isRelayAllowed, isAutoConnect, controlledTabs: controlledTabDetails, - allSelected, - someSelected, toggleTab, - toggleAll, connect, + decline, disconnect, - updateSettings, }; } diff --git a/packages/@n8n/mcp-browser-extension/src/ui/drawer.html b/packages/@n8n/mcp-browser-extension/src/ui/drawer.html new file mode 100644 index 00000000000..ba96afbcb34 --- /dev/null +++ b/packages/@n8n/mcp-browser-extension/src/ui/drawer.html @@ -0,0 +1,20 @@ + + + + + + n8n Browser Use + + + +
+ + + diff --git a/packages/@n8n/mcp-browser-extension/src/ui/tokens.scss b/packages/@n8n/mcp-browser-extension/src/ui/tokens.scss index 402171f0ebc..28365d2708c 100644 --- a/packages/@n8n/mcp-browser-extension/src/ui/tokens.scss +++ b/packages/@n8n/mcp-browser-extension/src/ui/tokens.scss @@ -6,6 +6,9 @@ color-scheme: light dark; @include primitives.primitives; @include tokens.theme; + + // Shared icon column, so info rows and tab rows keep one text edge and one icon centre + --row-marker-size: 40px; } @media (prefers-color-scheme: dark) { diff --git a/packages/@n8n/mcp-browser-extension/vite.sw.config.mts b/packages/@n8n/mcp-browser-extension/vite.sw.config.mts index 0d189cec4a3..85fc24347db 100644 --- a/packages/@n8n/mcp-browser-extension/vite.sw.config.mts +++ b/packages/@n8n/mcp-browser-extension/vite.sw.config.mts @@ -15,7 +15,9 @@ export default defineConfig({ fileName: () => 'background.mjs', }, outDir: 'dist', - emptyOutDir: false, + // The sw build runs first (see the build script), so it clears stale files; + // the ui build must keep emptyOutDir: false to preserve background.mjs + emptyOutDir: true, rollupOptions: { output: { entryFileNames: 'background.mjs', diff --git a/packages/@n8n/mcp-browser-extension/vite.ui.config.mts b/packages/@n8n/mcp-browser-extension/vite.ui.config.mts index 5b2d33e5ca1..b6593dc5ccd 100644 --- a/packages/@n8n/mcp-browser-extension/vite.ui.config.mts +++ b/packages/@n8n/mcp-browser-extension/vite.ui.config.mts @@ -57,9 +57,9 @@ export default defineConfig({ rollupOptions: { input: { connect: resolve(__dirname, 'src/ui/connect.html'), + drawer: resolve(__dirname, 'src/ui/drawer.html'), }, output: { - entryFileNames: 'connect.js', assetFileNames: '[name][extname]', }, }, diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/BrowserUseConnectStep.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/BrowserUseConnectStep.vue index 13401f30e81..9253679d9bf 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/BrowserUseConnectStep.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/BrowserUseConnectStep.vue @@ -7,8 +7,8 @@ import { useInstanceAiBrowserUseTelemetry } from '../../instanceAiBrowserUse.tel import { useExtensionDirectConnect } from '../../composables/useExtensionDirectConnect'; const CONNECT_URL_REFRESH_MARGIN_MS = 30_000; -const CONNECT_POPUP_WIDTH = 620; -const CONNECT_POPUP_HEIGHT = 640; +const CONNECT_POPUP_WIDTH = 540; +const CONNECT_POPUP_HEIGHT = 700; const props = withDefaults(defineProps<{ autoConnect?: boolean }>(), { autoConnect: false }); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/__tests__/BrowserUseConnectStep.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/__tests__/BrowserUseConnectStep.test.ts index 61b7c5a86a6..925aa68699c 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/__tests__/BrowserUseConnectStep.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/__tests__/BrowserUseConnectStep.test.ts @@ -141,7 +141,7 @@ describe('BrowserUseConnectStep', () => { expect(openSpy).toHaveBeenCalledWith( CONNECT_URL, 'n8n-browser-use-connect', - expect.stringMatching(/^popup,width=620,height=640,left=\d+,top=\d+$/), + expect.stringMatching(/^popup,width=540,height=700,left=\d+,top=\d+$/), ); openSpy.mockRestore();