Skip to content

Commit fd0ed5a

Browse files
committed
feat: add integrated web preview with element selection for AI context
- Implement WebPreviewProvider to manage webview lifecycle - Create WebPreviewView React component with device simulation - Add element selection mode with DOM inspection capabilities - Extract element context (HTML, CSS, XPath, position, styles) - Integrate with AI prompt system via webviewMessageHandler - Support responsive design testing with device presets - Add command registration and context menu integration - Include comprehensive tests for provider and component - Update documentation with usage instructions Fixes #5971
1 parent de13d8a commit fd0ed5a

File tree

17 files changed

+1446
-0
lines changed

17 files changed

+1446
-0
lines changed

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,27 @@ Roo Code comes with powerful [tools](https://docs.roocode.com/basic-usage/how-to
9393
- Execute commands in your VS Code terminal
9494
- Control a web browser
9595
- Use external tools via [MCP (Model Context Protocol)](https://docs.roocode.com/advanced-usage/mcp)
96+
- **Preview web applications** with integrated element selection for AI context
9697

9798
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.
9899

100+
### Web Preview
101+
102+
The integrated web preview feature allows you to:
103+
104+
- **Preview web applications** directly within VS Code
105+
- **Select UI elements** to automatically capture their context (HTML, CSS, XPath, position)
106+
- **Send element context to AI** for better communication about specific UI components
107+
- **Test responsive designs** with device simulation (Desktop, Laptop, iPad, iPhone, etc.)
108+
- **Navigate seamlessly** between different pages of your application
109+
110+
To use the web preview:
111+
112+
1. Right-click on any HTML file and select "Open Web Preview"
113+
2. Or use the command palette: `Roo Code: Open Web Preview`
114+
3. Click "Select Element" to enable element selection mode
115+
4. Click on any element in the preview to send its context to the AI assistant
116+
99117
### Customization
100118

101119
Make Roo Code work your way with:

assets/icons/browser_dark.svg

Lines changed: 10 additions & 0 deletions
Loading

assets/icons/browser_light.svg

Lines changed: 10 additions & 0 deletions
Loading

packages/types/src/vscode.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export const commandIds = [
5353
"focusInput",
5454
"acceptInput",
5555
"focusPanel",
56+
"openWebPreview",
5657
] as const
5758

5859
export type CommandId = (typeof commandIds)[number]

src/activate/registerCommands.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,47 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt
218218

219219
visibleProvider.postMessageToWebview({ type: "acceptInput" })
220220
},
221+
openWebPreview: async () => {
222+
const { WebPreviewProvider } = await import("../core/webview/WebPreviewProvider")
223+
224+
const contextProxy = await ContextProxy.getInstance(context)
225+
const visibleProvider = getVisibleProviderOrLog(outputChannel)
226+
227+
if (!visibleProvider) {
228+
return
229+
}
230+
231+
const webPreviewProvider = new WebPreviewProvider(context, outputChannel, contextProxy, visibleProvider)
232+
233+
const panel = vscode.window.createWebviewPanel(
234+
WebPreviewProvider.viewId,
235+
"Web Preview",
236+
vscode.ViewColumn.Two,
237+
{
238+
enableScripts: true,
239+
retainContextWhenHidden: true,
240+
localResourceRoots: [context.extensionUri],
241+
},
242+
)
243+
244+
panel.iconPath = {
245+
light: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "browser_light.svg"),
246+
dark: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "browser_dark.svg"),
247+
}
248+
249+
await webPreviewProvider.resolveWebviewView(panel)
250+
251+
// Handle panel disposal
252+
panel.onDidDispose(
253+
() => {
254+
webPreviewProvider.dispose()
255+
},
256+
null,
257+
context.subscriptions,
258+
)
259+
260+
TelemetryService.instance.captureTitleButtonClicked("webPreview")
261+
},
221262
})
222263

