chore(extension): connect button for each page, style tweaks (#848)

<img width="643" height="709" alt="image"
src="https://github.com/user-attachments/assets/850f2455-b853-4c0f-8047-a7f2ced16b7b"
/>
This commit is contained in:
Yury Semikhatsky
2025-08-07 17:24:48 -07:00
committed by GitHub
parent 636f1956cc
commit 3b6ecf0a43
4 changed files with 95 additions and 92 deletions

View File

@@ -44,7 +44,7 @@ class TabShareExtension {
private _onMessage(message: PageMessage, sender: chrome.runtime.MessageSender, sendResponse: (response: any) => void) { private _onMessage(message: PageMessage, sender: chrome.runtime.MessageSender, sendResponse: (response: any) => void) {
switch (message.type) { switch (message.type) {
case 'connectToMCPRelay': case 'connectToMCPRelay':
this._connectToRelay(message.mcpRelayUrl!, sender.tab!.id!).then( this._connectToRelay(sender.tab!.id!, message.mcpRelayUrl!).then(
() => sendResponse({ success: true }), () => sendResponse({ success: true }),
(error: any) => sendResponse({ success: false, error: error.message })); (error: any) => sendResponse({ success: false, error: error.message }));
return true; return true;
@@ -54,7 +54,7 @@ class TabShareExtension {
(error: any) => sendResponse({ success: false, error: error.message })); (error: any) => sendResponse({ success: false, error: error.message }));
return true; return true;
case 'connectToTab': case 'connectToTab':
this._connectTab(message.tabId, message.windowId, message.mcpRelayUrl!).then( this._connectTab(sender.tab!.id!, message.tabId, message.windowId, message.mcpRelayUrl!).then(
() => sendResponse({ success: true }), () => sendResponse({ success: true }),
(error: any) => sendResponse({ success: false, error: error.message })); (error: any) => sendResponse({ success: false, error: error.message }));
return true; // Return true to indicate that the response will be sent asynchronously return true; // Return true to indicate that the response will be sent asynchronously
@@ -62,7 +62,7 @@ class TabShareExtension {
return false; return false;
} }
private async _connectToRelay(mcpRelayUrl: string, selectorTabId: number): Promise<void> { private async _connectToRelay(selectorTabId: number, mcpRelayUrl: string): Promise<void> {
try { try {
debugLog(`Connecting to relay at ${mcpRelayUrl}`); debugLog(`Connecting to relay at ${mcpRelayUrl}`);
const socket = new WebSocket(mcpRelayUrl); const socket = new WebSocket(mcpRelayUrl);
@@ -86,7 +86,7 @@ class TabShareExtension {
} }
} }
private async _connectTab(tabId: number, windowId: number, mcpRelayUrl: string): Promise<void> { private async _connectTab(selectorTabId: number, tabId: number, windowId: number, mcpRelayUrl: string): Promise<void> {
try { try {
debugLog(`Connecting tab ${tabId} to relay at ${mcpRelayUrl}`); debugLog(`Connecting tab ${tabId} to relay at ${mcpRelayUrl}`);
try { try {
@@ -96,10 +96,10 @@ class TabShareExtension {
} }
await this._setConnectedTabId(null); await this._setConnectedTabId(null);
this._activeConnection = this._pendingTabSelection.get(tabId)?.connection; this._activeConnection = this._pendingTabSelection.get(selectorTabId)?.connection;
if (!this._activeConnection) if (!this._activeConnection)
throw new Error('No active MCP relay connection'); throw new Error('No active MCP relay connection');
this._pendingTabSelection.delete(tabId); this._pendingTabSelection.delete(selectorTabId);
this._activeConnection.setTabId(tabId); this._activeConnection.setTabId(tabId);
this._activeConnection.onclose = () => { this._activeConnection.onclose = () => {

View File

@@ -25,10 +25,9 @@ body {
background-color: #ffffff; background-color: #ffffff;
color: #1f2328; color: #1f2328;
margin: 0; margin: 0;
padding: 24px; padding: 16px;
min-height: 100vh; min-height: 100vh;
font-size: 14px; font-size: 14px;
line-height: 1.5;
} }
.content-wrapper { .content-wrapper {
@@ -36,50 +35,55 @@ body {
margin: 0 auto; margin: 0 auto;
} }
.main-title { /* Status Banner */
font-size: 32px; .status-container {
font-weight: 600; display: flex;
margin-bottom: 8px; align-items: center;
color: #1f2328; justify-content: space-between;
margin-bottom: 16px;
padding-right: 12px;
} }
/* Status Banner */
.status-banner { .status-banner {
padding: 16px; padding: 12px;
margin-bottom: 24px;
border-radius: 6px;
border: 1px solid;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
flex: 1;
} }
.status-banner.connected { .status-banner.connected {
background-color: #dafbe1; color: #1f2328;
border-color: #1a7f37; }
color: #0d5a23;
.status-banner.connected::before {
content: "\2705";
margin-right: 8px;
} }
.status-banner.error { .status-banner.error {
background-color: #ffebe9; color: #1f2328;
border-color: #da3633;
color: #a40e26;
} }
.status-banner.connecting { .status-banner.error::before {
background-color: #fff8c5; content: "\274C";
border-color: #d1b500; margin-right: 8px;
color: #7a5c00;
} }
/* Buttons */ /* Buttons */
.button-container { .button-container {
margin-bottom: 24px; margin-bottom: 16px;
display: flex;
justify-content: flex-end;
padding-right: 12px;
} }
.button { .button {
padding: 8px 16px; padding: 8px 16px;
border-radius: 6px; border-radius: 6px;
border: 1px solid; border: none;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
@@ -88,46 +92,63 @@ body {
justify-content: center; justify-content: center;
text-decoration: none; text-decoration: none;
margin-right: 8px; margin-right: 8px;
min-width: 90px;
} }
.button.primary { .button.primary {
background-color: #2da44e; background-color: #f8f9fa;
border-color: #2da44e; color: #3c4043;
color: #ffffff; border: 1px solid #dadce0;
} }
.button.primary:hover { .button.primary:hover {
background-color: #2c974b; background-color: #f1f3f4;
border-color: #dadce0;
box-shadow: 0 1px 2px 0 rgba(60,64,67,.1);
} }
.button.default { .button.default {
background-color: #f6f8fa; background-color: #f6f8fa;
border-color: #d1d9e0;
color: #24292f; color: #24292f;
} }
.button.default:hover { .button.default:hover {
background-color: #f3f4f6; background-color: #f3f4f6;
border-color: #c7d2da; }
.button.reject {
background-color: #da3633;
color: #ffffff;
border: 1px solid #da3633;
}
.button.reject:hover {
background-color: #c73836;
border-color: #c73836;
} }
/* Tab selection */ /* Tab selection */
.tab-section-title { .tab-section-title {
font-size: 20px; padding-left: 12px;
font-weight: 600; font-size: 12px;
margin-bottom: 16px; font-weight: 400;
color: #1f2328; margin-bottom: 12px;
color: #656d76;
} }
.tab-item { .tab-item {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 12px; padding: 12px;
border: 1px solid #d1d9e0;
border-radius: 6px;
margin-bottom: 8px; margin-bottom: 8px;
background-color: #ffffff; background-color: #ffffff;
cursor: pointer; cursor: pointer;
border-radius: 6px;
transition: background-color 0.2s ease;
}
.tab-item:hover {
background-color: #f8f9fa;
} }
.tab-item.selected { .tab-item.selected {

View File

@@ -29,7 +29,6 @@ type StatusType = 'connected' | 'error' | 'connecting';
const ConnectApp: React.FC = () => { const ConnectApp: React.FC = () => {
const [tabs, setTabs] = useState<TabInfo[]>([]); const [tabs, setTabs] = useState<TabInfo[]>([]);
const [selectedTab, setSelectedTab] = useState<TabInfo | undefined>();
const [status, setStatus] = useState<{ type: StatusType; message: string } | null>(null); const [status, setStatus] = useState<{ type: StatusType; message: string } | null>(null);
const [showButtons, setShowButtons] = useState(true); const [showButtons, setShowButtons] = useState(true);
const [showTabList, setShowTabList] = useState(true); const [showTabList, setShowTabList] = useState(true);
@@ -54,7 +53,7 @@ const ConnectApp: React.FC = () => {
setClientInfo(info); setClientInfo(info);
setStatus({ setStatus({
type: 'connecting', type: 'connecting',
message: `MCP client "${info}" is trying to connect. Do you want to continue?` message: `🎭 Playwright MCP started from "${info}" is trying to connect. Do you want to continue?`
}); });
} catch (e) { } catch (e) {
setStatus({ type: 'error', message: 'Failed to parse client version.' }); setStatus({ type: 'error', message: 'Failed to parse client version.' });
@@ -73,30 +72,22 @@ const ConnectApp: React.FC = () => {
const loadTabs = useCallback(async () => { const loadTabs = useCallback(async () => {
const response = await chrome.runtime.sendMessage({ type: 'getTabs' }); const response = await chrome.runtime.sendMessage({ type: 'getTabs' });
if (response.success) { if (response.success)
setTabs(response.tabs); setTabs(response.tabs);
const currentTab = response.tabs.find((tab: TabInfo) => tab.id === response.currentTabId); else
setSelectedTab(currentTab);
} else {
setStatus({ type: 'error', message: 'Failed to load tabs: ' + response.error }); setStatus({ type: 'error', message: 'Failed to load tabs: ' + response.error });
}
}, []); }, []);
const handleContinue = useCallback(async () => { const handleConnectToTab = useCallback(async (tab: TabInfo) => {
setShowButtons(false); setShowButtons(false);
setShowTabList(false); setShowTabList(false);
if (!selectedTab) {
setStatus({ type: 'error', message: 'Tab not selected.' });
return;
}
try { try {
const response = await chrome.runtime.sendMessage({ const response = await chrome.runtime.sendMessage({
type: 'connectToTab', type: 'connectToTab',
mcpRelayUrl, mcpRelayUrl,
tabId: selectedTab.id, tabId: tab.id,
windowId: selectedTab.windowId, windowId: tab.windowId,
}); });
if (response?.success) { if (response?.success) {
@@ -113,7 +104,7 @@ const ConnectApp: React.FC = () => {
message: `MCP client "${clientInfo}" failed to connect: ${e}` message: `MCP client "${clientInfo}" failed to connect: ${e}`
}); });
} }
}, [selectedTab, clientInfo, mcpRelayUrl]); }, [clientInfo, mcpRelayUrl]);
const handleReject = useCallback(() => { const handleReject = useCallback(() => {
setShowButtons(false); setShowButtons(false);
@@ -122,45 +113,41 @@ const ConnectApp: React.FC = () => {
}, []); }, []);
useEffect(() => { useEffect(() => {
chrome.runtime.onMessage.addListener(message => { const listener = (message: any) => {
if (message.type === 'connectionTimeout') if (message.type === 'connectionTimeout')
handleReject(); handleReject();
}); };
chrome.runtime.onMessage.addListener(listener);
return () => {
chrome.runtime.onMessage.removeListener(listener);
};
}, []); }, []);
return ( return (
<div className='app-container'> <div className='app-container'>
<div className='content-wrapper'> <div className='content-wrapper'>
<h1 className='main-title'> {status && (
Playwright MCP Extension <div className='status-container'>
</h1> <StatusBanner type={status.type} message={status.message} />
{showButtons && (
{status && <StatusBanner type={status.type} message={status.message} />} <Button variant='reject' onClick={handleReject}>
Reject
{showButtons && ( </Button>
<div className='button-container'> )}
<Button variant='primary' onClick={handleContinue}>
Continue
</Button>
<Button variant='default' onClick={handleReject}>
Reject
</Button>
</div> </div>
)} )}
{showTabList && ( {showTabList && (
<div> <div>
<h2 className='tab-section-title'> <div className='tab-section-title'>
Select page to expose to MCP server: Select page to expose to MCP server:
</h2> </div>
<div> <div>
{tabs.map(tab => ( {tabs.map(tab => (
<TabItem <TabItem
key={tab.id} key={tab.id}
tab={tab} tab={tab}
isSelected={selectedTab?.id === tab.id} onConnect={() => handleConnectToTab(tab)}
onSelect={() => setSelectedTab(tab)}
/> />
))} ))}
</div> </div>
@@ -175,7 +162,7 @@ const StatusBanner: React.FC<{ type: StatusType; message: string }> = ({ type, m
return <div className={`status-banner ${type}`}>{message}</div>; return <div className={`status-banner ${type}`}>{message}</div>;
}; };
const Button: React.FC<{ variant: 'primary' | 'default'; onClick: () => void; children: React.ReactNode }> = ({ const Button: React.FC<{ variant: 'primary' | 'default' | 'reject'; onClick: () => void; children: React.ReactNode }> = ({
variant, variant,
onClick, onClick,
children children
@@ -187,20 +174,12 @@ const Button: React.FC<{ variant: 'primary' | 'default'; onClick: () => void; ch
); );
}; };
const TabItem: React.FC<{ tab: TabInfo; isSelected: boolean; onSelect: () => void }> = ({ const TabItem: React.FC<{ tab: TabInfo; onConnect: () => void }> = ({
tab, tab,
isSelected, onConnect
onSelect
}) => { }) => {
const className = `tab-item ${isSelected ? 'selected' : ''}`.trim();
return ( return (
<div className={className} onClick={onSelect}> <div className='tab-item'>
<input
type='radio'
className='tab-radio'
checked={isSelected}
/>
<img <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>'} 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='' alt=''
@@ -210,6 +189,9 @@ const TabItem: React.FC<{ tab: TabInfo; isSelected: boolean; onSelect: () => voi
<div className='tab-title'>{tab.title || 'Untitled'}</div> <div className='tab-title'>{tab.title || 'Untitled'}</div>
<div className='tab-url'>{tab.url}</div> <div className='tab-url'>{tab.url}</div>
</div> </div>
<Button variant='primary' onClick={onConnect}>
Connect
</Button>
</div> </div>
); );
}; };

View File

@@ -94,7 +94,7 @@ test('navigate with extension', async ({ browserWithExtension, startClient, serv
}); });
const selectorPage = await confirmationPagePromise; const selectorPage = await confirmationPagePromise;
await selectorPage.getByRole('button', { name: 'Continue' }).click(); await selectorPage.locator('.tab-item', { hasText: 'Playwright MCP Extension' }).getByRole('button', { name: 'Connect' }).click();
expect(await navigateResponse).toHaveResponse({ expect(await navigateResponse).toHaveResponse({
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`), pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),