chore(extension): use react for connect dialog (#777)

This commit is contained in:
Yury Semikhatsky
2025-07-28 15:23:33 -07:00
committed by GitHub
parent 04988d8fac
commit 9b5f97b076
11 changed files with 2496 additions and 191 deletions

View File

@@ -17,18 +17,11 @@
<html> <html>
<head> <head>
<title>Playwright MCP extension</title> <title>Playwright MCP extension</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="src/ui/connect.css">
</head> </head>
<body> <body>
<h3>Playwright MCP extension</h3> <div id="root"></div>
<div id="status-container"></div> <script type="module" src="src/ui/connect.tsx"></script>
<div class="button-row">
<button id="continue-btn">Continue</button>
<button id="reject-btn">Reject</button>
</div>
<div id="tab-list-container">
<h4>Select page to expose to MCP server:</h4>
<div id="tab-list"></div>
</div>
<script src="lib/connect.js"></script>
</body> </body>
</html> </html>

View File

@@ -1,172 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
interface TabInfo {
id: number;
windowId: number;
title: string;
url: string;
favIconUrl?: string;
}
class ConnectPage {
private _tabList: HTMLElement;
private _tabListContainer: HTMLElement;
private _statusContainer: HTMLElement;
private _selectedTab: TabInfo | undefined;
constructor() {
this._tabList = document.getElementById('tab-list')!;
this._tabListContainer = document.getElementById('tab-list-container')!;
this._statusContainer = document.getElementById('status-container') as HTMLElement;
this._addButtonHandlers();
void this._loadTabs();
}
private _addButtonHandlers() {
const continueBtn = document.getElementById('continue-btn') as HTMLButtonElement;
const rejectBtn = document.getElementById('reject-btn') as HTMLButtonElement;
const buttonRow = document.querySelector('.button-row') as HTMLElement;
const params = new URLSearchParams(window.location.search);
const mcpRelayUrl = params.get('mcpRelayUrl');
if (!mcpRelayUrl) {
buttonRow.style.display = 'none';
this._showStatus('error', 'Missing mcpRelayUrl parameter in URL.');
return;
}
let clientInfo = 'unknown';
try {
const client = JSON.parse(params.get('client') || '{}');
clientInfo = `${client.name}/${client.version}`;
} catch (e) {
this._showStatus('error', 'Failed to parse client version.');
return;
}
this._showStatus('connecting', `MCP client "${clientInfo}" is trying to connect. Do you want to continue?`);
rejectBtn.addEventListener('click', async () => {
buttonRow.style.display = 'none';
this._tabListContainer.style.display = 'none';
this._showStatus('error', 'Connection rejected. This tab can be closed.');
});
continueBtn.addEventListener('click', async () => {
buttonRow.style.display = 'none';
this._tabListContainer.style.display = 'none';
try {
const selectedTab = this._selectedTab;
if (!selectedTab) {
this._showStatus('error', 'Tab not selected.');
return;
}
const response = await chrome.runtime.sendMessage({
type: 'connectToMCPRelay',
mcpRelayUrl,
tabId: selectedTab.id,
windowId: selectedTab.windowId,
});
if (response?.success)
this._showStatus('connected', `MCP client "${clientInfo}" connected.`);
else
this._showStatus('error', response?.error || `MCP client "${clientInfo}" failed to connect.`);
} catch (e) {
this._showStatus('error', `MCP client "${clientInfo}" failed to connect: ${e}`);
}
});
}
private async _loadTabs(): Promise<void> {
try {
const response = await chrome.runtime.sendMessage({ type: 'getTabs' });
if (response.success)
this._populateTabList(response.tabs, response.currentTabId);
else
this._showStatus('error', 'Failed to load tabs: ' + response.error);
} catch (error) {
this._showStatus('error', 'Failed to communicate with background script: ' + error);
}
}
private _populateTabList(tabs: TabInfo[], currentTabId: number): void {
this._tabList.replaceChildren();
this._selectedTab = tabs.find(tab => tab.id === currentTabId);
tabs.forEach((tab, index) => {
const tabElement = this._createTabElement(tab);
this._tabList.appendChild(tabElement);
});
}
private _createTabElement(tab: TabInfo): HTMLElement {
const disabled = tab.url.startsWith('chrome://');
const tabInfoDiv = document.createElement('div');
tabInfoDiv.className = 'tab-info';
tabInfoDiv.style.padding = '5px';
if (disabled)
tabInfoDiv.style.opacity = '0.5';
const radioButton = document.createElement('input');
radioButton.type = 'radio';
radioButton.name = 'tab-selection';
radioButton.checked = tab.id === this._selectedTab?.id;
radioButton.id = `tab-${tab.id}`;
radioButton.addEventListener('change', e => {
if (radioButton.checked)
this._selectedTab = tab;
});
if (disabled)
radioButton.disabled = true;
const favicon = document.createElement('img');
favicon.className = 'tab-favicon';
if (tab.favIconUrl)
favicon.src = tab.favIconUrl;
favicon.alt = '';
favicon.style.height = '16px';
favicon.style.width = '16px';
const title = document.createElement('span');
title.style.paddingLeft = '5px';
title.className = 'tab-title';
title.textContent = tab.title || 'Untitled';
const url = document.createElement('span');
url.style.paddingLeft = '5px';
url.className = 'tab-url';
url.textContent = tab.url;
tabInfoDiv.appendChild(radioButton);
tabInfoDiv.appendChild(favicon);
tabInfoDiv.appendChild(title);
tabInfoDiv.appendChild(url);
return tabInfoDiv;
}
private _showStatus(type: 'connected' | 'error' | 'connecting', message: string) {
const div = document.createElement('div');
div.className = `status ${type}`;
div.textContent = message;
this._statusContainer.replaceChildren(div);
}
}
new ConnectPage();

View File

@@ -0,0 +1,174 @@
/*
Copyright (c) Microsoft Corporation.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
body {
margin: 0;
padding: 0;
}
/* Base styles */
.app-container {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
background-color: #ffffff;
color: #1f2328;
margin: 0;
padding: 24px;
min-height: 100vh;
font-size: 14px;
line-height: 1.5;
}
.content-wrapper {
max-width: 600px;
margin: 0 auto;
}
.main-title {
font-size: 32px;
font-weight: 600;
margin-bottom: 8px;
color: #1f2328;
}
/* Status Banner */
.status-banner {
padding: 16px;
margin-bottom: 24px;
border-radius: 6px;
border: 1px solid;
font-size: 14px;
font-weight: 500;
}
.status-banner.connected {
background-color: #dafbe1;
border-color: #1a7f37;
color: #0d5a23;
}
.status-banner.error {
background-color: #ffebe9;
border-color: #da3633;
color: #a40e26;
}
.status-banner.connecting {
background-color: #fff8c5;
border-color: #d1b500;
color: #7a5c00;
}
/* Buttons */
.button-container {
margin-bottom: 24px;
}
.button {
padding: 8px 16px;
border-radius: 6px;
border: 1px solid;
font-size: 14px;
font-weight: 500;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
margin-right: 8px;
}
.button.primary {
background-color: #2da44e;
border-color: #2da44e;
color: #ffffff;
}
.button.primary:hover {
background-color: #2c974b;
}
.button.default {
background-color: #f6f8fa;
border-color: #d1d9e0;
color: #24292f;
}
.button.default:hover {
background-color: #f3f4f6;
border-color: #c7d2da;
}
/* Tab selection */
.tab-section-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 16px;
color: #1f2328;
}
.tab-item {
display: flex;
align-items: center;
padding: 12px;
border: 1px solid #d1d9e0;
border-radius: 6px;
margin-bottom: 8px;
background-color: #ffffff;
cursor: pointer;
}
.tab-item.selected {
background-color: #f6f8fa;
}
.tab-item.disabled {
cursor: not-allowed;
opacity: 0.5;
}
.tab-radio {
margin-right: 12px;
flex-shrink: 0;
}
.tab-favicon {
width: 16px;
height: 16px;
margin-right: 8px;
flex-shrink: 0;
}
.tab-content {
flex: 1;
min-width: 0;
}
.tab-title {
font-weight: 500;
color: #1f2328;
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tab-url {
font-size: 12px;
color: #656d76;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -0,0 +1,213 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useState, useEffect, useCallback } from 'react';
import { createRoot } from 'react-dom/client';
import './connect.css';
interface TabInfo {
id: number;
windowId: number;
title: string;
url: string;
favIconUrl?: string;
}
type StatusType = 'connected' | 'error' | 'connecting';
const ConnectApp: React.FC = () => {
const [tabs, setTabs] = useState<TabInfo[]>([]);
const [selectedTab, setSelectedTab] = useState<TabInfo | undefined>();
const [status, setStatus] = useState<{ type: StatusType; message: string } | null>(null);
const [showButtons, setShowButtons] = useState(true);
const [showTabList, setShowTabList] = useState(true);
const [clientInfo, setClientInfo] = useState('unknown');
const [mcpRelayUrl, setMcpRelayUrl] = useState('');
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const relayUrl = params.get('mcpRelayUrl');
if (!relayUrl) {
setShowButtons(false);
setStatus({ type: 'error', message: 'Missing mcpRelayUrl parameter in URL.' });
return;
}
setMcpRelayUrl(relayUrl);
try {
const client = JSON.parse(params.get('client') || '{}');
const info = `${client.name}/${client.version}`;
setClientInfo(info);
setStatus({
type: 'connecting',
message: `MCP client "${info}" is trying to connect. Do you want to continue?`
});
} catch (e) {
setStatus({ type: 'error', message: 'Failed to parse client version.' });
return;
}
void loadTabs();
}, []);
const loadTabs = async () => {
const response = await chrome.runtime.sendMessage({ type: 'getTabs' });
if (response.success) {
setTabs(response.tabs);
const currentTab = response.tabs.find((tab: TabInfo) => tab.id === response.currentTabId);
setSelectedTab(currentTab);
} else {
setStatus({ type: 'error', message: 'Failed to load tabs: ' + response.error });
}
};
const handleContinue = useCallback(async () => {
setShowButtons(false);
setShowTabList(false);
if (!selectedTab) {
setStatus({ type: 'error', message: 'Tab not selected.' });
return;
}
try {
const response = await chrome.runtime.sendMessage({
type: 'connectToMCPRelay',
mcpRelayUrl,
tabId: selectedTab.id,
windowId: selectedTab.windowId,
});
if (response?.success) {
setStatus({ type: 'connected', message: `MCP client "${clientInfo}" connected.` });
} else {
setStatus({
type: 'error',
message: response?.error || `MCP client "${clientInfo}" failed to connect.`
});
}
} catch (e) {
setStatus({
type: 'error',
message: `MCP client "${clientInfo}" failed to connect: ${e}`
});
}
}, [selectedTab, clientInfo, mcpRelayUrl]);
const handleReject = useCallback(() => {
setShowButtons(false);
setShowTabList(false);
setStatus({ type: 'error', message: 'Connection rejected. This tab can be closed.' });
}, []);
return (
<div className='app-container'>
<div className='content-wrapper'>
<h1 className='main-title'>
Playwright MCP Extension
</h1>
{status && <StatusBanner type={status.type} message={status.message} />}
{showButtons && (
<div className='button-container'>
<Button variant='primary' onClick={handleContinue}>
Continue
</Button>
<Button variant='default' onClick={handleReject}>
Reject
</Button>
</div>
)}
{showTabList && (
<div>
<h2 className='tab-section-title'>
Select page to expose to MCP server:
</h2>
<div>
{tabs.map(tab => (
<TabItem
key={tab.id}
tab={tab}
isSelected={selectedTab?.id === tab.id}
onSelect={() => setSelectedTab(tab)}
/>
))}
</div>
</div>
)}
</div>
</div>
);
};
const StatusBanner: React.FC<{ type: StatusType; message: string }> = ({ type, message }) => {
return <div className={`status-banner ${type}`}>{message}</div>;
};
const Button: React.FC<{ variant: 'primary' | 'default'; onClick: () => void; children: React.ReactNode }> = ({
variant,
onClick,
children
}) => {
return (
<button className={`button ${variant}`} onClick={onClick}>
{children}
</button>
);
};
const TabItem: React.FC<{ tab: TabInfo; isSelected: boolean; onSelect: () => void }> = ({
tab,
isSelected,
onSelect
}) => {
const disabled = tab.url.startsWith('chrome://');
const className = `tab-item ${isSelected ? 'selected' : ''} ${disabled ? 'disabled' : ''}`.trim();
return (
<div className={className} onClick={disabled ? undefined : onSelect}>
<input
type='radio'
className='tab-radio'
checked={isSelected}
disabled={disabled}
/>
<img
src={tab.favIconUrl || 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><rect width="16" height="16" fill="%23f6f8fa"/></svg>'}
alt=''
className='tab-favicon'
/>
<div className='tab-content'>
<div className='tab-title'>{tab.title || 'Untitled'}</div>
<div className='tab-url'>{tab.url}</div>
</div>
</div>
);
};
// Initialize the React app
const container = document.getElementById('root');
if (container) {
const root = createRoot(container);
root.render(<ConnectApp />);
}

View File

@@ -8,8 +8,13 @@
"rootDir": "src", "rootDir": "src",
"outDir": "./lib", "outDir": "./lib",
"resolveJsonModule": true, "resolveJsonModule": true,
"jsx": "react-jsx",
"jsxImportSource": "react"
}, },
"include": [ "include": [
"src", "src",
], ],
"exclude": [
"src/ui",
]
} }