223264
export const openClineInNewTab = async ({ context, outputChannel }: Omit<RegisterCommandOptions, "provider">) => {
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
import * as vscode from "vscode"
2+
import * as path from "path"
3+
import { EventEmitter } from "events"
4+
5+
import { Package } from "../../shared/package"
6+
import { ExtensionMessage } from "../../shared/ExtensionMessage"
7+
import { WebviewMessage } from "../../shared/WebviewMessage"
8+
import { ClineProvider } from "./ClineProvider"
9+
import { ContextProxy } from "../config/ContextProxy"
10+
import { getNonce } from "./getNonce"
11+
import { getUri } from "./getUri"
12+
13+
export interface WebPreviewElement {
14+
html: string
15+
css: string
16+
xpath: string
17+
selector: string
18+
position: {
19+
x: number
20+
y: number
21+
width: number
22+
height: number
23+
}
24+
computedStyles?: Record<string, string>
25+
attributes?: Record<string, string>
26+
}
27+
28+
export type WebPreviewProviderEvents = {
29+
elementSelected: [element: WebPreviewElement]
30+
}
31+
32+
export class WebPreviewProvider extends EventEmitter<WebPreviewProviderEvents> implements vscode.WebviewViewProvider {
33+
public static readonly viewId = `${Package.name}.WebPreviewProvider`
34+
private view?: vscode.WebviewView | vscode.WebviewPanel
35+
private disposables: vscode.Disposable[] = []
36+
private webviewDisposables: vscode.Disposable[] = []
37+
private currentUrl?: string
38+
private selectedElement?: WebPreviewElement
39+
40+
constructor(
41+
private readonly context: vscode.ExtensionContext,
42+
private readonly outputChannel: vscode.OutputChannel,
43+
private readonly contextProxy: ContextProxy,
44+
private readonly clineProvider: ClineProvider,
45+
) {
46+
super()
47+
this.log("WebPreviewProvider instantiated")
48+
}
49+
50+
async resolveWebviewView(webviewView: vscode.WebviewView | vscode.WebviewPanel) {
51+
this.log("Resolving web preview view")
52+
this.view = webviewView
53+
54+
webviewView.webview.options = {
55+
enableScripts: true,
56+
localResourceRoots: [this.contextProxy.extensionUri],
57+
}
58+
59+
webviewView.webview.html = this.getHtmlContent(webviewView.webview)
60+
this.setWebviewMessageListener(webviewView.webview)
61+
62+
// Listen for visibility changes
63+
if ("onDidChangeViewState" in webviewView) {
64+
const viewStateDisposable = webviewView.onDidChangeViewState(() => {
65+
if (this.view?.visible) {
66+
this.postMessageToWebview({ type: "action", action: "didBecomeVisible" })
67+
}
68+
})
69+
this.webviewDisposables.push(viewStateDisposable)
70+
} else if ("onDidChangeVisibility" in webviewView) {
71+
const visibilityDisposable = webviewView.onDidChangeVisibility(() => {
72+
if (this.view?.visible) {
73+
this.postMessageToWebview({ type: "action", action: "didBecomeVisible" })
74+
}
75+
})
76+
this.webviewDisposables.push(visibilityDisposable)
77+
}
78+
79+
// Handle disposal
80+
webviewView.onDidDispose(
81+
async () => {
82+
this.clearWebviewResources()
83+
},
84+
null,
85+
this.disposables,
86+
)
87+
88+
this.log("Web preview view resolved")
89+
}
90+
91+
private clearWebviewResources() {
92+
while (this.webviewDisposables.length) {
93+
const x = this.webviewDisposables.pop()
94+
if (x) {
95+
x.dispose()
96+
}
97+
}
98+
}
99+
100+
async dispose() {
101+
this.log("Disposing WebPreviewProvider...")
102+
103+
if (this.view && "dispose" in this.view) {
104+
this.view.dispose()
105+
this.log("Disposed webview")
106+
}
107+
108+
this.clearWebviewResources()
109+
110+
while (this.disposables.length) {
111+
const x = this.disposables.pop()
112+
if (x) {
113+
x.dispose()
114+
}
115+
}
116+
117+
this.log("Disposed all disposables")
118+
}
119+
120+
public async postMessageToWebview(message: ExtensionMessage) {
121+
await this.view?.webview.postMessage(message)
122+
}
123+
124+
private setWebviewMessageListener(webview: vscode.Webview) {
125+
const onReceiveMessage = async (message: WebviewMessage) => {
126+
switch (message.type) {
127+
case "webPreviewReady":
128+
this.log("Web preview ready")
129+
// Send initial configuration
130+
await this.postMessageToWebview({
131+
type: "webPreviewConfig",
132+
config: {
133+
defaultUrl: "http://localhost:3000",
134+
enableDeviceSimulation: true,
135+
},
136+
})
137+
break
138+
139+
case "webPreviewNavigate":
140+
if (message.url) {
141+
this.currentUrl = message.url
142+
this.log(`Navigating to: ${message.url}`)
143+
}
144+
break
145+
146+
case "webPreviewElementSelected":
147+
if (message.element) {
148+
this.selectedElement = message.element as WebPreviewElement
149+
this.emit("elementSelected", this.selectedElement)
150+
151+
// Send element context to Cline
152+
await this.sendElementContextToCline(this.selectedElement)
153+
}
154+
break
155+
156+
case "webPreviewError":
157+
this.log(`Web preview error: ${message.error}`)
158+
vscode.window.showErrorMessage(`Web Preview Error: ${message.error}`)
159+
break
160+
}
161+
}
162+
163+
const messageDisposable = webview.onDidReceiveMessage(onReceiveMessage)
164+
this.webviewDisposables.push(messageDisposable)
165+
}
166+
167+
private async sendElementContextToCline(element: WebPreviewElement) {
168+
// Format element context for AI
169+
const context = this.formatElementContext(element)
170+
171+
// Send to Cline provider
172+
await this.clineProvider.postMessageToWebview({
173+
type: "webPreviewElementContext",
174+
context,
175+
})
176+
}
177+
178+
private formatElementContext(element: WebPreviewElement): string {
179+
let context = "Selected Element Context:\n\n"
180+
181+
// HTML structure
182+
context += `HTML:\n${element.html}\n\n`
183+
184+
// CSS selector
185+
context += `CSS Selector: ${element.selector}\n`
186+
context += `XPath: ${element.xpath}\n\n`
187+
188+
// Position
189+
context += `Position: ${element.position.x}px, ${element.position.y}px\n`
190+
context += `Size: ${element.position.width}px × ${element.position.height}px\n\n`
191+
192+
// Computed styles (if available)
193+
if (element.computedStyles) {
194+
context += "Key Styles:\n"
195+
const importantStyles = ["display", "position", "width", "height", "color", "background-color", "font-size"]
196+
for (const style of importantStyles) {
197+
if (element.computedStyles[style]) {
198+
context += ` ${style}: ${element.computedStyles[style]}\n`
199+
}
200+
}
201+
context += "\n"
202+
}
203+
204+
// Attributes
205+
if (element.attributes) {
206+
context += "Attributes:\n"
207+
for (const [key, value] of Object.entries(element.attributes)) {
208+
context += ` ${key}: ${value}\n`
209+
}
210+
}
211+
212+
return context
213+
}
214+
215+
private getHtmlContent(webview: vscode.Webview): string {
216+
const scriptUri = getUri(webview, this.contextProxy.extensionUri, [
217+
"webview-ui",
218+
"build",
219+
"assets",
220+
"webPreview.js",
221+
])
222+
const stylesUri = getUri(webview, this.contextProxy.extensionUri, [
223+
"webview-ui",
224+
"build",
225+
"assets",
226+
"index.css",
227+
])
228+
const codiconsUri = getUri(webview, this.contextProxy.extensionUri, ["assets", "codicons", "codicon.css"])
229+
230+
const nonce = getNonce()
231+
232+
return /*html*/ `
233+
<!DOCTYPE html>
234+
<html lang="en">
235+
<head>
236+
<meta charset="utf-8">
237+
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
238+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; font-src ${webview.cspSource} data:; style-src ${webview.cspSource} 'unsafe-inline'; img-src ${webview.cspSource} https: http: data:; media-src ${webview.cspSource}; script-src ${webview.cspSource} 'nonce-${nonce}'; frame-src https: http:; connect-src https: http: ws: wss:;">
239+
<link rel="stylesheet" type="text/css" href="${stylesUri}">
240+
<link href="${codiconsUri}" rel="stylesheet" />
241+
<title>Web Preview</title>
242+
</head>
243+
<body>
244+
<div id="root"></div>
245+
<script nonce="${nonce}" type="module" src="${scriptUri}"></script>
246+
</body>
247+
</html>
248+
`
249+
}
250+
251+
public async navigateToUrl(url: string) {
252+
this.currentUrl = url
253+
await this.postMessageToWebview({
254+
type: "webPreviewNavigate",
255+
url,
256+
})
257+
}
258+
259+
public async setDeviceMode(device: string) {
260+
await this.postMessageToWebview({
261+
type: "webPreviewSetDevice",
262+
device,
263+
})
264+
}
265+
266+
public getSelectedElement(): WebPreviewElement | undefined {
267+
return this.selectedElement
268+
}
269+
270+
private log(message: string) {
271+
this.outputChannel.appendLine(`[WebPreview] ${message}`)
272+
console.log(`[WebPreview] ${message}`)
273+
}
274+
}

0 commit comments

Comments
 (0)