diff --git a/.vscode/launch.json b/.vscode/launch.json index 5f023be65b..145dce808e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -16,7 +16,10 @@ "preLaunchTask": "${defaultBuildTask}", "env": { "NODE_ENV": "development", - "VSCODE_DEBUG_MODE": "true" + "VSCODE_DEBUG_MODE": "true", + // Dev-only: registers the `zoo-code.openInBrowser` command (socket.io + // browser bridge for render debugging). See src/activate/registerCommands.ts. + "ROO_BROWSER_BRIDGE": "1" }, "resolveSourceMapLocations": ["${workspaceFolder}/**", "!**/node_modules/**"], "presentation": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47f526185f..122e2cd151 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -692,6 +692,12 @@ importers: rimraf: specifier: 6.0.1 version: 6.0.1 + socket.io: + specifier: 4.8.3 + version: 4.8.3 + socket.io-client: + specifier: 4.8.3 + version: 4.8.3 vitest: specifier: 4.1.9 version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -954,6 +960,9 @@ importers: monocart-reporter: specifier: ^2.9.20 version: 2.12.2 + socket.io-client: + specifier: 4.8.3 + version: 4.8.3 vite: specifier: 8.1.0 version: 8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) @@ -3099,6 +3108,9 @@ packages: resolution: {integrity: sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q==} engines: {node: '>=18.0.0'} + '@socket.io/component-emitter@3.1.2': + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + '@stablelib/base64@1.0.1': resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} @@ -3340,6 +3352,9 @@ packages: '@types/clone-deep@4.0.4': resolution: {integrity: sha512-vXh6JuuaAha6sqEbJueYdh5zNBPPgG1OYumuz2UvLvriN6ABHDSW8ludREGWJb1MLIzbwZn4q4zUbUCerJTJfA==} + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -3569,6 +3584,9 @@ packages: '@types/vscode@1.100.0': resolution: {integrity: sha512-4uNyvzHoraXEeCamR3+fzcBlh7Afs4Ifjs4epINyUX/jvdk0uzLnwiDY35UKDKnkCHP5Nu3dljl2H8lR6s+rQw==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -4072,6 +4090,10 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + base64id@2.0.0: + resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} + engines: {node: ^4.5.0 || >= 5.9} + better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} @@ -4895,6 +4917,17 @@ packages: end-of-stream@1.4.4: resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} + engine.io-client@6.6.6: + resolution: {integrity: sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==} + + engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} + engines: {node: '>=10.0.0'} + + engine.io@6.6.10: + resolution: {integrity: sha512-9/lX2bdlizlCXMHRMOIm03VBQHQYC7VvydcxtTAUJRxNW1QzM/2PMFSmr6h/lCiMHcyCP6abK+t9Q+j4vekk8Q==} + engines: {node: '>=10.2.0'} + enhanced-resolve@5.21.6: resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} engines: {node: '>=10.13.0'} @@ -7854,6 +7887,21 @@ packages: resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==} engines: {node: '>= 18'} + socket.io-adapter@2.5.8: + resolution: {integrity: sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==} + + socket.io-client@4.8.3: + resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==} + engines: {node: '>=10.0.0'} + + socket.io-parser@4.2.7: + resolution: {integrity: sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==} + engines: {node: '>=10.0.0'} + + socket.io@4.8.3: + resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==} + engines: {node: '>=10.2.0'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -8826,6 +8874,10 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xmlhttprequest-ssl@2.1.2: + resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} + engines: {node: '>=0.4.0'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -11297,6 +11349,8 @@ snapshots: dependencies: tslib: 2.8.1 + '@socket.io/component-emitter@3.1.2': {} + '@stablelib/base64@1.0.1': {} '@standard-schema/spec@1.1.0': {} @@ -11572,6 +11626,10 @@ snapshots: '@types/clone-deep@4.0.4': {} + '@types/cors@2.8.19': + dependencies: + '@types/node': 22.20.1 + '@types/d3-array@3.2.2': {} '@types/d3-axis@3.0.6': @@ -11812,6 +11870,10 @@ snapshots: '@types/vscode@1.100.0': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.20.1 + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.33': @@ -12428,6 +12490,8 @@ snapshots: base64-js@1.5.1: {} + base64id@2.0.0: {} + better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 @@ -13237,6 +13301,36 @@ snapshots: dependencies: once: 1.4.0 + engine.io-client@6.6.6: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3(supports-color@8.1.1) + engine.io-parser: 5.2.3 + ws: 8.21.3 + xmlhttprequest-ssl: 2.1.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + engine.io-parser@5.2.3: {} + + engine.io@6.6.10: + dependencies: + '@types/cors': 2.8.19 + '@types/node': 22.20.1 + '@types/ws': 8.18.1 + accepts: 1.3.8 + cookie: 0.7.2 + cors: 2.8.5 + debug: 4.4.3(supports-color@8.1.1) + engine.io-parser: 5.2.3 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + enhanced-resolve@5.21.6: dependencies: graceful-fs: 4.2.11 @@ -16933,6 +17027,47 @@ snapshots: smol-toml@1.7.0: {} + socket.io-adapter@2.5.8: + dependencies: + debug: 4.4.3(supports-color@8.1.1) + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-client@4.8.3: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3(supports-color@8.1.1) + engine.io-client: 6.6.6 + socket.io-parser: 4.2.7 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-parser@4.2.7: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + socket.io@4.8.3: + dependencies: + accepts: 1.3.8 + base64id: 2.0.0 + cors: 2.8.5 + debug: 4.4.3(supports-color@8.1.1) + engine.io: 6.6.10 + socket.io-adapter: 2.5.8 + socket.io-parser: 4.2.7 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + source-map-js@1.2.1: {} source-map@0.5.6: {} @@ -17895,6 +18030,8 @@ snapshots: xmlchars@2.2.0: {} + xmlhttprequest-ssl@2.1.2: {} + xtend@4.0.2: {} y18n@5.0.8: {} diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index 67a2b935ec..078f80db5a 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -2,6 +2,7 @@ import type { Mock } from "vitest" import * as vscode from "vscode" import { ClineProvider } from "../../core/webview/ClineProvider" +import { BrowserBridgeServer } from "../../core/webview/browserBridge" import { getVisibleProviderOrLog, openClineInNewTab, registerCommands, setPanel } from "../registerCommands" vi.mock("execa", () => ({ @@ -93,6 +94,13 @@ vi.mock("../../services/ripgrep/diagnostic", () => ({ registerRipgrepDiagnosticCommand: vi.fn().mockReturnValue({ dispose: vi.fn() }), })) +// The browser bridge command is dev-only tooling; registerCommands must merely +// hand it the activation context (its self-gating is covered in +// core/webview/__tests__/browserBridge.spec.ts). +vi.mock("../../core/webview/browserBridge", () => ({ + BrowserBridgeServer: { registerCommand: vi.fn() }, +})) + describe("getVisibleProviderOrLog", () => { let mockOutputChannel: vscode.OutputChannel @@ -192,6 +200,17 @@ describe("registerCommands handlers", () => { expect(mockContext.subscriptions).toContain(disposable) }) + it("delegates the dev-only browser bridge command registration to BrowserBridgeServer", async () => { + const registerCommandSpy = vi.mocked(BrowserBridgeServer.registerCommand) + expect(registerCommandSpy).toHaveBeenCalledTimes(1) + expect(registerCommandSpy).toHaveBeenCalledWith(mockContext, mockOutputChannel, expect.any(Function)) + + // The third argument is the visible-provider callback the bridge uses + // to find the provider to put into browser mode. + const getVisibleProvider = registerCommandSpy.mock.calls[0][2] + expect(getVisibleProvider()).toBe(mockVisibleProvider) + }) + it("settingsButtonClicked posts both settingsButtonClicked and didBecomeVisible actions", () => { handlers["zoo-code.settingsButtonClicked"]() diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 692aabfd68..e9b5a5b667 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -4,7 +4,6 @@ import delay from "delay" import type { CommandId } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" -import { Package } from "../shared/package" import { getCommand } from "../utils/commands" import { ClineProvider } from "../core/webview/ClineProvider" import { ContextProxy } from "../core/config/ContextProxy" @@ -15,6 +14,7 @@ import { importSettingsWithFeedback } from "../core/config/importExport" import { MdmService } from "../services/mdm/MdmService" import { registerRipgrepDiagnosticCommand } from "../services/ripgrep/diagnostic" import { t } from "../i18n" +import { BrowserBridgeServer } from "../core/webview/browserBridge" /** * Helper to get the visible ClineProvider instance or log if not found. @@ -71,6 +71,9 @@ export const registerCommands = (options: RegisterCommandOptions) => { } context.subscriptions.push(registerRipgrepDiagnosticCommand()) + + // Dev-only tooling: self-gating no-op in production (see browserBridge.ts). + BrowserBridgeServer.registerCommand(context, options.outputChannel, () => ClineProvider.getVisibleInstance()) } // `showRipgrepDiagnostic` is registered separately by diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 495fe454b7..12829eb32f 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -114,6 +114,7 @@ import { CustomModesManager } from "../config/CustomModesManager" import { Task } from "../task/Task" import { webviewMessageHandler } from "./webviewMessageHandler" +import { BrowserBridgeServer } from "./browserBridge" import type { ClineMessage, TodoItem } from "@roo-code/types" import { type ApiMessage, @@ -897,6 +898,10 @@ export class ClineProvider this.customModesManager?.dispose() this.taskHistoryStore.dispose() this.flushGlobalStateWriteThrough() + + // Release the provider-owned browser bridge (socket.io server + port). + BrowserBridgeServer.disposeFor(this) + this.log("Disposed all disposables") ClineProvider.activeInstances.delete(this) @@ -1041,12 +1046,21 @@ export class ClineProvider localResourceRoots: resourceRoots, } - webviewView.webview.html = - this.contextProxy.extensionMode === vscode.ExtensionMode.Development && - process.env.ROO_CODE_THEME_FIXTURE_PROBE !== "1" - ? await this.getHMRHtmlContent(webviewView.webview) - : await this.getHtmlContent(webviewView.webview) - + // When the browser bridge is active the UI runs only in Chrome; render + // an informational placeholder inside the real iframe (no React, no + // scripts, but with a clickable link back to the browser tab) so the + // environments don't run simultaneously. The virtual webview (backed by + // socket.io) already has its message listener registered when the + // bridge was enabled, so it is not re-registered here. + if (BrowserBridgeServer.active(this)) { + BrowserBridgeServer.setPlaceholder(this) + } else { + webviewView.webview.html = + this.contextProxy.extensionMode === vscode.ExtensionMode.Development && + process.env.ROO_CODE_THEME_FIXTURE_PROBE !== "1" + ? await this.getHMRHtmlContent(webviewView.webview) + : await this.getHtmlContent(webviewView.webview) + } // Initialize out-of-scope variables that need to receive persistent // global state values. await this.getState().then( @@ -1078,8 +1092,12 @@ export class ClineProvider ) // Sets up an event listener to listen for messages passed from the webview view context - // and executes code based on the message that is received. - this.setWebviewMessageListener(webviewView.webview) + // and executes code based on the message that is received. When the bridge is active + // the listener was already registered on the virtual webview; + // re-registering here would double-handle every message. + if (!BrowserBridgeServer.active(this)) { + this.setWebviewMessageListener(webviewView.webview) + } // Initialize code index status subscription for the current workspace. this.updateCodeIndexStatusSubscription() @@ -1478,8 +1496,13 @@ export class ClineProvider return } + const webview = BrowserBridgeServer.webviewFor(this) ?? this.view?.webview + if (!webview) { + return + } + try { - await this.view?.webview.postMessage(message) + await webview.postMessage(message) } catch { // View disposed, drop message silently } @@ -4534,8 +4557,9 @@ export class ClineProvider const fileUri = vscode.Uri.file(filePath) // Check if we have a webview available - if (this.view?.webview) { - const webviewUri = this.view.webview.asWebviewUri(fileUri) + const activeWebview = BrowserBridgeServer.webviewFor(this) ?? this.view?.webview + if (activeWebview) { + const webviewUri = activeWebview.asWebviewUri(fileUri) return webviewUri.toString() } diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index bfd4706dcc..286bba2ae4 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -31,6 +31,7 @@ import { Task, TaskOptions } from "../../task/Task" import { safeWriteJson } from "../../../utils/safeWriteJson" import { ClineProvider } from "../ClineProvider" +import { BrowserBridgeServer } from "../browserBridge" import { webviewMessageHandler } from "../webviewMessageHandler" import { Terminal } from "../../../integrations/terminal/Terminal" import { MessageManager } from "../../message-manager" @@ -621,6 +622,194 @@ describe("ClineProvider", () => { expect(mockWebviewView.webview.html).toContain("