View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ESNext",
"esModuleInterop": true,
"moduleResolution": "node",
"strict": true,
"module": "ESNext",
"rootDir": "src",
"outDir": "./lib",
"resolveJsonModule": true,
"jsx": "react-jsx",
"jsxImportSource": "react",
"noEmit": true,
},
"include": [
"src/ui",
],
}

40
extension/vite.config.ts Normal file
View File

@@ -0,0 +1,40 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { resolve } from 'path';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
base: '/lib/ui/',
build: {
outDir: resolve(__dirname, 'lib/ui'),
emptyOutDir: true,
minify: false,
rollupOptions: {
input: resolve(__dirname, 'connect.html'),
output: {
manualChunks: undefined,
inlineDynamicImports: true,
entryFileNames: '[name].js',
chunkFileNames: '[name].js',
assetFileNames: '[name].[ext]'
}
}
}
});

2034
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,12 +17,13 @@
"license": "Apache-2.0", "license": "Apache-2.0",
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"build:extension": "tsc --project extension", "build:extension": "tsc --project extension && tsc --project extension && vite build extension",
"lint": "npm run update-readme && eslint . && tsc --noEmit", "lint": "npm run update-readme && eslint . && tsc --noEmit",
"lint-fix": "eslint . --fix", "lint-fix": "eslint . --fix",
"update-readme": "node utils/update-readme.js", "update-readme": "node utils/update-readme.js",
"watch": "tsc --watch", "watch": "tsc --watch",
"watch:extension": "tsc --watch --project extension", "watch:extension": "tsc --watch --project extension & tsc --watch --project extension/tsconfig.ui.json & vite build extension --watch",
"dev:extension": "vite build --watch",
"test": "playwright test", "test": "playwright test",
"ctest": "playwright test --project=chrome", "ctest": "playwright test --project=chrome",
"ftest": "playwright test --project=firefox", "ftest": "playwright test --project=firefox",
@@ -58,14 +59,21 @@
"@types/chrome": "^0.0.315", "@types/chrome": "^0.0.315",
"@types/debug": "^4.1.12", "@types/debug": "^4.1.12",
"@types/node": "^22.13.10", "@types/node": "^22.13.10",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.26.1", "@typescript-eslint/eslint-plugin": "^8.26.1",
"@typescript-eslint/parser": "^8.26.1", "@typescript-eslint/parser": "^8.26.1",
"@typescript-eslint/utils": "^8.26.1", "@typescript-eslint/utils": "^8.26.1",
"esbuild": "^0.20.1",
"eslint": "^9.19.0", "eslint": "^9.19.0",
"eslint-plugin-import": "^2.31.0", "eslint-plugin-import": "^2.31.0",
"vite": "^5.0.0",
"@vitejs/plugin-react": "^4.0.0",
"eslint-plugin-notice": "^1.0.0", "eslint-plugin-notice": "^1.0.0",
"openai": "^5.10.2", "openai": "^5.10.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^5.8.2" "typescript": "^5.8.2"
}, },
"bin": { "bin": {

View File

@@ -103,7 +103,7 @@ export class CDPRelayServer {
private async _connectBrowser(clientInfo: { name: string, version: string }) { private async _connectBrowser(clientInfo: { name: string, version: string }) {
const mcpRelayEndpoint = `${this._wsHost}${this._extensionPath}`; const mcpRelayEndpoint = `${this._wsHost}${this._extensionPath}`;
// Need to specify "key" in the manifest.json to make the id stable when loading from file. // Need to specify "key" in the manifest.json to make the id stable when loading from file.
const url = new URL('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html'); const url = new URL('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/lib/ui/connect.html');
url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint); url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
url.searchParams.set('client', JSON.stringify(clientInfo)); url.searchParams.set('client', JSON.stringify(clientInfo));
const href = url.toString(); const href = url.toString();

View File

@@ -1,4 +1,4 @@
{ {
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"include": ["**/*.ts", "**/*.js"], "include": ["**/*.ts", "**/*.tsx", "**/*.js"],
} }