Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/src/api/class-page.md
Original file line number Diff line number Diff line change
Expand Up @@ -2722,6 +2722,11 @@ Returns whether the element is [visible](../actionability.md#visible). [`param:
- type: <[Keyboard]>


## async method: Page.clearConsoleMessages
* since: v1.59

Clears all stored console messages from this page. Subsequent calls to [`method: Page.consoleMessages`] will only return messages logged after the clear.

## async method: Page.consoleMessages
* since: v1.56
- returns: <[Array]<[ConsoleMessage]>>
Expand Down
4 changes: 3 additions & 1 deletion packages/devtools/src/devtools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ function tabFavicon(url: string): string {
}
}

export const DevTools: React.FC<{ wsUrl: string }> = ({ wsUrl }) => {
export const DevTools: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => {
const [status, setStatus] = React.useState<{ text: string; cls: string }>({ text: 'Connecting', cls: '' });
const [tabs, setTabs] = React.useState<TabInfo[]>([]);
const [selectedPageId, setSelectedPageId] = React.useState<string | undefined>();
Expand All @@ -55,6 +55,8 @@ export const DevTools: React.FC<{ wsUrl: string }> = ({ wsUrl }) => {
}, [captured]);

React.useEffect(() => {
if (!wsUrl)
return;
const transport = new DevToolsTransport(wsUrl);
transportRef.current = transport;

Expand Down
47 changes: 40 additions & 7 deletions packages/devtools/src/grid.css
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,40 @@

.workspace-header {
font-size: 13px;
font-weight: 600;
color: var(--fg-dim);
margin-bottom: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: flex;
align-items: center;
}

.workspace-header.collapsible {
cursor: pointer;
user-select: none;
}

.workspace-chevron {
width: 16px;
height: 16px;
flex-shrink: 0;
margin-right: 4px;
transition: transform 0.15s ease;
}

.workspace-chevron.expanded {
transform: rotate(90deg);
}

.workspace-name {
font-weight: 600;
}

.workspace-path {
margin-left: 6px;
color: var(--fg-muted);
font-weight: 400;
}

.session-chips {
Expand Down Expand Up @@ -106,24 +134,29 @@
color: var(--fg-muted);
}

.session-chip-close {
.session-chip.disconnected {
opacity: 0.6;
cursor: default;
}

.session-chip-action {
width: 20px;
height: 20px;
border-radius: 50%;
margin-left: auto;
opacity: 0;
}

.session-chip-close svg {
width: 10px;
height: 10px;
.session-chip-action svg {
width: 12px;
height: 12px;
}

.session-chip:hover .session-chip-close {
.session-chip:hover .session-chip-action {
opacity: 1;
}

.session-chip-close:hover {
.session-chip-action:hover {
background: rgba(255, 255, 255, 0.12);
}

Expand Down
262 changes: 103 additions & 159 deletions packages/devtools/src/grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,127 +20,30 @@ import { navigate } from './index';
import { Screencast } from './screencast';

import type { SessionConfig } from '../../playwright/src/mcp/terminal/registry';
import type { SessionModel, SessionStatus } from './sessionModel';

type SessionStatus = {
config: SessionConfig;
canConnect: boolean;
};

export const Grid: React.FC = () => {
const [sessions, setSessions] = React.useState<SessionStatus[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | undefined>();
const [screencastUrls, setScreencastUrls] = React.useState<Record<string, string>>({});

const lastJsonRef = React.useRef<string>('');
const knownTimestampsRef = React.useRef<Map<string, number>>(new Map());
const startingRef = React.useRef<Set<string>>(new Set());

async function fetchSessions() {
try {
const response = await fetch('/api/sessions/list');
if (!response.ok)
throw new Error(`HTTP ${response.status}`);
const text = await response.text();
if (text !== lastJsonRef.current) {
lastJsonRef.current = text;
setSessions(JSON.parse(text));
}
setError(undefined);
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
}

React.useEffect(() => {
let active = true;
let timeoutId: ReturnType<typeof setTimeout>;
async function poll() {
await fetchSessions();
if (active)
timeoutId = setTimeout(poll, 3000);
}
poll();
return () => { active = false; clearTimeout(timeoutId); };
}, []);

// Manage screencast lifecycle when sessions change.
React.useEffect(() => {
let active = true;
const liveSockets = new Set<string>();

for (const { config, canConnect } of sessions) {
if (!canConnect)
continue;
const key = config.socketPath;
liveSockets.add(key);

const known = knownTimestampsRef.current.get(key);
if (known === config.timestamp)
continue;
if (startingRef.current.has(key))
continue;

knownTimestampsRef.current.set(key, config.timestamp);
startingRef.current.add(key);

void (async () => {
try {
const resp = await fetch('/api/sessions/start-screencast', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config }),
});
if (!resp.ok)
throw new Error();
const { url } = await resp.json();
if (active)
setScreencastUrls(prev => ({ ...prev, [key]: url }));
} catch {
knownTimestampsRef.current.delete(key);
} finally {
startingRef.current.delete(key);
}
})();
}

// Clean up sessions that are no longer live.
setScreencastUrls(prev => {
const next = { ...prev };
let changed = false;
for (const key of Object.keys(next)) {
if (!liveSockets.has(key)) {
delete next[key];
knownTimestampsRef.current.delete(key);
changed = true;
}
}
return changed ? next : prev;
});

return () => { active = false; };
}, [sessions]);

// Clear all screencasts on unmount.
React.useEffect(() => {
return () => setScreencastUrls({});
}, []);
export const Grid: React.FC<{ model: SessionModel }> = ({ model }) => {
const [expandedWorkspaces, setExpandedWorkspaces] = React.useState<Set<string>>(new Set());
const sessions = model.sessions;
const clientInfo = model.clientInfo;

function browserLabel(config: SessionConfig): string {
if (config.resolvedConfig)
return config.resolvedConfig.browser.launchOptions.channel ?? config.resolvedConfig.browser.browserName;
return config.cli.browser || 'chromium';
}

function headedLabel(config: SessionConfig): string {
if (config.resolvedConfig)
return config.resolvedConfig.browser.launchOptions.headless ? 'headless' : 'headed';
return config.cli.headed ? 'headed' : 'headless';
function toggleWorkspace(workspace: string) {
setExpandedWorkspaces(prev => {
const next = new Set(prev);
if (next.has(workspace))
next.delete(workspace);
else
next.add(workspace);
return next;
});
}


const workspaceGroups = React.useMemo(() => {
const groups = new Map<string, SessionStatus[]>();
for (const session of sessions) {
Expand All @@ -154,59 +57,100 @@ export const Grid: React.FC = () => {
}
for (const list of groups.values())
list.sort((a, b) => a.config.name.localeCompare(b.config.name));
return [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0]));
}, [sessions]);

// Current workspace first, then alphabetical.
const entries = [...groups.entries()];
const current = entries.filter(([key]) => key === clientInfo?.workspaceDir);
const other = entries.filter(([key]) => key !== clientInfo?.workspaceDir).sort((a, b) => a[0].localeCompare(b[0]));
return [...current, ...other];
}, [sessions, clientInfo?.workspaceDir]);

function renderSessionChip(config: SessionConfig, canConnect: boolean, visible: boolean) {
const href = '#session=' + encodeURIComponent(config.socketPath);
const wsUrl = model.wsUrls.get(config.socketPath);
return (
<a key={config.socketPath} className={'session-chip' + (canConnect ? '' : ' disconnected')} href={canConnect ? href : undefined} onClick={e => {
e.preventDefault(); if (canConnect)
navigate(href);
}}>
<div className='session-chip-header'>
<div className={'session-status-dot ' + (canConnect ? 'open' : 'closed')} />
<span className='session-chip-name'>{config.name}</span>
<span className='session-chip-detail'>{browserLabel(config)}</span>
<span className='session-chip-detail'>v{config.version}</span>
{canConnect && (
<button
className='session-chip-action'
title='Close session'
onClick={e => {
e.preventDefault();
e.stopPropagation();
void model.closeSession(config);
}}
>
<svg viewBox='0 0 12 12' fill='none' stroke='currentColor' strokeWidth='1.5' strokeLinecap='round'>
<line x1='2' y1='2' x2='10' y2='10'/>
<line x1='10' y1='2' x2='2' y2='10'/>
</svg>
</button>
)}
{!canConnect && (
<button
className='session-chip-action'
title='Delete session data'
onClick={e => {
e.preventDefault();
e.stopPropagation();
void model.deleteSessionData(config);
}}
>
<svg viewBox='0 0 16 16' fill='none' stroke='currentColor' strokeWidth='1.2' strokeLinecap='round' strokeLinejoin='round'>
<path d='M2 4h12'/>
<path d='M5 4V3a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1'/>
<path d='M4 4l.8 9a1 1 0 0 0 1 .9h4.4a1 1 0 0 0 1-.9L12 4'/>
</svg>
</button>
)}
</div>
<div className='screencast-container'>
{canConnect && visible && wsUrl && <Screencast wsUrl={wsUrl} />}
{!canConnect && <div className='screencast-placeholder'>Session closed</div>}
</div>
</a>
);
}

return (<div className='grid-view'>
{loading && sessions.length === 0 && <div className='grid-loading'>Loading sessions...</div>}
{error && <div className='grid-error'>Error: {error}</div>}
{!loading && !error && sessions.length === 0 && <div className='grid-empty'>No sessions found.</div>}
{model.loading && sessions.length === 0 && <div className='grid-loading'>Loading sessions...</div>}
{model.error && <div className='grid-error'>Error: {model.error}</div>}
{!model.loading && !model.error && sessions.length === 0 && <div className='grid-empty'>No sessions found.</div>}

<div className='workspace-list'>
{workspaceGroups.map(([workspace, entries]) => (
<div key={workspace} className='workspace-group'>
<div className='workspace-header'>{workspace}</div>
<div className='session-chips'>
{entries.map(({ config, canConnect }) => {
const href = '#session=' + encodeURIComponent(config.socketPath);
return (
<a key={config.socketPath} className='session-chip' href={href} onClick={e => { e.preventDefault(); navigate(href); }}>
<div className='session-chip-header'>
<div className={'session-status-dot ' + (canConnect ? 'open' : 'closed')} />
<span className='session-chip-name'>{config.name}</span>
<span className='session-chip-detail'>{browserLabel(config)}</span>
<span className='session-chip-detail'>{headedLabel(config)}</span>
<span className='session-chip-detail'>v{config.version}</span>
<button
className='session-chip-close'
title='Close session'
onClick={e => {
e.preventDefault();
e.stopPropagation();
void fetch('/api/sessions/close', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config }),
}).then(() => fetchSessions());
}}
>
<svg viewBox='0 0 12 12' fill='none' stroke='currentColor' strokeWidth='1.5' strokeLinecap='round'>
<line x1='2' y1='2' x2='10' y2='10'/>
<line x1='10' y1='2' x2='2' y2='10'/>
</svg>
</button>
</div>
<div className='screencast-container'>
{screencastUrls[config.socketPath] && (
<Screencast wsUrl={screencastUrls[config.socketPath]} />
)}
</div>
</a>
);
})}
{workspaceGroups.map(([workspace, entries]) => {
const isCurrent = workspace === clientInfo?.workspaceDir;
const isExpanded = isCurrent || expandedWorkspaces.has(workspace);
return (
<div key={workspace} className='workspace-group'>
<div
className={'workspace-header' + (isCurrent ? '' : ' collapsible')}
onClick={isCurrent ? undefined : () => toggleWorkspace(workspace)}
>
{!isCurrent && (
<svg className={'workspace-chevron' + (isExpanded ? ' expanded' : '')} viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='2' strokeLinecap='round' strokeLinejoin='round'>
<polyline points='9 18 15 12 9 6'/>
</svg>
)}
<span className='workspace-name'>{workspace.split('/').pop() || workspace}</span>
<span className='workspace-path'>&mdash; {workspace}</span>
</div>
{isExpanded && (
<div className='session-chips'>
{entries.map(({ config, canConnect }) => renderSessionChip(config, canConnect, isExpanded))}
</div>
)}
</div>
</div>
))}
);
})}
</div>
</div>);
};
Loading
Loading