mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat: Restyle and simplify Browser Use connection view (no-changelog) (#36611)
This commit is contained in:
parent
ad22ee8033
commit
649e73ba68
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<TabManagementSettings> {
|
||||
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<TabManagementSettings> {
|
|||
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<void> {
|
||||
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<unknown> {
|
|||
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<ExternalConnectR
|
|||
const existing = await deliverRelayUrl(relayUrl);
|
||||
const tabId = existing?.id ?? (await openConnectPopup(relayUrl));
|
||||
pendingConnectFlow = { relayUrl, tabId, notify: null };
|
||||
setDrawerEnabled(false);
|
||||
return { accepted: true };
|
||||
}
|
||||
|
||||
|
|
@ -331,7 +341,6 @@ chrome.webNavigation.onCreatedNavigationTarget.addListener((details) => {
|
|||
|
||||
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<void> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof vi.fn>).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. */
|
||||
|
|
|
|||
|
|
@ -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<typeof setInterval> | 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<string, unknown>): Promise<unknown> {
|
||||
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<string, unknown>): Promise<unknown> {
|
||||
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<string, unknown>): Promise<unknown> {
|
||||
const id = params.id as string;
|
||||
if (!id) throw new Error('id is required');
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
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<string, unknown>).allowTabCreation === 'boolean' &&
|
||||
typeof (raw as Record<string, unknown>).allowTabClosing === 'boolean'
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,151 +1,242 @@
|
|||
<script setup lang="ts">
|
||||
import { N8nButton, N8nLogo } from '@n8n/design-system';
|
||||
import { computed, ref } from 'vue';
|
||||
import { N8nButton, N8nIcon, N8nLogo } from '@n8n/design-system';
|
||||
import { useConnection } from './composables/useConnection';
|
||||
import StatusBadge from './components/StatusBadge.vue';
|
||||
import InfoRow from './components/InfoRow.vue';
|
||||
import TabList from './components/TabList.vue';
|
||||
import SettingsPanel from './components/SettingsPanel.vue';
|
||||
|
||||
const {
|
||||
status,
|
||||
tabs,
|
||||
selectedTabIds,
|
||||
errorMessage,
|
||||
settings,
|
||||
hasRelayUrl,
|
||||
relayHost,
|
||||
isRelayAllowed,
|
||||
isAutoConnect,
|
||||
controlledTabs,
|
||||
controlledTabIds,
|
||||
allSelected,
|
||||
someSelected,
|
||||
toggleTab,
|
||||
toggleAll,
|
||||
connect,
|
||||
decline,
|
||||
disconnect,
|
||||
updateSettings,
|
||||
} = useConnection();
|
||||
|
||||
const showTabSelection = ref(false);
|
||||
|
||||
const isConnected = computed(() => status.value === 'connected');
|
||||
const showConnectPrompt = computed(() => hasRelayUrl.value && isRelayAllowed.value);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container">
|
||||
<h1 class="title">
|
||||
<div class="card">
|
||||
<div class="content">
|
||||
<N8nLogo class="logo" size="small" :collapsed="false" />
|
||||
<span class="title-text">Browser Use</span>
|
||||
</h1>
|
||||
<p class="subtitle">
|
||||
{{ isAutoConnect ? 'Auto-connecting (eval mode)…' : 'Let n8n AI control your browser' }}
|
||||
</p>
|
||||
|
||||
<StatusBadge :status="status" :tab-count="controlledTabIds.length" />
|
||||
|
||||
<template v-if="status !== 'connected'">
|
||||
<template v-if="hasRelayUrl">
|
||||
<template v-if="isRelayAllowed">
|
||||
<p class="connect-notice">
|
||||
You're about to connect to <strong>{{ relayHost }}</strong
|
||||
>. Only continue if you started this connection yourself.
|
||||
</p>
|
||||
<TabList
|
||||
v-if="tabs.length"
|
||||
:tabs="tabs"
|
||||
selectable
|
||||
:selected-tab-ids="selectedTabIds"
|
||||
:all-selected="allSelected"
|
||||
@toggle-tab="toggleTab"
|
||||
@toggle-all="toggleAll"
|
||||
<template v-if="isConnected">
|
||||
<h1 class="title">
|
||||
<span class="status-dot" />
|
||||
Connected to n8n
|
||||
</h1>
|
||||
<div class="panel">
|
||||
<InfoRow
|
||||
icon="shield"
|
||||
:title="relayHost ? `Connected to ${relayHost}` : 'Connected to your n8n instance'"
|
||||
/>
|
||||
<N8nButton
|
||||
class="full-width"
|
||||
size="large"
|
||||
:disabled="status === 'connecting'"
|
||||
@click="connect"
|
||||
<InfoRow
|
||||
icon="lock"
|
||||
title="Browser access"
|
||||
description="Tabs n8n opens will appear below"
|
||||
/>
|
||||
<template v-if="controlledTabs.length">
|
||||
<hr class="divider" />
|
||||
<TabList :tabs="controlledTabs" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="showConnectPrompt">
|
||||
<h1 class="title">Allow n8n to access your browser</h1>
|
||||
<p v-if="isAutoConnect" class="subtitle">Auto-connecting (eval mode)…</p>
|
||||
<div class="panel">
|
||||
<InfoRow
|
||||
icon="shield"
|
||||
:title="`Connecting to ${relayHost}`"
|
||||
description="Only continue if you initiated this connection"
|
||||
/>
|
||||
<InfoRow
|
||||
icon="lock"
|
||||
title="Browser access"
|
||||
description="n8n can access tabs it opens. Select existing tabs below to grant additional access"
|
||||
>
|
||||
Connect{{
|
||||
someSelected
|
||||
? ` (${selectedTabIds.size} tab${selectedTabIds.size !== 1 ? 's' : ''})`
|
||||
: ''
|
||||
}}
|
||||
</N8nButton>
|
||||
</template>
|
||||
<p v-else class="error">
|
||||
<button
|
||||
v-if="tabs.length"
|
||||
class="tabs-toggle"
|
||||
@click="showTabSelection = !showTabSelection"
|
||||
>
|
||||
Allow access to existing tabs{{
|
||||
selectedTabIds.size ? ` (${selectedTabIds.size})` : ''
|
||||
}}
|
||||
<N8nIcon :icon="showTabSelection ? 'chevron-up' : 'chevron-down'" size="medium" />
|
||||
</button>
|
||||
</InfoRow>
|
||||
<template v-if="showTabSelection && tabs.length">
|
||||
<hr class="divider" />
|
||||
<TabList
|
||||
:tabs="tabs"
|
||||
selectable
|
||||
:selected-tab-ids="selectedTabIds"
|
||||
@toggle-tab="toggleTab"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="hasRelayUrl">
|
||||
<h1 class="title">Allow n8n to access your browser</h1>
|
||||
<p class="error">
|
||||
Can't connect to <strong>{{ relayHost || 'this address' }}</strong> — it isn't a valid n8n
|
||||
instance.
|
||||
</p>
|
||||
</template>
|
||||
<p v-else class="info-text">
|
||||
Waiting for n8n AI to connect. Ask n8n AI to open your browser to get started.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<TabList v-if="controlledTabs.length" :tabs="controlledTabs" header-text="Controlled Tabs" />
|
||||
<N8nButton class="full-width" variant="outline" size="large" @click="disconnect"
|
||||
>Disconnect</N8nButton
|
||||
<template v-else>
|
||||
<h1 class="title">n8n Browser Use extension</h1>
|
||||
<div class="panel">
|
||||
<InfoRow
|
||||
icon="eye-off"
|
||||
title="Disconnected"
|
||||
description="Initiate the connection from your n8n instance to get started"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p v-if="errorMessage" class="error">{{ errorMessage }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="isConnected" class="footer">
|
||||
<N8nButton variant="ghost" size="large" @click="disconnect">Disconnect</N8nButton>
|
||||
</div>
|
||||
<div v-else-if="showConnectPrompt" class="footer">
|
||||
<N8nButton variant="ghost" size="large" @click="decline">Decline</N8nButton>
|
||||
<N8nButton size="large" :disabled="status === 'connecting'" @click="connect"
|
||||
>Allow connection</N8nButton
|
||||
>
|
||||
</template>
|
||||
|
||||
<SettingsPanel :settings="settings" @update-settings="updateSettings" />
|
||||
|
||||
<p v-if="errorMessage" class="error">{{ errorMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.container {
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(500px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: var(--spacing--xl);
|
||||
background: var(--background--surface);
|
||||
height: min(700px, calc(100dvh - 2 * var(--spacing--md)));
|
||||
min-height: 420px;
|
||||
background: var(--background--subtle);
|
||||
border: var(--border-width) var(--border-style) var(--color--foreground--tint-1);
|
||||
border-radius: var(--radius--xl);
|
||||
box-shadow: var(--shadow--light);
|
||||
overflow: hidden;
|
||||
|
||||
// In a popup window the window itself is the card — no chrome, fill the viewport
|
||||
@media (max-width: 550px) {
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
height: 100dvh;
|
||||
min-height: 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing--xl);
|
||||
padding-bottom: var(--spacing--xs);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
margin-bottom: var(--spacing--lg);
|
||||
|
||||
:deep(svg) {
|
||||
margin-left: 0;
|
||||
width: auto;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--4xs);
|
||||
gap: var(--spacing--xs);
|
||||
font-size: var(--font-size--lg);
|
||||
font-weight: var(--font-weight--bold);
|
||||
font-weight: var(--font-weight--medium);
|
||||
color: var(--color--text--shade-1);
|
||||
margin: 0;
|
||||
margin: 0 0 var(--spacing--lg);
|
||||
}
|
||||
|
||||
.logo {
|
||||
:deep(svg) {
|
||||
margin-left: 0;
|
||||
width: auto;
|
||||
height: 2.4em;
|
||||
}
|
||||
}
|
||||
|
||||
.title-text {
|
||||
margin-top: 2px;
|
||||
.status-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--color--success);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: var(--font-size--sm);
|
||||
color: var(--color--text);
|
||||
margin: 0 0 var(--spacing--lg);
|
||||
}
|
||||
|
||||
.info-text {
|
||||
font-size: var(--font-size--sm);
|
||||
line-height: var(--line-height--xl);
|
||||
color: var(--text-color--subtler);
|
||||
margin: 0 0 var(--spacing--sm);
|
||||
}
|
||||
|
||||
.connect-notice {
|
||||
font-size: var(--font-size--sm);
|
||||
line-height: var(--line-height--xl);
|
||||
color: var(--color--text);
|
||||
margin: 0 0 var(--spacing--sm);
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--lg);
|
||||
background: var(--background--surface);
|
||||
border: var(--border-width) var(--border-style) var(--color--foreground--tint-1);
|
||||
border-radius: var(--radius--lg);
|
||||
padding: var(--spacing--lg);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
.divider {
|
||||
border: none;
|
||||
border-top: var(--border-width) var(--border-style) var(--color--foreground--tint-1);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tabs-toggle {
|
||||
appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
margin-top: var(--spacing--sm);
|
||||
padding: var(--spacing--3xs) var(--spacing--xs);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size--xs);
|
||||
color: var(--color--text--shade-1);
|
||||
|
||||
&:hover {
|
||||
background: var(--color--background);
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing--2xs);
|
||||
padding: var(--spacing--md) var(--spacing--xl) var(--spacing--xl);
|
||||
}
|
||||
|
||||
.error {
|
||||
|
|
@ -163,4 +254,17 @@ html {
|
|||
min-height: 100vh;
|
||||
padding: var(--spacing--md);
|
||||
}
|
||||
|
||||
/* body is a flex item of html — without min-width: 0, nowrap tab URLs
|
||||
propagate their intrinsic width and stretch the page beyond the viewport */
|
||||
body {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 550px) {
|
||||
html {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
<script setup lang="ts">
|
||||
import { N8nIcon } from '@n8n/design-system';
|
||||
import type { IconName } from '@n8n/design-system';
|
||||
|
||||
defineProps<{
|
||||
icon: IconName;
|
||||
title: string;
|
||||
description?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="info-row">
|
||||
<span class="marker">
|
||||
<N8nIcon :icon="icon" size="large" />
|
||||
</span>
|
||||
<div class="info-content">
|
||||
<div class="info-title">{{ title }}</div>
|
||||
<div v-if="description" class="info-description">{{ description }}</div>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.marker {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--row-marker-size);
|
||||
height: var(--row-marker-size);
|
||||
border-radius: 50%;
|
||||
background: var(--color--background);
|
||||
color: var(--color--text--shade-1);
|
||||
}
|
||||
|
||||
.info-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-top: var(--spacing--4xs);
|
||||
}
|
||||
|
||||
.info-title {
|
||||
font-size: var(--font-size--sm);
|
||||
font-weight: var(--font-weight--medium);
|
||||
color: var(--color--text--shade-1);
|
||||
}
|
||||
|
||||
.info-description {
|
||||
font-size: var(--font-size--sm);
|
||||
line-height: var(--line-height--lg);
|
||||
color: var(--text-color--subtler);
|
||||
margin-top: var(--spacing--4xs);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { N8nIcon, N8nSwitch2 } from '@n8n/design-system';
|
||||
import type { TabManagementSettings } from '../../types';
|
||||
|
||||
defineProps<{
|
||||
settings: TabManagementSettings;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
updateSettings: [partial: Partial<TabManagementSettings>];
|
||||
}>();
|
||||
|
||||
const showSettings = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings">
|
||||
<button class="settings-toggle" @click="showSettings = !showSettings">
|
||||
<N8nIcon icon="settings" :size="14" />
|
||||
<span>Settings</span>
|
||||
<N8nIcon
|
||||
icon="chevron-down"
|
||||
:size="12"
|
||||
:class="['chevron', { 'chevron--open': showSettings }]"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div v-if="showSettings" class="settings-panel">
|
||||
<label
|
||||
class="setting-row"
|
||||
@click.prevent="emit('updateSettings', { allowTabCreation: !settings.allowTabCreation })"
|
||||
>
|
||||
<span class="setting-label">
|
||||
<span class="setting-name">Allow tab creation</span>
|
||||
<span class="setting-desc">AI agent can open new browser tabs</span>
|
||||
</span>
|
||||
<N8nSwitch2
|
||||
:model-value="settings.allowTabCreation"
|
||||
@update:model-value="emit('updateSettings', { allowTabCreation: $event })"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label
|
||||
class="setting-row"
|
||||
@click.prevent="emit('updateSettings', { allowTabClosing: !settings.allowTabClosing })"
|
||||
>
|
||||
<span class="setting-label">
|
||||
<span class="setting-name">Allow tab closing</span>
|
||||
<span class="setting-desc">AI agent can close controlled tabs</span>
|
||||
</span>
|
||||
<N8nSwitch2
|
||||
:model-value="settings.allowTabClosing"
|
||||
@update:model-value="emit('updateSettings', { allowTabClosing: $event })"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.settings {
|
||||
margin-top: var(--spacing--md);
|
||||
border-top: var(--border-width) var(--border-style) var(--color--foreground--tint-1);
|
||||
padding-top: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.settings-toggle {
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: var(--spacing--2xs) 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size--xs);
|
||||
color: var(--color--text--tint-1);
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
color: var(--color--text);
|
||||
}
|
||||
}
|
||||
|
||||
.chevron {
|
||||
margin-left: auto;
|
||||
transition: transform var(--duration--snappy);
|
||||
}
|
||||
|
||||
.chevron--open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
padding: var(--spacing--xs) 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--xs);
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing--sm);
|
||||
padding: var(--spacing--2xs) var(--spacing--xs);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--background--surface--hover);
|
||||
}
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.setting-name {
|
||||
font-size: var(--font-size--sm);
|
||||
color: var(--color--text);
|
||||
}
|
||||
|
||||
.setting-desc {
|
||||
font-size: var(--font-size--2xs);
|
||||
color: var(--text-color--subtler);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { ConnectionStatus } from '../../types';
|
||||
|
||||
const props = defineProps<{
|
||||
status: ConnectionStatus;
|
||||
tabCount: number;
|
||||
}>();
|
||||
|
||||
const theme = computed(() => {
|
||||
const map: Record<ConnectionStatus, string> = {
|
||||
connected: 'success',
|
||||
connecting: 'warning',
|
||||
disconnected: 'danger',
|
||||
};
|
||||
return map[props.status];
|
||||
});
|
||||
|
||||
const label = computed(() => {
|
||||
const map: Record<ConnectionStatus, string> = {
|
||||
connected: 'Connected',
|
||||
connecting: 'Connecting...',
|
||||
disconnected: 'Disconnected',
|
||||
};
|
||||
return map[props.status];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['status', `status--${theme}`]">
|
||||
<span class="status-dot" />
|
||||
<span>{{ label }}</span>
|
||||
<span v-if="status === 'connected'" class="tab-count"
|
||||
>{{ tabCount }} {{ tabCount === 1 ? 'tab' : 'tabs' }}</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
padding: var(--spacing--xs) var(--spacing--sm);
|
||||
border-radius: var(--radius--lg);
|
||||
margin-bottom: var(--spacing--md);
|
||||
font-size: var(--font-size--sm);
|
||||
}
|
||||
|
||||
.status--success {
|
||||
background: var(--background--success);
|
||||
color: var(--text-color--success);
|
||||
}
|
||||
|
||||
.status--warning {
|
||||
background: var(--background--warning);
|
||||
color: var(--text-color--warning);
|
||||
}
|
||||
|
||||
.status--danger {
|
||||
background: var(--background--danger);
|
||||
color: var(--text-color--danger);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: currentcolor;
|
||||
}
|
||||
|
||||
.tab-count {
|
||||
margin-left: auto;
|
||||
font-size: var(--font-size--2xs);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -32,23 +32,26 @@ function hideFavicon(event: Event): void {
|
|||
|
||||
<template>
|
||||
<li :class="['tab-item', { 'tab-item--selectable': selectable }]" @click="handleClick">
|
||||
<N8nCheckbox
|
||||
v-if="selectable"
|
||||
:model-value="selected"
|
||||
@click.stop
|
||||
@update:model-value="handleClick"
|
||||
/>
|
||||
<img
|
||||
v-if="tab.favIconUrl"
|
||||
:src="tab.favIconUrl"
|
||||
alt=""
|
||||
class="tab-favicon"
|
||||
@error="hideFavicon"
|
||||
/>
|
||||
<span class="tab-marker">
|
||||
<img
|
||||
v-if="tab.favIconUrl"
|
||||
:src="tab.favIconUrl"
|
||||
alt=""
|
||||
class="tab-favicon"
|
||||
@error="hideFavicon"
|
||||
/>
|
||||
</span>
|
||||
<div class="tab-info">
|
||||
<div class="tab-title">{{ tab.title ?? 'Untitled' }}</div>
|
||||
<div class="tab-url">{{ tab.url ?? '' }}</div>
|
||||
</div>
|
||||
<N8nCheckbox
|
||||
v-if="selectable"
|
||||
class="tab-checkbox"
|
||||
:model-value="selected"
|
||||
@click.stop
|
||||
@update:model-value="handleClick"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { N8nCheckbox } from '@n8n/design-system';
|
||||
import TabItem from './TabItem.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
|
|
@ -8,80 +6,41 @@ const props = withDefaults(
|
|||
tabs: chrome.tabs.Tab[];
|
||||
selectable?: boolean;
|
||||
selectedTabIds?: Set<number>;
|
||||
allSelected?: boolean;
|
||||
headerText?: string;
|
||||
}>(),
|
||||
{
|
||||
selectable: false,
|
||||
selectedTabIds: () => new Set<number>(),
|
||||
allSelected: false,
|
||||
headerText: '',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleTab: [tabId: number];
|
||||
toggleAll: [];
|
||||
}>();
|
||||
|
||||
const someSelected = computed(() => props.selectedTabIds.size > 0);
|
||||
|
||||
function isSelected(tab: chrome.tabs.Tab): boolean {
|
||||
return tab.id !== undefined && props.selectedTabIds.has(tab.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tab-list-container">
|
||||
<div class="tab-list-header">
|
||||
<label v-if="selectable" class="select-all" @click.prevent="emit('toggleAll')">
|
||||
<N8nCheckbox :model-value="allSelected" :indeterminate="someSelected && !allSelected" />
|
||||
<span>Select All ({{ tabs.length }} tabs)</span>
|
||||
</label>
|
||||
<template v-else>{{ headerText }}</template>
|
||||
</div>
|
||||
<ul class="tab-list">
|
||||
<TabItem
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
:tab="tab"
|
||||
:selectable="selectable"
|
||||
:selected="isSelected(tab)"
|
||||
@toggle="emit('toggleTab', $event)"
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
<ul class="tab-list">
|
||||
<TabItem
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
:tab="tab"
|
||||
:selectable="selectable"
|
||||
:selected="isSelected(tab)"
|
||||
@toggle="emit('toggleTab', $event)"
|
||||
/>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tab-list-container {
|
||||
margin-bottom: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.tab-list-header {
|
||||
font-size: var(--font-size--xs);
|
||||
font-weight: var(--font-weight--bold);
|
||||
color: var(--color--text--tint-1);
|
||||
padding: var(--spacing--2xs) var(--spacing--xs);
|
||||
border-bottom: var(--border-width) var(--border-style) var(--color--foreground--tint-1);
|
||||
margin-bottom: var(--spacing--3xs);
|
||||
}
|
||||
|
||||
.tab-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.select-all {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size--xs);
|
||||
font-weight: var(--font-weight--bold);
|
||||
color: var(--color--text--tint-1);
|
||||
min-height: 60px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<ConnectionStatus>('disconnected');
|
||||
const controlledTabIds = ref<ControlledTabId[]>([]);
|
||||
const errorMessage = ref('');
|
||||
const settings = ref<TabManagementSettings>({ ...DEFAULT_SETTINGS });
|
||||
const relayUrl = ref<string | null>(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<string | null>(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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<TabManagementSettings>): Promise<void> {
|
||||
settings.value = { ...settings.value, ...partial };
|
||||
await chrome.runtime.sendMessage({
|
||||
type: 'updateSettings',
|
||||
settings: settings.value,
|
||||
});
|
||||
async function decline(): Promise<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
20
packages/@n8n/mcp-browser-extension/src/ui/drawer.html
Normal file
20
packages/@n8n/mcp-browser-extension/src/ui/drawer.html
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>n8n Browser Use</title>
|
||||
<style>
|
||||
/* Action popups size to content — fix the viewport so the app's
|
||||
popup-mode media query (≤550px) applies and fills the window */
|
||||
html {
|
||||
width: 500px;
|
||||
height: 600px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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]',
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user