playwright-mcp
by Ashish-Bansal
Verified
import React, { useEffect, useRef } from 'react';
import { Maximize, StopCircle, Image, CircleXIcon, GlobeIcon, KeyboardIcon, MousePointerClickIcon, TextCursorInputIcon, CodeIcon, PlusIcon } from 'lucide-react';
import { useGlobalState } from '@/hooks/use-global-stage';
import { Button } from '@/components/ui/button';
import { ScrollArea } from "@/components/ui/scroll-area";
import { Card, CardContent } from '@/components/ui/card';
import { ClickToEdit } from '@/components/ui/click-to-edit';
import { BrowserEvent, BrowserEventType } from '@/mcp/recording/events';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
interface MessageProps {
message: Message;
onDelete: (content: string) => void;
}
const truncate = (text: string, maxLength = 25) => {
return text.length > maxLength ? text.slice(0, maxLength) + '...' : text
}
const MessageCard: React.FC<{
icon: React.ReactNode,
title: React.ReactNode,
content?: React.ReactNode,
onDelete: () => void
}> = ({ icon, title, content, onDelete }) => {
return (
<Card className="group py-4 rounded-sm">
<CardContent className="px-4 flex gap-2 flex-col">
<div className="flex gap-2">
<div className="flex flex-1 gap-2">
<div className="w-5 h-5 flex items-center justify-center">
{icon}
</div>
<div className="text-sm font-medium text-gray-800">{title}</div>
</div>
<div className="">
<CircleXIcon className="w-4 h-4 transition-opacity duration-200 opacity-0 group-hover:opacity-100 hover:text-destructive cursor-pointer" onClick={onDelete} />
</div>
</div>
{content && (
<div className="mt-2">
{content}
</div>
)}
</CardContent>
</Card>
);
};
const renderInteraction = (message: Message, deleteMessage: () => void) => {
const rawInteraction = JSON.parse(message.content);
const interaction = rawInteraction as BrowserEvent;
const getIcon = (type: BrowserEventType) => {
switch (type) {
case BrowserEventType.Click:
return <MousePointerClickIcon />;
case BrowserEventType.Input:
return <TextCursorInputIcon />;
case BrowserEventType.KeyPress:
return <KeyboardIcon />;
case BrowserEventType.OpenPage:
return <GlobeIcon />;
default:
return <GlobeIcon />;
}
};
const getText = (interaction: BrowserEvent) => {
switch (interaction.type) {
case BrowserEventType.Click:
return <>Click on <span className="font-bold text-gray-600 ">"{truncate(interaction.elementName || '')}"</span> {truncate(interaction.elementType || '')}</>;
case BrowserEventType.Input:
return <>Type <span className="font-bold text-gray-600 ">"{truncate(interaction.typedText || '')}"</span> in <span className="font-bold text-gray-600">{truncate(interaction.elementName || '')}</span></>;
case BrowserEventType.KeyPress:
return <>Press <span className="font-bold text-gray-600 ">{interaction.keys.join(' + ')}</span> key{interaction.keys.length > 1 ? 's' : ''}</>;
case BrowserEventType.OpenPage:
return <>Navigate to <span className="font-bold text-gray-600 ">{truncate(interaction.windowUrl || '')}</span></>;
default:
return <>Unknown interaction</>;
}
};
const selector = 'selectors' in interaction ? interaction.selectors?.[0] : undefined;
return (
<MessageCard
icon={getIcon(interaction.type)}
title={getText(interaction)}
content={selector && (
<div className="flex flex-col gap-2">
<ClickToEdit className="text-xs text-muted-foreground bg-gray-100 rounded-sm p-1" placeholder="CSS selector e.g. [data-testid='button']" text={selector} onSave={() => { }} />
</div>
)}
onDelete={deleteMessage}
/>
);
};
const renderImage = (message: Message, deleteMessage: () => void) => {
return (
<MessageCard
icon={<Image className="text-gray-600" />}
title="Screenshot captured"
content={
<img
src={`data:image/png;base64,${message.content}`}
className="rounded w-full"
alt="Screenshot"
/>
}
onDelete={deleteMessage}
/>
);
};
const renderDom = (message: Message, deleteMessage: () => void) => {
const chars = message.content.length;
return (
<MessageCard
icon={<CodeIcon className="text-gray-600" />}
title="DOM Element captured"
content={
<div className="bg-gray-50 p-3 rounded">
<div className="font-mono text-xs overflow-x-auto break-all">
{message.content.length > 300 ? message.content.slice(0, 297) + '...' : message.content}
</div>
<div className="text-xs text-muted-foreground mt-2">
{chars} characters
</div>
</div>
}
onDelete={deleteMessage}
/>
);
};
const MessageComponent: React.FC<MessageProps> = ({ message, onDelete }) => {
const deleteMessage = () => onDelete(message.content);
if (message.type === 'Interaction') {
return renderInteraction(message, deleteMessage);
}
if (message.type === 'Image') {
return renderImage(message, deleteMessage);
}
if (message.type === 'DOM') {
return renderDom(message, deleteMessage);
}
return null;
};
const Context: React.FC = () => {
const [state, updateState] = useGlobalState();
const messagesContainerRef = useRef<HTMLDivElement>(null);
const prevMessagesLength = useRef(state.messages.length);
const isFirstRender = useRef(true);
const scrollToBottom = () => {
if (messagesContainerRef.current) {
const scrollArea = messagesContainerRef.current.querySelector('[data-radix-scroll-area-viewport]');
if (scrollArea) {
scrollArea.scrollTo({
top: scrollArea.scrollHeight,
behavior: 'smooth'
});
}
}
};
useEffect(() => {
if (state.messages.length > prevMessagesLength.current) {
setTimeout(() => {
scrollToBottom();
}, 200);
}
prevMessagesLength.current = state.messages.length;
}, [state.messages.length]);
useEffect(() => {
if (isFirstRender.current) {
setTimeout(() => {
scrollToBottom();
}, 500);
isFirstRender.current = false;
}
}, []);
const handleDelete = (content: string) => {
updateState({
...state,
messages: state.messages.filter((m: Message) => m.content !== content)
});
};
const stopPicking = () => {
updateState({
...state,
pickingType: null
});
window.triggerMcpStopPicking();
};
const startPicking = (type: 'DOM' | 'Image') => {
updateState({
...state,
pickingType: type
});
window.triggerMcpStartPicking(type);
};
const toggleRecordingInteractions = () => {
updateState({
...state,
recordingInteractions: !state.recordingInteractions
});
};
const messageGroups: Message[][] = []
state.messages.forEach((message: Message) => {
const url = message.windowUrl
const lastMessageGroup = messageGroups.length > 0 ? messageGroups[messageGroups.length - 1] : null
if (!lastMessageGroup || lastMessageGroup[0].windowUrl !== url) {
messageGroups.push([message])
} else {
lastMessageGroup.push(message)
}
});
const recordingInteractions = state.recordingInteractions;
return (
<div className="flex-1 flex flex-col h-full bg-white">
<div className="p-4 flex gap-2">
<Button
onClick={toggleRecordingInteractions}
className="w-40"
>
<div className="flex items-center gap-2">
{recordingInteractions ? (
<div className="w-3 h-3 bg-red-500" />
) : (
<div className="w-4 h-4 rounded-full bg-red-500" />
)}
{recordingInteractions ? 'Stop Recording' : 'Start Recording'}
</div>
</Button>
</div>
<ScrollArea ref={messagesContainerRef} className="flex-1 max-h-[calc(100vh-194px)] overflow-y-auto">
{(messageGroups.length > 0 || recordingInteractions) ? (
<div className="flex flex-col gap-8 p-4">
{messageGroups.map((messageGroup: Message[], index: number) => (
<div key={index} className="flex flex-col gap-4">
<div className="text-sm font-medium text-gray-800 px-1">On page <span className="font-bold text-gray-600 break-all">{truncate(messageGroup[0].windowUrl || '', 120)}</span></div>
<div className="flex flex-col gap-2">
{messageGroup.map((message: Message, index: number) => (
<MessageComponent
key={index}
message={message}
onDelete={handleDelete}
/>
))}
</div>
</div>
))}
{recordingInteractions && (
<div className="flex items-center gap-1 mb-8">
<div className="mr-2">
<div className="w-5 h-5 rounded-full border border-dotted border-gray-300 flex items-center justify-center">
<div className="w-2 h-2 rounded-full bg-red-500 animate-pulse"></div>
</div>
</div>
<div className="text-gray-700">
Recording interaction with browser
</div>
<div className="ml-auto relative">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<PlusIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="z-[999999]">
<DropdownMenuItem onSelect={() => startPicking('DOM')}>
<Maximize className="mr-2 h-4 w-4" />
<span>Select DOM Element</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => startPicking('Image')}>
<Image className="mr-2 h-4 w-4" />
<span>Take Screenshot</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)}
</div>
) : (
<div className="flex flex-col gap-2 p-4">
<div className="text-sm text-muted-foreground">
No interactions recorded yet!
<br />
<br />
Click 'Start Recording' to record interactions.
<br />
<br />
Once you are done with it, go to your MCP server (like Claude, Cursor),
ask it to pull context using `get-context` tool and give it instructions
on what kind of testcase to write .
</div>
</div>
)}
</ScrollArea>
</div>
);
};
export default Context;