feat: Restyle and simplify Browser Use connection view (no-changelog) (#36611)

This commit is contained in:
Dimitri Lavrenük 2026-08-20 07:02:55 +00:00 committed by GitHub
parent ad22ee8033
commit 649e73ba68
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 527 additions and 663 deletions

View File

@ -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",

View File

@ -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(

View File

@ -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 });
}
}

View File

@ -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. */

View File

@ -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');

View File

@ -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'
);
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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();
});
});

View File

@ -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,
};
}

View 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>

View File

@ -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) {

View File

@ -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',

View File

@ -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]',
},
},

View File

@ -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 });

View File

@ -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();