feat: add integrated web preview panel with element selection

- Implement WebPreviewProvider for managing preview panel
- Add element selection overlay with DOM inspection
- Extract comprehensive element context (HTML, CSS, position, etc.)
- Integrate selected element context with AI chat
- Add responsive controls and device simulation
- Support multiple device viewports (mobile, tablet, desktop)
- Handle cross-origin restrictions gracefully
- Add comprehensive tests for WebPreviewProvider
- Update documentation with usage guide

Closes #5971
This commit is contained in:
Roo Code 2025-07-20 05:37:14 +00:00
parent de13d8a5cf
commit e6b80cc40d
13 changed files with 1344 additions and 0 deletions

View file

@ -92,6 +92,7 @@ Roo Code comes with powerful [tools](https://docs.roocode.com/basic-usage/how-to
- Read and write files in your project
- Execute commands in your VS Code terminal
- Control a web browser
- **Preview web applications** and select UI elements for AI context
- Use external tools via [MCP (Model Context Protocol)](https://docs.roocode.com/advanced-usage/mcp)
MCP extends Roo Code's capabilities by allowing you to add unlimited custom tools. Integrate with external APIs, connect to databases, or create specialized development tools - MCP provides the framework to expand Roo Code's functionality to meet your specific needs.

123
docs/web-preview.md Normal file
View file

@ -0,0 +1,123 @@
# Web Preview Feature
The Web Preview feature in Roo Code allows developers to preview web applications directly within VSCode and select UI elements to provide context to the AI assistant. This feature is similar to Windsurf IDE's preview functionality.
## Features
- **Integrated Web Preview**: View web applications in a dedicated panel within VSCode
- **Element Selection**: Click on UI elements to select them and extract their context
- **Responsive Design Testing**: Switch between different device viewports
- **Element Context Extraction**: Automatically extract HTML, CSS, position, and other metadata
- **AI Integration**: Selected element context is automatically sent to the AI assistant
## How to Use
### Opening the Web Preview
1. Open the Web Preview panel by:
- Using the command palette: `Cmd/Ctrl + Shift + P` → "Roo Code: Open Web Preview"
- Clicking on the Web Preview icon in the Roo Code sidebar
### Loading a URL
1. Enter the URL in the address bar at the top of the preview panel
2. Click "Go" or press Enter to load the page
### Selecting Elements
1. Click the "🎯 Select Element" button to enable element selection mode
2. Hover over elements in the preview to see them highlighted
3. Click on an element to select it
4. The element's context will be automatically sent to the AI chat
### Device Simulation
Use the device selector dropdown to switch between different viewport sizes:
- Responsive (default)
- iPhone SE (375x667)
- iPhone 12/13 (390x844)
- iPad (768x1024)
- Desktop (1280x800)
- Full HD (1920x1080)
## Element Context
When you select an element, the following information is extracted and sent to the AI:
- **HTML**: The complete HTML of the selected element
- **CSS**: All CSS rules that apply to the element
- **Position**: X, Y coordinates and dimensions (width, height)
- **Computed Styles**: Key style properties like display, position, colors, fonts
- **Attributes**: All HTML attributes on the element
- **Selectors**: Both CSS selector and XPath for the element
## Example Use Cases
1. **UI Debugging**: Select a misaligned element and ask the AI to fix the CSS
2. **Component Analysis**: Select a component and ask the AI to explain how it works
3. **Style Improvements**: Select an element and ask for design suggestions
4. **Accessibility**: Select elements and ask for accessibility improvements
5. **Code Generation**: Select a UI pattern and ask the AI to create similar components
## Limitations
- **Cross-Origin Restrictions**: Element inspection may not work on pages with strict CORS policies
- **IFrame Content**: Cannot inspect elements inside cross-origin iframes
- **Dynamic Content**: Some dynamically loaded content may not be immediately selectable
## Technical Details
The Web Preview feature consists of:
1. **WebPreviewProvider**: Main provider class that manages the preview panel
2. **Preview UI**: HTML/CSS/JS for the preview interface and controls
3. **Element Inspector**: JavaScript injection for element selection and context extraction
4. **Message Passing**: Communication between the preview, extension, and AI chat
## API Reference
### WebPreviewProvider
```typescript
class WebPreviewProvider {
// Load a URL in the preview
loadUrl(url: string): Promise<void>
// Set viewport dimensions
setViewport(width: number, height: number): Promise<void>
// Get the last selected element context
getSelectedElementContext(): ElementContext | undefined
}
```
### ElementContext Interface
```typescript
interface ElementContext {
html: string
css: string
position: {
x: number
y: number
width: number
height: number
}
computedStyles?: Record<string, string>
attributes?: Record<string, string>
xpath?: string
selector?: string
}
```
## Contributing
To contribute to the Web Preview feature:
1. The main code is in `src/core/webview/WebPreviewProvider.ts`
2. Preview UI assets are in `src/core/webview/preview/`
3. Tests are in `src/__tests__/WebPreviewProvider.spec.ts`
4. Message types are defined in `src/shared/ExtensionMessage.ts`
Please ensure all tests pass and add new tests for any new functionality.

View file

@ -53,6 +53,7 @@ export const commandIds = [
"focusInput",
"acceptInput",
"focusPanel",
"openWebPreview",
] as const
export type CommandId = (typeof commandIds)[number]

View file

@ -0,0 +1,285 @@
import * as vscode from "vscode"
import { WebPreviewProvider, ElementContext, WebPreviewMessage } from "../core/webview/WebPreviewProvider"
import { ClineProvider } from "../core/webview/ClineProvider"
import { ExtensionMessage } from "../shared/ExtensionMessage"
// Mock dependencies
vitest.mock("vscode")
vitest.mock("../core/webview/getNonce", () => ({
getNonce: vitest.fn().mockReturnValue("test-nonce"),
}))
vitest.mock("../core/webview/getUri", () => ({
getUri: vitest.fn().mockReturnValue("test-uri"),
}))
describe("WebPreviewProvider", () => {
let mockContext: vscode.ExtensionContext
let mockOutputChannel: vscode.OutputChannel
let mockWebview: vscode.Webview
let mockWebviewView: vscode.WebviewView
let mockClineProvider: ClineProvider
let provider: WebPreviewProvider
beforeEach(() => {
vitest.clearAllMocks()
// Mock output channel
mockOutputChannel = {
appendLine: vitest.fn(),
append: vitest.fn(),
clear: vitest.fn(),
show: vitest.fn(),
hide: vitest.fn(),
dispose: vitest.fn(),
} as unknown as vscode.OutputChannel
// Mock extension context
mockContext = {
extensionUri: { fsPath: "/mock/extension" },
subscriptions: [],
} as unknown as vscode.ExtensionContext
// Mock webview
mockWebview = {
options: {},
html: "",
postMessage: vitest.fn().mockResolvedValue(undefined),
onDidReceiveMessage: vitest.fn().mockReturnValue({ dispose: vitest.fn() }),
cspSource: "test-csp-source",
} as unknown as vscode.Webview
// Mock webview view
mockWebviewView = {
webview: mockWebview,
onDidDispose: vitest.fn().mockReturnValue({ dispose: vitest.fn() }),
} as unknown as vscode.WebviewView
// Mock ClineProvider
mockClineProvider = {
postMessageToWebview: vitest.fn().mockResolvedValue(undefined),
} as unknown as ClineProvider
// Create provider instance
provider = new WebPreviewProvider(mockContext, mockOutputChannel)
})
describe("resolveWebviewView", () => {
it("should set up webview with correct options", async () => {
await provider.resolveWebviewView(mockWebviewView, {} as any, {} as any)
expect(mockWebview.options).toEqual({
enableScripts: true,
localResourceRoots: [mockContext.extensionUri],
})
})
it("should set HTML content with preview controls", async () => {
await provider.resolveWebviewView(mockWebviewView, {} as any, {} as any)
expect(mockWebview.html).toContain("urlInput")
expect(mockWebview.html).toContain("deviceSelector")
expect(mockWebview.html).toContain("toggleInspector")
expect(mockWebview.html).toContain("preview")
})
it("should register message handler", async () => {
await provider.resolveWebviewView(mockWebviewView, {} as any, {} as any)
expect(mockWebview.onDidReceiveMessage).toHaveBeenCalled()
})
})
describe("handleWebviewMessage", () => {
beforeEach(async () => {
provider.setClineProvider(mockClineProvider)
await provider.resolveWebviewView(mockWebviewView, {} as any, {} as any)
})
it("should handle elementSelected message", async () => {
const elementContext: ElementContext = {
html: "<div>Test</div>",
css: ".test { color: red; }",
position: { x: 10, y: 20, width: 100, height: 50 },
selector: ".test",
xpath: "//div[@class='test']",
}
const message: WebPreviewMessage = {
type: "elementSelected",
elementContext,
}
// Get the message handler
const messageHandler = vitest.mocked(mockWebview.onDidReceiveMessage).mock.calls[0][0]
await messageHandler(message)
// Verify ClineProvider was called with formatted message
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith(
expect.objectContaining({
type: "webPreviewElementSelected",
text: expect.stringContaining("Selected element context"),
elementContext,
}),
)
})
it("should handle urlChanged message", async () => {
const message: WebPreviewMessage = {
type: "urlChanged",
url: "https://example.com",
}
const messageHandler = vitest.mocked(mockWebview.onDidReceiveMessage).mock.calls[0][0]
await messageHandler(message)
// Verify URL was stored
expect(provider["currentUrl"]).toBe("https://example.com")
})
it("should handle error message", async () => {
const message: WebPreviewMessage = {
type: "error",
error: "Test error",
}
const messageHandler = vitest.mocked(mockWebview.onDidReceiveMessage).mock.calls[0][0]
await messageHandler(message)
// Verify error was logged
expect(mockOutputChannel.appendLine).toHaveBeenCalledWith("Web Preview Error: Test error")
})
it("should handle previewReady message", async () => {
const message: WebPreviewMessage = {
type: "previewReady",
}
const messageHandler = vitest.mocked(mockWebview.onDidReceiveMessage).mock.calls[0][0]
await messageHandler(message)
// Verify ready message was logged
expect(mockOutputChannel.appendLine).toHaveBeenCalledWith("Web preview ready")
})
})
describe("formatElementContext", () => {
it("should format element context correctly", () => {
const context: ElementContext = {
html: "<div>Test</div>",
css: ".test { color: red; }",
position: { x: 10, y: 20, width: 100, height: 50 },
selector: ".test",
xpath: "//div[@class='test']",
}
const formatted = provider["formatElementContext"](context)
expect(formatted).toContain("HTML:")
expect(formatted).toContain("<div>Test</div>")
expect(formatted).toContain("CSS:")
expect(formatted).toContain(".test { color: red; }")
expect(formatted).toContain("Position: 10, 20 (100x50)")
expect(formatted).toContain("CSS Selector: .test")
expect(formatted).toContain("XPath: //div[@class='test']")
})
it("should handle missing optional fields", () => {
const context: ElementContext = {
html: "<div>Test</div>",
css: "",
position: { x: 0, y: 0, width: 0, height: 0 },
}
const formatted = provider["formatElementContext"](context)
expect(formatted).toContain("HTML:")
expect(formatted).not.toContain("CSS Selector:")
expect(formatted).not.toContain("XPath:")
})
})
describe("loadUrl", () => {
it("should post loadUrl message to webview", async () => {
await provider.resolveWebviewView(mockWebviewView, {} as any, {} as any)
await provider.loadUrl("https://example.com")
expect(mockWebview.postMessage).toHaveBeenCalledWith({
type: "loadUrl",
url: "https://example.com",
})
})
it("should not post message if view is not initialized", async () => {
await provider.loadUrl("https://example.com")
expect(mockWebview.postMessage).not.toHaveBeenCalled()
})
})
describe("setViewport", () => {
it("should post setViewport message to webview", async () => {
await provider.resolveWebviewView(mockWebviewView, {} as any, {} as any)
await provider.setViewport(1024, 768)
expect(mockWebview.postMessage).toHaveBeenCalledWith({
type: "setViewport",
width: 1024,
height: 768,
})
})
it("should not post message if view is not initialized", async () => {
await provider.setViewport(1024, 768)
expect(mockWebview.postMessage).not.toHaveBeenCalled()
})
})
describe("getSelectedElementContext", () => {
it("should return stored element context", async () => {
const elementContext: ElementContext = {
html: "<div>Test</div>",
css: ".test { color: red; }",
position: { x: 10, y: 20, width: 100, height: 50 },
}
provider["selectedElementContext"] = elementContext
expect(provider.getSelectedElementContext()).toEqual(elementContext)
})
it("should return undefined if no context is stored", () => {
expect(provider.getSelectedElementContext()).toBeUndefined()
})
})
describe("getInstance", () => {
it("should return the singleton instance", () => {
const instance = WebPreviewProvider.getInstance()
expect(instance).toBe(provider)
})
it("should return undefined if no instance exists", () => {
// Create new provider without storing as singleton
const newProvider = new WebPreviewProvider(mockContext, mockOutputChannel)
newProvider.dispose()
expect(WebPreviewProvider.getInstance()).toBeUndefined()
})
})
describe("dispose", () => {
it("should clean up resources", async () => {
await provider.resolveWebviewView(mockWebviewView, {} as any, {} as any)
const disposeSpy = vitest.fn()
provider["disposables"].push({ dispose: disposeSpy })
provider.dispose()
expect(disposeSpy).toHaveBeenCalled()
expect(provider["view"]).toBeUndefined()
expect(WebPreviewProvider.getInstance()).toBeUndefined()
})
})
})

View file

@ -218,6 +218,10 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt
visibleProvider.postMessageToWebview({ type: "acceptInput" })
},
openWebPreview: () => {
// Focus on the web preview panel
vscode.commands.executeCommand("roo-cline.WebPreviewProvider.focus")
},
})
export const openClineInNewTab = async ({ context, outputChannel }: Omit<RegisterCommandOptions, "provider">) => {

View file

@ -0,0 +1,234 @@
import * as vscode from "vscode"
import * as path from "path"
import { getNonce } from "./getNonce"
import { getUri } from "./getUri"
import { ClineProvider } from "./ClineProvider"
import { ExtensionMessage } from "../../shared/ExtensionMessage"
export interface ElementContext {
html: string
css: string
position: {
x: number
y: number
width: number
height: number
}
computedStyles?: Record<string, string>
attributes?: Record<string, string>
xpath?: string
selector?: string
}
export interface WebPreviewMessage {
type: "elementSelected" | "previewReady" | "error" | "urlChanged" | "viewportChanged"
elementContext?: ElementContext
url?: string
viewport?: { width: number; height: number }
error?: string
}
export class WebPreviewProvider implements vscode.WebviewViewProvider {
public static readonly viewId = "roo-code.webPreview"
private static instance?: WebPreviewProvider
private view?: vscode.WebviewView
private disposables: vscode.Disposable[] = []
private currentUrl?: string
private selectedElementContext?: ElementContext
private clineProvider?: ClineProvider
constructor(
private readonly context: vscode.ExtensionContext,
private readonly outputChannel: vscode.OutputChannel,
) {
WebPreviewProvider.instance = this
}
public static getInstance(): WebPreviewProvider | undefined {
return WebPreviewProvider.instance
}
public setClineProvider(provider: ClineProvider) {
this.clineProvider = provider
}
public async resolveWebviewView(
webviewView: vscode.WebviewView,
_context: vscode.WebviewViewResolveContext,
_token: vscode.CancellationToken,
) {
this.view = webviewView
webviewView.webview.options = {
enableScripts: true,
localResourceRoots: [this.context.extensionUri],
}
webviewView.webview.html = this.getHtmlContent(webviewView.webview)
// Set up message listener
const messageDisposable = webviewView.webview.onDidReceiveMessage(async (message: WebPreviewMessage) => {
await this.handleWebviewMessage(message)
})
this.disposables.push(messageDisposable)
// Handle view disposal
webviewView.onDidDispose(
() => {
this.dispose()
},
null,
this.disposables,
)
}
private async handleWebviewMessage(message: WebPreviewMessage) {
switch (message.type) {
case "elementSelected":
if (message.elementContext) {
this.selectedElementContext = message.elementContext
await this.sendElementContextToCline(message.elementContext)
}
break
case "urlChanged":
this.currentUrl = message.url
break
case "viewportChanged":
// Handle viewport changes if needed
break
case "error":
this.outputChannel.appendLine(`Web Preview Error: ${message.error}`)
break
case "previewReady":
this.outputChannel.appendLine("Web preview ready")
break
}
}
private async sendElementContextToCline(context: ElementContext) {
if (!this.clineProvider) {
return
}
// Format the element context for the AI
const contextMessage = this.formatElementContext(context)
// Send to Cline's chat
await this.clineProvider.postMessageToWebview({
type: "webPreviewElementSelected",
text: contextMessage,
elementContext: context,
} as ExtensionMessage)
}
private formatElementContext(context: ElementContext): string {
let message = "Selected element context:\n\n"
message += `HTML:\n\`\`\`html\n${context.html}\n\`\`\`\n\n`
if (context.css) {
message += `CSS:\n\`\`\`css\n${context.css}\n\`\`\`\n\n`
}
message += `Position: ${context.position.x}, ${context.position.y} (${context.position.width}x${context.position.height})\n`
if (context.selector) {
message += `CSS Selector: ${context.selector}\n`
}
if (context.xpath) {
message += `XPath: ${context.xpath}\n`
}
return message
}
public async loadUrl(url: string) {
if (!this.view) {
return
}
this.currentUrl = url
await this.view.webview.postMessage({
type: "loadUrl",
url,
})
}
public async setViewport(width: number, height: number) {
if (!this.view) {
return
}
await this.view.webview.postMessage({
type: "setViewport",
width,
height,
})
}
public getSelectedElementContext(): ElementContext | undefined {
return this.selectedElementContext
}
private getHtmlContent(webview: vscode.Webview): string {
const scriptUri = getUri(webview, this.context.extensionUri, [
"src",
"core",
"webview",
"preview",
"preview.js",
])
const stylesUri = getUri(webview, this.context.extensionUri, [
"src",
"core",
"webview",
"preview",
"preview.css",
])
const nonce = getNonce()
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; frame-src http: https:; style-src ${webview.cspSource} 'unsafe-inline'; script-src 'nonce-${nonce}'; img-src ${webview.cspSource} https: http: data:;">
<link href="${stylesUri}" rel="stylesheet">
<title>Web Preview</title>
</head>
<body>
<div id="controls">
<input type="text" id="urlInput" placeholder="Enter URL..." />
<button id="goButton">Go</button>
<select id="deviceSelector">
<option value="responsive">Responsive</option>
<option value="375x667">iPhone SE</option>
<option value="390x844">iPhone 12/13</option>
<option value="768x1024">iPad</option>
<option value="1280x800">Desktop</option>
<option value="1920x1080">Full HD</option>
</select>
<button id="toggleInspector">🎯 Select Element</button>
</div>
<div id="previewContainer">
<iframe id="preview" sandbox="allow-scripts allow-same-origin allow-forms allow-popups"></iframe>
<div id="elementOverlay" style="display: none;"></div>
</div>
<script nonce="${nonce}" src="${scriptUri}"></script>
</body>
</html>`
}
public dispose() {
WebPreviewProvider.instance = undefined
while (this.disposables.length) {
const disposable = this.disposables.pop()
if (disposable) {
disposable.dispose()
}
}
this.view = undefined
}
}

View file

@ -0,0 +1,218 @@
body {
margin: 0;
padding: 0;
font-family: var(--vscode-font-family);
background-color: var(--vscode-editor-background);
color: var(--vscode-editor-foreground);
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
#controls {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
background-color: var(--vscode-editor-background);
border-bottom: 1px solid var(--vscode-panel-border);
flex-shrink: 0;
}
#urlInput {
flex: 1;
padding: 4px 8px;
background-color: var(--vscode-input-background);
color: var(--vscode-input-foreground);
border: 1px solid var(--vscode-input-border);
border-radius: 2px;
font-family: var(--vscode-font-family);
font-size: 13px;
}
#urlInput:focus {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: -1px;
}
button {
padding: 4px 12px;
background-color: var(--vscode-button-background);
color: var(--vscode-button-foreground);
border: none;
border-radius: 2px;
cursor: pointer;
font-family: var(--vscode-font-family);
font-size: 13px;
white-space: nowrap;
}
button:hover {
background-color: var(--vscode-button-hoverBackground);
}
button:active {
transform: translateY(1px);
}
#toggleInspector {
background-color: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
}
#toggleInspector:hover {
background-color: var(--vscode-button-secondaryHoverBackground);
}
#toggleInspector.active {
background-color: var(--vscode-button-background);
color: var(--vscode-button-foreground);
}
select {
padding: 4px 8px;
background-color: var(--vscode-dropdown-background);
color: var(--vscode-dropdown-foreground);
border: 1px solid var(--vscode-dropdown-border);
border-radius: 2px;
font-family: var(--vscode-font-family);
font-size: 13px;
cursor: pointer;
}
select:focus {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: -1px;
}
#previewContainer {
flex: 1;
position: relative;
background-color: var(--vscode-editor-background);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
#preview {
background-color: white;
border: none;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
transition:
width 0.3s ease,
height 0.3s ease;
}
#elementOverlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 1000;
}
#elementOverlay.active {
pointer-events: auto;
cursor: crosshair;
}
/* Responsive adjustments */
@media (max-width: 600px) {
#controls {
flex-wrap: wrap;
}
#urlInput {
width: 100%;
flex-basis: 100%;
}
}
/* Loading indicator */
.loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 14px;
color: var(--vscode-descriptionForeground);
}
/* Error message */
.error {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 16px;
background-color: var(--vscode-inputValidation-errorBackground);
border: 1px solid var(--vscode-inputValidation-errorBorder);
border-radius: 4px;
color: var(--vscode-errorForeground);
max-width: 80%;
text-align: center;
}
/* Highlight overlay for element selection */
.element-highlight {
position: absolute;
border: 2px solid #0066ff;
background-color: rgba(0, 102, 255, 0.1);
pointer-events: none;
z-index: 999999;
transition: all 0.1s ease;
}
/* Tooltip for element info */
.element-tooltip {
position: absolute;
background-color: var(--vscode-editorWidget-background);
border: 1px solid var(--vscode-editorWidget-border);
padding: 4px 8px;
border-radius: 3px;
font-size: 12px;
color: var(--vscode-editorWidget-foreground);
pointer-events: none;
z-index: 1000000;
white-space: nowrap;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
/* Device frame simulation */
.device-frame {
position: relative;
margin: 20px auto;
border-radius: 20px;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.3);
background-color: #1a1a1a;
padding: 10px;
}
.device-frame.iphone {
border-radius: 30px;
padding: 15px;
}
.device-frame.ipad {
border-radius: 20px;
padding: 20px;
}
/* Viewport size indicator */
.viewport-indicator {
position: absolute;
bottom: 10px;
right: 10px;
background-color: var(--vscode-badge-background);
color: var(--vscode-badge-foreground);
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
font-family: var(--vscode-editor-font-family);
pointer-events: none;
z-index: 100;
}

View file

@ -0,0 +1,440 @@
/* eslint-env browser */
/* global acquireVsCodeApi */
;(function () {
const vscode = acquireVsCodeApi()
let isInspectorMode = false
let currentHighlight = null
let iframe = null
let overlay = null
// Device presets for responsive preview
const devicePresets = {
desktop: { width: "100%", height: "100%", name: "Desktop" },
"iphone-14": { width: 390, height: 844, name: "iPhone 14" },
"iphone-se": { width: 375, height: 667, name: "iPhone SE" },
ipad: { width: 820, height: 1180, name: "iPad" },
"pixel-7": { width: 412, height: 915, name: "Pixel 7" },
"galaxy-s21": { width: 360, height: 800, name: "Galaxy S21" },
}
// Initialize when DOM is ready
document.addEventListener("DOMContentLoaded", () => {
iframe = document.getElementById("preview")
overlay = document.getElementById("elementOverlay")
setupControls()
setupMessageHandlers()
// Send ready message
vscode.postMessage({ type: "previewReady" })
})
function setupControls() {
// URL input and go button
const urlInput = document.getElementById("urlInput")
const goButton = document.getElementById("goButton")
goButton.addEventListener("click", () => {
const url = urlInput.value.trim()
if (url) {
loadUrl(url)
}
})
urlInput.addEventListener("keypress", (e) => {
if (e.key === "Enter") {
const url = urlInput.value.trim()
if (url) {
loadUrl(url)
}
}
})
// Device selector
const deviceSelector = document.getElementById("deviceSelector")
deviceSelector.addEventListener("change", (e) => {
const value = e.target.value
if (value === "responsive") {
iframe.style.width = "100%"
iframe.style.height = "100%"
} else {
const [width, height] = value.split("x")
setViewport(parseInt(width), parseInt(height))
}
})
// Inspector toggle
const toggleInspector = document.getElementById("toggleInspector")
toggleInspector.addEventListener("click", () => {
isInspectorMode = !isInspectorMode
toggleInspector.classList.toggle("active", isInspectorMode)
if (isInspectorMode) {
enableInspectorMode()
} else {
disableInspectorMode()
}
})
}
function setupMessageHandlers() {
window.addEventListener("message", (event) => {
const message = event.data
switch (message.type) {
case "loadUrl":
loadUrl(message.url)
break
case "setViewport":
setViewport(message.width, message.height)
break
}
})
}
function applyDevicePreset(deviceKey, deviceFrame, iframe) {
const preset = devicePresets[deviceKey]
if (deviceKey === "desktop") {
deviceFrame.className = "device-frame desktop"
deviceFrame.style.width = ""
deviceFrame.style.height = ""
iframe.style.width = "100%"
iframe.style.height = "100%"
} else {
deviceFrame.className = "device-frame mobile"
deviceFrame.style.width = preset.width + "px"
deviceFrame.style.height = preset.height + "px"
iframe.style.width = "100%"
iframe.style.height = "100%"
}
}
function loadUrl(url) {
// Ensure URL has protocol
if (!url.startsWith("http://") && !url.startsWith("https://")) {
url = "http://" + url
}
try {
iframe.src = url
document.getElementById("urlInput").value = url
// Notify extension
vscode.postMessage({
type: "urlChanged",
url: url,
})
// Setup iframe load handler
iframe.onload = () => {
if (isInspectorMode) {
injectInspectorScript()
}
}
} catch (error) {
vscode.postMessage({
type: "error",
error: error.message,
})
}
}
function setViewport(width, height) {
iframe.style.width = width + "px"
iframe.style.height = height + "px"
vscode.postMessage({
type: "viewportChanged",
viewport: { width, height },
})
}
function enableInspectorMode() {
overlay.style.display = "block"
overlay.style.pointerEvents = "auto"
// Add click handler to overlay
overlay.addEventListener("click", handleOverlayClick)
overlay.addEventListener("mousemove", handleOverlayMouseMove)
// Inject inspector script into iframe
injectInspectorScript()
}
function disableInspectorMode() {
overlay.style.display = "none"
overlay.style.pointerEvents = "none"
// Remove handlers
overlay.removeEventListener("click", handleOverlayClick)
overlay.removeEventListener("mousemove", handleOverlayMouseMove)
// Clear highlight
if (currentHighlight) {
currentHighlight.remove()
currentHighlight = null
}
// Remove inspector from iframe
try {
if (iframe.contentWindow) {
iframe.contentWindow.postMessage({ type: "disableInspector" }, "*")
}
} catch (e) {
// Cross-origin restriction
}
}
function injectInspectorScript() {
try {
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document
// Check if we can access the iframe content
if (!iframeDoc) {
console.warn("Cannot access iframe content - cross-origin restriction")
return
}
// Inject inspector script
const script = iframeDoc.createElement("script")
script.textContent = `
(function() {
let hoveredElement = null;
let highlightDiv = null;
function createHighlight() {
if (highlightDiv) {
highlightDiv.remove();
}
highlightDiv = document.createElement('div');
highlightDiv.style.position = 'absolute';
highlightDiv.style.border = '2px solid #0066ff';
highlightDiv.style.backgroundColor = 'rgba(0, 102, 255, 0.1)';
highlightDiv.style.pointerEvents = 'none';
highlightDiv.style.zIndex = '999999';
document.body.appendChild(highlightDiv);
}
function updateHighlight(element) {
if (!highlightDiv) {
createHighlight();
}
const rect = element.getBoundingClientRect();
highlightDiv.style.left = rect.left + window.scrollX + 'px';
highlightDiv.style.top = rect.top + window.scrollY + 'px';
highlightDiv.style.width = rect.width + 'px';
highlightDiv.style.height = rect.height + 'px';
}
function getElementContext(element) {
const rect = element.getBoundingClientRect();
const styles = window.getComputedStyle(element);
// Get CSS rules
let cssRules = [];
try {
for (let sheet of document.styleSheets) {
for (let rule of sheet.cssRules) {
if (rule.selectorText && element.matches(rule.selectorText)) {
cssRules.push(rule.cssText);
}
}
}
} catch (e) {
// Cross-origin stylesheets
}
// Get attributes
const attributes = {};
for (let attr of element.attributes) {
attributes[attr.name] = attr.value;
}
// Get XPath
function getXPath(el) {
if (el.id) return '//*[@id="' + el.id + '"]';
if (el === document.body) return '/html/body';
let ix = 0;
const siblings = el.parentNode.childNodes;
for (let i = 0; i < siblings.length; i++) {
const sibling = siblings[i];
if (sibling === el) {
return getXPath(el.parentNode) + '/' + el.tagName.toLowerCase() + '[' + (ix + 1) + ']';
}
if (sibling.nodeType === 1 && sibling.tagName === el.tagName) {
ix++;
}
}
}
// Get CSS selector
function getCssSelector(el) {
const path = [];
while (el.nodeType === Node.ELEMENT_NODE) {
let selector = el.nodeName.toLowerCase();
if (el.id) {
selector += '#' + el.id;
path.unshift(selector);
break;
} else {
let sibling = el;
let nth = 1;
while (sibling = sibling.previousElementSibling) {
if (sibling.nodeName.toLowerCase() === selector) nth++;
}
if (nth !== 1) selector += ':nth-of-type(' + nth + ')';
}
path.unshift(selector);
el = el.parentNode;
}
return path.join(' > ');
}
return {
html: element.outerHTML,
css: cssRules.join('\\n'),
position: {
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height
},
computedStyles: {
display: styles.display,
position: styles.position,
width: styles.width,
height: styles.height,
margin: styles.margin,
padding: styles.padding,
color: styles.color,
backgroundColor: styles.backgroundColor,
fontSize: styles.fontSize,
fontFamily: styles.fontFamily
},
attributes: attributes,
xpath: getXPath(element),
selector: getCssSelector(element)
};
}
// Mouse move handler
document.addEventListener('mousemove', (e) => {
const element = document.elementFromPoint(e.clientX, e.clientY);
if (element && element !== hoveredElement) {
hoveredElement = element;
updateHighlight(element);
}
});
// Click handler
document.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const element = document.elementFromPoint(e.clientX, e.clientY);
if (element) {
const context = getElementContext(element);
// Send to parent window
window.parent.postMessage({
type: 'elementSelected',
context: context
}, '*');
}
return false;
}, true);
// Listen for disable message
window.addEventListener('message', (e) => {
if (e.data.type === 'disableInspector') {
if (highlightDiv) {
highlightDiv.remove();
highlightDiv = null;
}
}
});
})();
`
iframeDoc.body.appendChild(script)
} catch (error) {
console.error("Error injecting inspector script:", error)
vscode.postMessage({
type: "error",
error: "Cannot inspect elements on this page due to security restrictions",
})
}
}
function handleOverlayClick(e) {
// Calculate position relative to iframe
const iframeRect = iframe.getBoundingClientRect()
const x = e.clientX - iframeRect.left
const y = e.clientY - iframeRect.top
// Try to get element from iframe
try {
if (iframe.contentWindow) {
iframe.contentWindow.postMessage(
{
type: "click",
x: x,
y: y,
},
"*",
)
}
} catch (error) {
// Cross-origin restriction
vscode.postMessage({
type: "error",
error: "Cannot inspect elements on cross-origin pages",
})
}
}
function handleOverlayMouseMove(e) {
// Similar to click, but for hover effects
const iframeRect = iframe.getBoundingClientRect()
const x = e.clientX - iframeRect.left
const y = e.clientY - iframeRect.top
try {
if (iframe.contentWindow) {
iframe.contentWindow.postMessage(
{
type: "mousemove",
x: x,
y: y,
},
"*",
)
}
} catch (error) {
// Ignore cross-origin errors for mousemove
}
}
// Listen for messages from iframe
window.addEventListener("message", (event) => {
if (event.data.type === "elementSelected" && event.data.context) {
// Forward to extension
vscode.postMessage({
type: "elementSelected",
elementContext: event.data.context,
})
// Disable inspector mode after selection
isInspectorMode = false
document.getElementById("toggleInspector").classList.remove("active")
disableInspectorMode()
}
})
})()

View file

@ -29,6 +29,12 @@ export default [
"no-undef": "off",
},
},
{
files: ["core/webview/preview/preview.js"],
rules: {
"no-undef": "off",
},
},
{
ignores: ["webview-ui", "out"],
},

View file

@ -22,6 +22,7 @@ import { Package } from "./shared/package"
import { formatLanguage } from "./shared/language"
import { ContextProxy } from "./core/config/ContextProxy"
import { ClineProvider } from "./core/webview/ClineProvider"
import { WebPreviewProvider } from "./core/webview/WebPreviewProvider"
import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider"
import { TerminalRegistry } from "./integrations/terminal/TerminalRegistry"
import { McpServerManager } from "./services/mcp/McpServerManager"
@ -121,6 +122,16 @@ export async function activate(context: vscode.ExtensionContext) {
}),
)
// Register the web preview provider
const webPreviewProvider = new WebPreviewProvider(context, outputChannel)
webPreviewProvider.setClineProvider(provider)
context.subscriptions.push(
vscode.window.registerWebviewViewProvider(WebPreviewProvider.viewId, webPreviewProvider, {
webviewOptions: { retainContextWhenHidden: true },
}),
)
// Auto-import configuration if specified in settings
try {
await autoImportSettings(outputChannel, {

View file

@ -66,6 +66,12 @@
"type": "webview",
"id": "roo-cline.SidebarProvider",
"name": "%views.sidebar.name%"
},
{
"type": "webview",
"id": "roo-cline.WebPreviewProvider",
"name": "Web Preview",
"visibility": "collapsed"
}
]
},
@ -174,6 +180,12 @@
"command": "roo-cline.acceptInput",
"title": "%command.acceptInput.title%",
"category": "%configuration.title%"
},
{
"command": "roo-cline.openWebPreview",
"title": "Open Web Preview",
"category": "%configuration.title%",
"icon": "$(globe)"
}
],
"menus": {

View file

@ -107,6 +107,7 @@ export interface ExtensionMessage {
| "codeIndexSecretStatus"
| "showDeleteMessageDialog"
| "showEditMessageDialog"
| "webPreviewElementSelected"
text?: string
payload?: any // Add a generic payload for now, can refine later
action?:
@ -161,6 +162,7 @@ export interface ExtensionMessage {
settings?: any
messageTs?: number
context?: string
elementContext?: any // For web preview element context
}
export type ExtensionState = Pick<

View file

@ -758,6 +758,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
setIsCondensing(false)
}
break
case "webPreviewElementSelected":
// Handle web preview element selection
if (message.text) {
// Add the element context to the chat input
handleSetChatBoxMessage(message.text, [])
}
break
}
// textAreaRef.current is not explicitly required here since React
// guarantees that ref will be stable across re-renders, and we're