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..70a2fad932 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -595,6 +595,9 @@ importers: simple-git: specifier: ^3.27.0 version: 3.36.0 + socket.io: + specifier: ^4.8.3 + version: 4.8.3 strip-bom: specifier: ^5.0.0 version: 5.0.0 @@ -692,6 +695,9 @@ importers: rimraf: specifier: 6.0.1 version: 6.0.1 + 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)) @@ -860,6 +866,9 @@ importers: shiki: specifier: ^3.2.1 version: 3.4.1 + socket.io-client: + specifier: ^4.8.3 + version: 4.8.3 source-map: specifier: ^0.7.4 version: 0.7.6 @@ -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..5b0805651d 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -2,7 +2,13 @@ import type { Mock } from "vitest" import * as vscode from "vscode" import { ClineProvider } from "../../core/webview/ClineProvider" -import { getVisibleProviderOrLog, openClineInNewTab, registerCommands, setPanel } from "../registerCommands" +import { + getVisibleProviderOrLog, + openClineInNewTab, + registerBrowserBridgeCommand, + registerCommands, + setPanel, +} from "../registerCommands" vi.mock("execa", () => ({ execa: vi.fn(), @@ -15,6 +21,7 @@ vi.mock("vscode", () => ({ }, Uri: { joinPath: vi.fn((_base: unknown, ..._pathSegments: string[]) => ({ path: _pathSegments.join("/") })), + parse: vi.fn((value: string) => ({ toString: () => value })), }, ViewColumn: { Two: 2, @@ -22,8 +29,12 @@ vi.mock("vscode", () => ({ window: { createTextEditorDecorationType: vi.fn().mockReturnValue({ dispose: vi.fn() }), createWebviewPanel: vi.fn(), + showErrorMessage: vi.fn(), visibleTextEditors: [], }, + env: { + openExternal: vi.fn().mockResolvedValue(true), + }, workspace: { workspaceFolders: [ { @@ -41,6 +52,13 @@ vi.mock("vscode", () => ({ vi.mock("../../core/webview/ClineProvider") +vi.mock("../../core/webview/browserBridge", () => ({ + BrowserBridgeServer: { + start: vi.fn(), + getBrowserUrl: vi.fn((port: number) => `http://localhost:5173/?bridgePort=${port}`), + }, +})) + vi.mock("../../shared/package", () => ({ Package: { name: "zoo-code", @@ -133,7 +151,11 @@ describe("getVisibleProviderOrLog", () => { describe("registerCommands handlers", () => { let mockOutputChannel: vscode.OutputChannel let mockContext: vscode.ExtensionContext - let mockVisibleProvider: { postMessageToWebview: Mock } + let mockVisibleProvider: { + postMessageToWebview: Mock + getActiveBrowserBridgePort: Mock + enableBrowserBridge: Mock + } let mockProvider: { postMessageToWebview: Mock } let handlers: Record unknown> @@ -158,6 +180,8 @@ describe("registerCommands handlers", () => { mockVisibleProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined), + getActiveBrowserBridgePort: vi.fn().mockReturnValue(undefined), + enableBrowserBridge: vi.fn(), } mockProvider = { @@ -376,6 +400,113 @@ describe("registerCommands handlers", () => { }) }) +// The openInBrowser command is dev-only tooling: it is not contributed in +// package.json and is registered directly through registerBrowserBridgeCommand +// (only when ROO_BROWSER_BRIDGE=1 in a Development host), so these tests drive +// that function instead of the main registration loop. +describe("registerBrowserBridgeCommand", () => { + let mockOutputChannel: vscode.OutputChannel + let mockVisibleProvider: { + getActiveBrowserBridgePort: Mock + enableBrowserBridge: Mock + } + let openInBrowser: () => Promise + + const getOpenedUrl = (): string => { + const uri = vi.mocked(vscode.env.openExternal).mock.calls[0]?.[0] as { toString(): string } | undefined + return uri?.toString() ?? "" + } + + beforeEach(() => { + vi.clearAllMocks() + + mockOutputChannel = { + appendLine: vi.fn(), + append: vi.fn(), + clear: vi.fn(), + hide: vi.fn(), + name: "mock", + replace: vi.fn(), + show: vi.fn(), + dispose: vi.fn(), + } + + mockVisibleProvider = { + getActiveBrowserBridgePort: vi.fn().mockReturnValue(undefined), + enableBrowserBridge: vi.fn(), + } + ;(ClineProvider.getVisibleInstance as Mock).mockReturnValue(mockVisibleProvider) + + let handler: (() => Promise) | undefined + ;(vscode.commands.registerCommand as Mock).mockImplementation((id: string, cb: () => Promise) => { + expect(id).toBe("zoo-code.openInBrowser") + handler = cb + return { dispose: vi.fn() } + }) + + registerBrowserBridgeCommand({ + context: { subscriptions: [] } as unknown as vscode.ExtensionContext, + outputChannel: mockOutputChannel, + provider: {} as ClineProvider, + }) + openInBrowser = () => handler!() + }) + + it("starts a bridge, enables it on the provider, and opens the URL with the port", async () => { + const bridge = { + port: 43210, + dispose: vi.fn(), + getBrowserUrl: vi.fn().mockReturnValue("http://localhost:5173/?bridgePort=43210"), + } + const { BrowserBridgeServer } = await import("../../core/webview/browserBridge") + vi.mocked(BrowserBridgeServer.start).mockResolvedValue(bridge as never) + + await openInBrowser() + + expect(BrowserBridgeServer.start).toHaveBeenCalledTimes(1) + expect(mockVisibleProvider.enableBrowserBridge).toHaveBeenCalledWith(bridge) + expect(mockVisibleProvider.getActiveBrowserBridgePort).toHaveBeenCalled() + expect(vscode.env.openExternal).toHaveBeenCalledTimes(1) + expect(getOpenedUrl()).toBe("http://localhost:5173/?bridgePort=43210") + }) + + it("reuses the existing bridge and does not start a second one", async () => { + const { BrowserBridgeServer } = await import("../../core/webview/browserBridge") + mockVisibleProvider.getActiveBrowserBridgePort.mockReturnValue(43210) + + await openInBrowser() + + expect(BrowserBridgeServer.start).not.toHaveBeenCalled() + expect(mockVisibleProvider.enableBrowserBridge).not.toHaveBeenCalled() + expect(vscode.env.openExternal).toHaveBeenCalledTimes(1) + expect(getOpenedUrl()).toBe("http://localhost:5173/?bridgePort=43210") + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + "[openInBrowser] Reusing existing browser bridge on port 43210.", + ) + }) + + it("aborts when there is no visible provider", async () => { + const { BrowserBridgeServer } = await import("../../core/webview/browserBridge") + ;(ClineProvider.getVisibleInstance as Mock).mockReturnValue(undefined) + + await openInBrowser() + + expect(BrowserBridgeServer.start).not.toHaveBeenCalled() + expect(vscode.env.openExternal).not.toHaveBeenCalled() + }) + + it("logs when the bridge fails to start", async () => { + const { BrowserBridgeServer } = await import("../../core/webview/browserBridge") + vi.mocked(BrowserBridgeServer.start).mockResolvedValue(undefined as never) + + await openInBrowser() + + expect(mockVisibleProvider.enableBrowserBridge).not.toHaveBeenCalled() + expect(vscode.env.openExternal).not.toHaveBeenCalled() + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith("[openInBrowser] Failed to start the browser bridge.") + }) +}) + describe("openClineInNewTab", () => { let mockOutputChannel: vscode.OutputChannel let mockContext: vscode.ExtensionContext diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 692aabfd68..705150b1e3 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -15,6 +15,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 +72,59 @@ export const registerCommands = (options: RegisterCommandOptions) => { } context.subscriptions.push(registerRipgrepDiagnosticCommand()) + + // Dev-only tooling: the browser bridge command is registered at runtime only + // when ROO_BROWSER_BRIDGE=1 is set in the extension host environment (see + // .vscode/launch.json) AND the extension runs in Development mode. It is + // intentionally absent from package.json contributions, so public users + // never see a toolbar button or a localized command-palette entry. + if (process.env.ROO_BROWSER_BRIDGE === "1" && context.extensionMode === vscode.ExtensionMode.Development) { + context.subscriptions.push(registerBrowserBridgeCommand(options)) + } +} + +/** + * Registers the dev-only `openInBrowser` command that switches a provider to + * the socket.io browser bridge. Returns the disposable so the caller can + * manage its lifecycle via `context.subscriptions`. + */ +export function registerBrowserBridgeCommand({ outputChannel }: RegisterCommandOptions): vscode.Disposable { + return vscode.commands.registerCommand(`${Package.name}.openInBrowser`, async () => { + const visibleProvider = getVisibleProviderOrLog(outputChannel) + if (!visibleProvider) { + return + } + + // One provider -> one bridge -> one port, permanently. If this provider + // is already in browser mode, reuse its bridge instead of starting a + // second one (an extra socket.io server would leak its port). + const activePort = visibleProvider.getActiveBrowserBridgePort() + + if (activePort !== undefined) { + outputChannel.appendLine(`[openInBrowser] Reusing existing browser bridge on port ${activePort}.`) + await vscode.env.openExternal(vscode.Uri.parse(BrowserBridgeServer.getBrowserUrl(activePort))) + return + } + + const bridge = await BrowserBridgeServer.start( + (message) => outputChannel.appendLine(message), + (error) => { + // Surface bridge failures to the developer, not just the output + // channel — a silently dead command is a dead end. + void vscode.window.showErrorMessage(`Failed to start the browser bridge: ${error.message}`) + }, + ) + if (!bridge) { + outputChannel.appendLine("[openInBrowser] Failed to start the browser bridge.") + return + } + // Irreversible switch: from now on the provider posts to the virtual + // webview (socket.io) and the real iframe renders a placeholder with a + // clickable link to the browser tab. + visibleProvider.enableBrowserBridge(bridge) + + await vscode.env.openExternal(vscode.Uri.parse(bridge.getBrowserUrl())) + }) } // `showRipgrepDiagnostic` is registered separately by diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 87a899344c..b94afe7009 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -109,6 +109,7 @@ import { CustomModesManager } from "../config/CustomModesManager" import { Task } from "../task/Task" import { webviewMessageHandler } from "./webviewMessageHandler" +import type { BrowserBridgeServer } from "./browserBridge" import type { ClineMessage, TodoItem } from "@roo-code/types" import { type ApiMessage, @@ -195,6 +196,13 @@ export class ClineProvider >() private nextThemeFixtureProbeId = 0 private view?: vscode.WebviewView | vscode.WebviewPanel + // Owned socket.io bridge when this provider was switched to browser mode. + // Each provider owns its own bridge (and therefore its own port); it is + // disposed together with the provider. + private browserBridge?: BrowserBridgeServer + // The webview used for message passing. Equals `view.webview` normally, or + // the bridge's virtual webview when the browser bridge is active. + private bridgeWebview?: vscode.Webview private taskRegistry = new TaskRegistry() private taskScheduler = new TaskScheduler() private delegationTransitionLocks?: Map> @@ -886,6 +894,12 @@ export class ClineProvider this.customModesManager?.dispose() this.taskHistoryStore.dispose() this.flushGlobalStateWriteThrough() + + // Release the provider-owned browser bridge (socket.io server + port). + this.browserBridge?.dispose() + this.browserBridge = undefined + this.bridgeWebview = undefined + this.log("Disposed all disposables") ClineProvider.activeInstances.delete(this) @@ -1030,12 +1044,23 @@ 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 in + // enableBrowserBridge(), so it is not re-registered here. + const browserBridgeActive = this.browserBridge !== undefined + if (browserBridgeActive) { + webviewView.webview.html = this.browserBridge?.getPlaceholderHtml() ?? "" + } 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( @@ -1067,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 in + // enableBrowserBridge(); re-registering here would double-handle every message. + if (!this.bridgeWebview) { + this.setWebviewMessageListener(webviewView.webview) + } // Initialize code index status subscription for the current workspace. this.updateCodeIndexStatusSubscription() @@ -1468,13 +1497,57 @@ export class ClineProvider return } + const webview = this.bridgeWebview ?? this.view?.webview + if (!webview) { + return + } + try { - await this.view?.webview.postMessage(message) + await webview.postMessage(message) } catch { // View disposed, drop message silently } } + /** + * Irreversibly switches this provider to the browser bridge. + * + * From this point on the provider posts to the virtual webview (backed by + * socket.io) and the real iframe renders a placeholder with a clickable + * link to the browser tab so the two environments never run + * simultaneously. Safe to call repeatedly — the switch only happens once. + * The provider takes ownership of the bridge and disposes it together with + * itself. + */ + public enableBrowserBridge(bridge: BrowserBridgeServer): void { + if (this.bridgeWebview) { + // Already switched: this provider is permanently bound to its first + // bridge (one provider -> one bridge -> one port). Refuse the new + // bridge and dispose it so its socket.io server/port does not leak. + bridge.dispose() + return + } + + this.browserBridge = bridge + this.bridgeWebview = bridge.getOrCreateVirtualWebview() + // The message listener must be registered on the virtual webview exactly + // once. resolveWebviewView() checks `this.bridgeWebview` and skips its own + // registration, so re-resolves don't double-handle messages. + this.setWebviewMessageListener(this.bridgeWebview) + if (this.view) { + this.view.webview.html = bridge.getPlaceholderHtml() + } + } + + /** + * The port of the provider-owned browser bridge, or `undefined` when the + * provider has not been switched to browser mode yet. Lets command handlers + * reuse the existing bridge instead of creating a second one. + */ + public getActiveBrowserBridgePort(): number | undefined { + return this.browserBridge?.port + } + public requestWebviewThemeFixture(timeoutMs = 5_000): Promise { if (process.env.ROO_CODE_THEME_FIXTURE_PROBE !== "1") { return Promise.reject(new Error("Theme fixture probing is disabled")) @@ -4410,8 +4483,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 = this.bridgeWebview ?? 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 1a6a82a5b0..07ffee4168 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -619,6 +619,72 @@ describe("ClineProvider", () => { expect(mockWebviewView.webview.html).toContain("Zoo Code") }) + describe("browser bridge ownership", () => { + const createFakeBridge = (port: number) => ({ + port, + dispose: vi.fn(), + getOrCreateVirtualWebview: vi.fn().mockReturnValue(mockWebviewView.webview), + getPlaceholderHtml: vi.fn().mockReturnValue(``), + getBrowserUrl: vi.fn().mockReturnValue(`http://localhost:5173/?bridgePort=${port}`), + }) + + test("getActiveBrowserBridgePort returns undefined before the bridge is enabled", () => { + expect(provider.getActiveBrowserBridgePort()).toBeUndefined() + }) + + test("enableBrowserBridge adopts the bridge and getActiveBrowserBridgePort reports its port", () => { + const bridge = createFakeBridge(43210) + provider.enableBrowserBridge(bridge as never) + + expect(provider.getActiveBrowserBridgePort()).toBe(43210) + expect(bridge.getOrCreateVirtualWebview).toHaveBeenCalledTimes(1) + }) + + test("enableBrowserBridge ignores and disposes a second bridge (one provider -> one bridge)", () => { + const firstBridge = createFakeBridge(43210) + const secondBridge = createFakeBridge(43211) + provider.enableBrowserBridge(firstBridge as never) + provider.enableBrowserBridge(secondBridge as never) + + // The first bridge stays authoritative; the second is rejected and disposed. + expect(provider.getActiveBrowserBridgePort()).toBe(43210) + expect(secondBridge.dispose).toHaveBeenCalledTimes(1) + expect(firstBridge.dispose).not.toHaveBeenCalled() + expect(firstBridge.getOrCreateVirtualWebview).toHaveBeenCalledTimes(1) + expect(secondBridge.getOrCreateVirtualWebview).not.toHaveBeenCalled() + }) + + test("enableBrowserBridge renders the bridge placeholder html in an already-resolved view", () => { + const bridge = createFakeBridge(43210) + // @ts-ignore - accessing private property for testing + provider.view = mockWebviewView + + provider.enableBrowserBridge(bridge as never) + + expect(bridge.getPlaceholderHtml).toHaveBeenCalledTimes(1) + expect(mockWebviewView.webview.html).toContain("bridge placeholder 43210") + }) + + test("resolveWebviewView renders the bridge placeholder when the bridge is active", async () => { + const bridge = createFakeBridge(43210) + provider.enableBrowserBridge(bridge as never) + + await provider.resolveWebviewView(mockWebviewView) + + expect(mockWebviewView.webview.html).toContain("bridge placeholder 43210") + }) + + test("dispose releases the provider-owned bridge", async () => { + const bridge = createFakeBridge(43210) + provider.enableBrowserBridge(bridge as never) + + await provider.dispose() + + expect(bridge.dispose).toHaveBeenCalledTimes(1) + expect(provider.getActiveBrowserBridgePort()).toBeUndefined() + }) + }) + describe("logWebviewHiddenDiagnostics", () => { let visibilityCallback: () => void diff --git a/src/core/webview/__tests__/browserBridge.spec.ts b/src/core/webview/__tests__/browserBridge.spec.ts new file mode 100644 index 0000000000..88cce58e36 --- /dev/null +++ b/src/core/webview/__tests__/browserBridge.spec.ts @@ -0,0 +1,156 @@ +// npx vitest run core/webview/__tests__/browserBridge.spec.ts +// +// BrowserBridgeServer / createVirtualWebview round-trip tests. +// +// These spin up a real socket.io server on an ephemeral port and connect with +// socket.io-client, exactly like the webview-ui BrowserBridgeClient does, so a +// regression in the socket.io v4 wiring (client events arrive on the socket, +// not the Server) is caught here. + +import { io, type Socket } from "socket.io-client" + +import type { ExtensionMessage, WebviewMessage } from "@roo-code/types" + +import { allowNetConnect } from "../../../vitest.setup" +import { BrowserBridgeServer, getBrowserBridgePort } from "../browserBridge" + +// vitest.setup.ts disables real network requests via nock by default. The +// bridge tests connect a real socket.io client to a loopback server, so allow +// net connect for 127.0.0.1 (both the websocket upgrade and the polling +// transport use the same host). +allowNetConnect(/^127\.0\.0\.1(?::\d+)?$/) + +function connectToBridge(port: number): Promise { + return new Promise((resolve, reject) => { + const socket = io(`http://127.0.0.1:${port}`, { + transports: ["websocket", "polling"], + reconnection: false, + timeout: 5_000, + }) + socket.once("connect", () => resolve(socket)) + socket.once("connect_error", (error) => { + socket.disconnect() + reject(error) + }) + }) +} + +function waitFor(predicate: () => T | undefined, timeoutMs = 5_000): Promise { + return new Promise((resolve, reject) => { + const started = Date.now() + const poll = () => { + const value = predicate() + if (value !== undefined) { + resolve(value) + } else if (Date.now() - started > timeoutMs) { + reject(new Error("Timed out waiting for bridge message")) + } else { + setTimeout(poll, 10) + } + } + poll() + }) +} + +describe("BrowserBridgeServer", () => { + let bridge: BrowserBridgeServer | undefined + const sockets: Socket[] = [] + + beforeEach(async () => { + expect(getBrowserBridgePort()).toBe(0) // default: OS-assigned port + bridge = await BrowserBridgeServer.start(() => {}) + expect(bridge).toBeDefined() + }) + + afterEach(async () => { + for (const socket of sockets.splice(0)) { + socket.disconnect() + } + bridge?.dispose() + bridge = undefined + }) + + it("forwards webview->extension messages received from a browser client", async () => { + const server = bridge! + const received: WebviewMessage[] = [] + server.onWebviewMessage((message) => received.push(message)) + + const client = await connectToBridge(server.port) + sockets.push(client) + + const sent: WebviewMessage = { type: "clearTask" } + client.emit("webviewMessage", sent) + + const arrived = await waitFor(() => (received.length > 0 ? received[0] : undefined)) + expect(arrived).toEqual(sent) + }) + + it("broadcasts extension->webview messages to connected clients", async () => { + const server = bridge! + const client = await connectToBridge(server.port) + sockets.push(client) + + let inbound: unknown + client.on("extensionMessage", (message: unknown) => { + inbound = message + }) + + const sent: ExtensionMessage = { type: "state", state: { clineMessages: [] } as never } + server.broadcast(sent) + + const arrived = await waitFor(() => inbound) + expect(arrived).toEqual(sent) + }) + + it("delivers virtual-webview messages to the provider listener (full round trip)", async () => { + const server = bridge! + const webview = server.getOrCreateVirtualWebview() + + const providerReceived: WebviewMessage[] = [] + webview.onDidReceiveMessage((message) => { + providerReceived.push(message as WebviewMessage) + }) + + const client = await connectToBridge(server.port) + sockets.push(client) + + const sent: WebviewMessage = { type: "clearTask" } + client.emit("webviewMessage", sent) + + const arrived = await waitFor(() => (providerReceived.length > 0 ? providerReceived[0] : undefined)) + expect(arrived).toEqual(sent) + }) + + it("getBrowserUrl builds the Vite dev-server URL with the bridge port", () => { + const server = bridge! + expect(server.getBrowserUrl()).toBe(`http://localhost:5173/?bridgePort=${server.port}`) + expect(BrowserBridgeServer.getBrowserUrl(43210)).toBe("http://localhost:5173/?bridgePort=43210") + }) + + it("getPlaceholderHtml renders browser-mode info with a clickable valid link", () => { + const server = bridge! + const html = server.getPlaceholderHtml() + + expect(html).toContain("") + // Browser-mode notice: the tab cannot be restored. + expect(html).toContain("browser mode") + expect(html).toContain("cannot be restored") + // The link points at the exact URL that opens the browser tab. + expect(html).toContain(`href="${server.getBrowserUrl()}"`) + }) + + it("stop delivers after the listener is disposed", async () => { + const server = bridge! + const received: WebviewMessage[] = [] + const subscription = server.onWebviewMessage((message) => received.push(message)) + + const client = await connectToBridge(server.port) + sockets.push(client) + + subscription.dispose() + client.emit("webviewMessage", { type: "clearTask" } satisfies WebviewMessage) + await new Promise((resolve) => setTimeout(resolve, 150)) + + expect(received).toHaveLength(0) + }) +}) diff --git a/src/core/webview/browserBridge.ts b/src/core/webview/browserBridge.ts new file mode 100644 index 0000000000..1e6bcd083d --- /dev/null +++ b/src/core/webview/browserBridge.ts @@ -0,0 +1,272 @@ +import { createServer } from "http" + +import { Server as SocketIoServer, type Socket } from "socket.io" +import { Uri } from "vscode" +import type { Disposable, Webview } from "vscode" + +import type { ExtensionMessage, WebviewMessage } from "@roo-code/types" + +/** + * Browser bridge — standalone-browser UI transport for the Zoo Code webview + * (development-only tooling for analyzing render issues with full + * React DevTools / Chrome DevTools support). + * + * Each `ClineProvider` owns exactly one {@link BrowserBridgeServer} instance. + * The bridge is started on demand from the dev-only `zoo-code.openInBrowser` + * command, which is registered only when `ROO_BROWSER_BRIDGE=1` is set in a + * Development extension host (see `registerCommands.ts`); it is not + * contributed in package.json. Once started, the extension host serves a + * socket.io server on `127.0.0.1:` and swaps the real VSCode webview for + * a virtual one ({@link createVirtualWebview}). The UI then lives entirely in a + * normal Chrome tab (loaded from the Vite dev server) instead of the VSCode + * webview iframe, while the message protocol (`WebviewMessage` / + * `ExtensionMessage`) stays unchanged. + * + * Ports are unique per bridge: the OS assigns a free port unless + * `ROO_BROWSER_BRIDGE_PORT` is set (a dev-only override). The actual port is + * exposed via {@link BrowserBridgeServer.port} and passed to the browser tab + * as a `?bridgePort=` URL query parameter, so any number of Zoo Code + * tabs or sidebar panels can run in the browser simultaneously, each on its + * own port. + */ + +export const DEFAULT_BROWSER_BRIDGE_PORT = 0 + +/** + * Base URL of the webview-ui Vite dev server. It always runs on a fixed port + * (see webview-ui/vite.config.ts); if it's not up, the browser tab simply + * shows a connection error — good enough for a dev-only tool. + */ +const VITE_BASE_URL = "http://localhost:5173" + +/** + * The port the bridge binds to. Defaults to `0` (let the OS pick a free port) + * so every provider gets a unique port. `ROO_BROWSER_BRIDGE_PORT` can still + * override this for parallel dev hosts that need a fixed, known port. + */ +export function getBrowserBridgePort(): number { + const raw = process.env.ROO_BROWSER_BRIDGE_PORT + if (raw === undefined || raw === "") { + return DEFAULT_BROWSER_BRIDGE_PORT + } + const port = Number(raw) + return Number.isInteger(port) && port > 0 && port < 65536 ? port : DEFAULT_BROWSER_BRIDGE_PORT +} + +/** + * Resolves the port a bound http server is actually listening on. When + * `port: 0` was requested, the OS picks a free port and reports it back here. + */ +function getBoundPort(httpServer: SocketIoServer["httpServer"], requestedPort: number): number { + const address = httpServer.address() + if (address && typeof address === "object") { + return address.port + } + return requestedPort +} + +/** + * Server side of the bridge. Binds to 127.0.0.1 only and restricts CORS to + * local origins, since this is a development-only transport. + */ +export class BrowserBridgeServer implements Disposable { + private readonly server: SocketIoServer + private readonly _port: number + + private readonly webviewMessageListeners = new Set<(message: WebviewMessage) => void>() + + private virtualWebview: Webview | undefined + + private constructor(server: SocketIoServer, port: number) { + this.server = server + this._port = port + + // In socket.io v4 client-emitted events arrive on the individual + // socket, not on the Server instance: forward each socket's + // "webviewMessage" events to the onWebviewMessage() subscribers. + server.on("connection", (socket: Socket) => { + socket.on("webviewMessage", (message: WebviewMessage) => { + for (const listener of this.webviewMessageListeners) { + listener(message) + } + }) + }) + } + + /** The TCP port this bridge is listening on. */ + get port(): number { + return this._port + } + + /** + * URL a browser tab must load to connect to the bridge listening on + * `port` (the Vite dev server plus the `?bridgePort` query parameter). + */ + static getBrowserUrl(port: number): string { + return `${VITE_BASE_URL}/?bridgePort=${port}` + } + + /** + * URL a browser tab must load to connect to this bridge. + */ + getBrowserUrl(): string { + return BrowserBridgeServer.getBrowserUrl(this._port) + } + + /** + * Placeholder rendered inside the real VSCode webview when the browser + * bridge is active. The UI lives only in Chrome; this keeps the iframe + * empty (no React, no scripts), while native VSCode webview chrome + * (tab/sidebar shell) stays. The message tells the developer the tab + * cannot be restored and links back to the browser tab so it can be + * reopened by clicking. + */ + getPlaceholderHtml(): string { + const url = this.getBrowserUrl() + return /*html*/ ` + + + + Zoo Code + + +
+

+ You have started browser mode. The Zoo Code UI now runs in a + Chrome tab connected via the browser bridge, and this tab + cannot be restored. +

+

+ ${url} +

+
+ +` + } + + /** + * Starts the bridge server on {@link getBrowserBridgePort}. Returns + * undefined when the port is already taken (only possible with an explicit + * `ROO_BROWSER_BRIDGE_PORT`, e.g. a second dev host). + * + * `onError` (when provided) is invoked with the failure so callers can + * surface it to the user (e.g. `vscode.window.showErrorMessage`) instead + * of only writing to a log. + */ + static async start( + log: (message: string) => void = console.log, + onError?: (error: Error) => void, + ): Promise { + const requestedPort = getBrowserBridgePort() + + // Create the http server explicitly (the socket.io Server constructor + // with options alone does not own one) and let socket.io take over its + // lifecycle. + const httpServer = createServer() + const server = new SocketIoServer(httpServer, { + // Development-only transport: bind loopback and allow local origins only. + cors: { + origin: [/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/], + }, + transports: ["websocket", "polling"], + }) + + try { + await new Promise((resolve, reject) => { + httpServer.once("error", reject) + httpServer.listen({ port: requestedPort, host: "127.0.0.1" }, () => { + httpServer.off("error", reject) + resolve() + }) + }) + } catch (error) { + const failure = error instanceof Error ? error : new Error(String(error)) + log(`[BrowserBridge] Failed to start on 127.0.0.1:${requestedPort} -> ${failure.message}`) + onError?.(failure) + return undefined + } + + const port = getBoundPort(httpServer, requestedPort) + log(`[BrowserBridge] Listening on ws://127.0.0.1:${port}`) + return new BrowserBridgeServer(server, port) + } + + /** + * The single virtual webview for this bridge. Reused across webview + * re-resolves so bridge-level listeners are registered exactly once. + */ + getOrCreateVirtualWebview(): Webview { + if (!this.virtualWebview) { + this.virtualWebview = createVirtualWebview(this) + } + return this.virtualWebview + } + + /** + * Broadcasts an extension->webview message to all connected browser clients. + */ + broadcast(message: ExtensionMessage): void { + this.server.emit("extensionMessage", message) + } + + /** + * Subscribes to webview->extension messages coming from browser clients. + * Returns a dispose function. + */ + onWebviewMessage(listener: (message: WebviewMessage) => void): Disposable { + this.webviewMessageListeners.add(listener) + return { + dispose: () => { + this.webviewMessageListeners.delete(listener) + }, + } + } + + dispose(): void { + try { + void this.server.close() + } catch { + // Already closed + } + } +} + +/** + * A virtual `vscode.Webview` implementation backed by the browser bridge. + * + * Mirrors the CLI mock-webview pattern: `postMessage` broadcasts to the browser, + * `onDidReceiveMessage` forwards browser messages to the provider. The real + * iframe webview is intentionally not used — the UI runs only in Chrome. + */ +export function createVirtualWebview(bridge: BrowserBridgeServer): Webview { + const messageListeners = new Set<(message: WebviewMessage) => void>() + + bridge.onWebviewMessage((message) => { + for (const listener of messageListeners) { + listener(message) + } + }) + + return { + options: { enableScripts: true }, + cspSource: "vscode-webview://bridge", + html: "", + postMessage(message: unknown): Thenable { + bridge.broadcast(message as ExtensionMessage) + return Promise.resolve(true) + }, + onDidReceiveMessage(listener: (message: unknown) => void): Disposable { + const wrapped = listener as (message: WebviewMessage) => void + messageListeners.add(wrapped) + return { + dispose: () => { + messageListeners.delete(wrapped) + }, + } + }, + asWebviewUri(localResource: Uri): Uri { + // The browser UI never renders webview URIs; return the input unchanged. + return localResource + }, + } +} diff --git a/src/package.json b/src/package.json index 4e9bfcfcf7..8e2c7a58bb 100644 --- a/src/package.json +++ b/src/package.json @@ -516,6 +516,7 @@ "serialize-error": "^12.0.0", "shell-quote": "^1.8.2", "simple-git": "^3.27.0", + "socket.io": "^4.8.3", "strip-bom": "^5.0.0", "tiktoken": "^1.0.21", "tree-sitter-wasms": "^0.1.13", @@ -550,6 +551,7 @@ "nock": "14.0.15", "ovsx": "0.10.12", "rimraf": "6.0.1", + "socket.io-client": "4.8.3", "vitest": "4.1.9" } } diff --git a/webview-ui/package.json b/webview-ui/package.json index 450288eff5..ff15cc6a07 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.json @@ -72,6 +72,7 @@ "remove-markdown": "^0.6.4", "shell-quote": "^1.8.2", "shiki": "^3.2.1", + "socket.io-client": "^4.8.3", "source-map": "^0.7.4", "stacktrace-js": "^2.0.2", "styled-components": "^6.1.13", diff --git a/webview-ui/src/index.css b/webview-ui/src/index.css index bccbdf7031..fab7265e27 100644 --- a/webview-ui/src/index.css +++ b/webview-ui/src/index.css @@ -185,6 +185,184 @@ --radius: 0.5rem; } + /* + * Standalone browser mode (browser bridge): VS Code is not present, so the + * --vscode-* variables it normally injects into the webview are missing. + * Provide a minimal dark-theme fallback scoped to the marker class added by + * the browser bridge client so the UI renders with a usable theme. + */ + html.roo-browser-mode { + --vscode-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --vscode-font-size: 13px; + + --vscode-editor-background: #1e1e1e; + --vscode-editor-foreground: #d4d4d4; + --vscode-editorGroup-border: #444444; + --vscode-editorWarning-foreground: #cca700; + --vscode-editorWarning-background: #352a05; + + --vscode-foreground: #cccccc; + + --vscode-button-foreground: #ffffff; + --vscode-button-background: #0e639c; + --vscode-button-secondaryForeground: #ffffff; + --vscode-button-secondaryBackground: #3a3d41; + --vscode-button-hoverBackground: #1177bb; + + --vscode-dropdown-foreground: #cccccc; + --vscode-dropdown-background: #3c3c3c; + --vscode-dropdown-border: #3c3c3c; + + --vscode-input-foreground: #cccccc; + --vscode-input-background: #3c3c3c; + --vscode-input-border: #3c3c3c; + + --vscode-focusBorder: #007fd4; + + --vscode-badge-foreground: #ffffff; + --vscode-badge-background: #4d4d4d; + + --vscode-notifications-foreground: #cccccc; + --vscode-notifications-background: #252526; + --vscode-notifications-border: #303031; + + --vscode-descriptionForeground: #9d9d9d; + --vscode-errorForeground: #f48771; + + --vscode-list-hoverForeground: #ffffff; + --vscode-list-hoverBackground: #2a2d2e; + --vscode-list-focusBackground: #094771; + --vscode-list-activeSelectionBackground: #094771; + --vscode-list-activeSelectionForeground: #ffffff; + + --vscode-toolbar-hoverBackground: rgba(90, 93, 94, 0.31); + --vscode-toolbar-hoverOutline: rgba(255, 255, 255, 0.12); + + --vscode-panel-border: #444444; + + --vscode-sideBar-foreground: #cccccc; + --vscode-sideBar-background: #252526; + --vscode-sideBar-border: #444444; + + --vscode-sideBarSectionHeader-foreground: #cccccc; + --vscode-sideBarSectionHeader-background: #2b2b2b; + --vscode-sideBarSectionHeader-border: #444444; + + --vscode-titleBar-activeForeground: #cccccc; + --vscode-titleBar-inactiveForeground: #9d9d9d; + + --vscode-charts-green: #89d185; + --vscode-charts-red: #f14c4c; + --vscode-charts-yellow: #cca700; + --vscode-charts-blue: #3794ff; + --vscode-charts-orange: #d18616; + + --vscode-inputValidation-infoForeground: #3794ff; + --vscode-inputValidation-infoBackground: #063b49; + --vscode-inputValidation-infoBorder: #007acc; + --vscode-inputValidation-warningForeground: #cca700; + --vscode-inputValidation-warningBackground: #352a05; + --vscode-inputValidation-warningBorder: #b89500; + --vscode-inputValidation-errorForeground: #f48771; + --vscode-inputValidation-errorBackground: #5a1d1d; + --vscode-inputValidation-errorBorder: #be1100; + + --vscode-textCodeBlock-background: #2d2d2d; + --vscode-menu-foreground: #cccccc; + --vscode-menu-background: #252526; + --vscode-menu-selectionBackground: #094771; + + --vscode-textLink-foreground: #3794ff; + --vscode-disabledForeground: #6f6f6f; + --vscode-banner-background: #004578; + --vscode-banner-foreground: #ffffff; + + --vscode-font-weight: normal; + --vscode-textLink-activeForeground: #3794ff; + --vscode-scrollbarSlider-background: rgba(121, 121, 121, 0.4); + --vscode-scrollbarSlider-hoverBackground: rgba(100, 100, 100, 0.7); + --vscode-scrollbarSlider-activeBackground: rgba(191, 191, 191, 0.4); + --vscode-textPreformat-foreground: #d7ba7d; + --vscode-textPreformat-background: transparent; + --vscode-textBlockQuote-background: #222222; + --vscode-textBlockQuote-border: #3e3e42; + --vscode-keybindingLabel-background: rgba(128, 128, 128, 0.17); + --vscode-keybindingLabel-foreground: #cccccc; + --vscode-keybindingLabel-border: rgba(51, 51, 51, 0.6); + --vscode-keybindingLabel-bottomBorder: rgba(68, 68, 68, 0.6); + --vscode-widget-shadow: rgba(0, 0, 0, 0.36); + --vscode-editorWidget-foreground: #cccccc; + --vscode-editorWidget-background: #252526; + --vscode-editorWidget-border: #454545; + --vscode-progressBar-background: #0e70c0; + --vscode-icon-foreground: #c5c5c5; + --vscode-editorError-foreground: #f14c4c; + --vscode-diffEditor-insertedTextBackground: rgba(155, 185, 85, 0.2); + --vscode-diffEditor-removedTextBackground: rgba(255, 0, 0, 0.2); + } + + /* + * VS Code additionally injects an unlayered "_defaultStyles" stylesheet + * into every webview (html/body background + text color, font, scrollbars, + * link/code colors). A plain browser tab has none of that, which is why + * body and input text rendered black-on-white even with the variables + * above. Mirror the essentials here, scoped to browser mode. + */ + html.roo-browser-mode { + background-color: var(--vscode-editor-background); + color: var(--vscode-editor-foreground); + } + + html.roo-browser-mode body { + overscroll-behavior-x: none; + background-color: transparent; + color: var(--vscode-editor-foreground); + font-family: var(--vscode-font-family); + font-weight: var(--vscode-font-weight); + font-size: var(--vscode-font-size); + padding: 0 20px; + } + + html.roo-browser-mode a, + html.roo-browser-mode a code { + color: var(--vscode-textLink-foreground); + } + + html.roo-browser-mode a:hover { + color: var(--vscode-textLink-activeForeground); + } + + html.roo-browser-mode code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + color: var(--vscode-textPreformat-foreground); + } + + html.roo-browser-mode blockquote { + background: var(--vscode-textBlockQuote-background); + border-left: 1px solid var(--vscode-textBlockQuote-border); + } + + html.roo-browser-mode ::-webkit-scrollbar { + width: 10px; + height: 10px; + } + + html.roo-browser-mode ::-webkit-scrollbar-corner { + background-color: var(--vscode-editor-background); + } + + html.roo-browser-mode ::-webkit-scrollbar-thumb { + background-color: var(--vscode-scrollbarSlider-background); + } + + html.roo-browser-mode ::-webkit-scrollbar-thumb:hover { + background-color: var(--vscode-scrollbarSlider-hoverBackground); + } + + html.roo-browser-mode ::-webkit-scrollbar-thumb:active { + background-color: var(--vscode-scrollbarSlider-activeBackground); + } + /* Higher specififty than VSCode's theme and root. */ /* Used for baseline theme overrides, but avoid using for styling. */ diff --git a/webview-ui/src/utils/vscode.ts b/webview-ui/src/utils/vscode.ts index 2cc0a58909..e3c94a7c24 100644 --- a/webview-ui/src/utils/vscode.ts +++ b/webview-ui/src/utils/vscode.ts @@ -1,7 +1,67 @@ import type { WebviewApi } from "vscode-webview" +import { io, type Socket } from "socket.io-client" import { WebviewMessage } from "@roo/WebviewMessage" +/** + * Browser bridge transport for standalone (non-webview) browser mode. + * + * When the UI runs in a normal Chrome tab (loaded from the Vite dev server), + * `acquireVsCodeApi` is undefined. Messages then flow over socket.io to the + * extension host instead of the VSCode webview message protocol: + * + * - `postMessage(message)` emits `webviewMessage` to the bridge server + * - inbound `extensionMessage` events are re-dispatched through + * `window.postMessage` so the existing `window.addEventListener("message")` + * consumers in the app work unchanged. + */ +class BrowserBridgeClient { + private readonly socket: Socket + + constructor(port: number) { + this.socket = io(`http://127.0.0.1:${port}`, { + transports: ["websocket", "polling"], + }) + + // Mark the document so CSS can provide a dark-theme fallback for the + // --vscode-* variables that VS Code normally injects into the webview. + document.documentElement.classList.add("roo-browser-mode") + + this.socket.on("extensionMessage", (message: unknown) => { + window.postMessage(message, "*") + }) + + this.socket.on("connect_error", (error) => { + console.warn("[BrowserBridge] socket.io connect error:", error) + }) + } + + public postMessage(message: WebviewMessage) { + this.socket.emit("webviewMessage", message) + } + + public dispose() { + this.socket.disconnect() + } +} + +/** + * The bridge port is served as a `?bridgePort=` query parameter on the + * URL opened in Chrome by the "Open in Chrome" command. Its presence tells the + * UI that the browser bridge is active — there is no env-var or build-time + * flag involved. Returns `undefined` when the param is absent. + */ +function getBridgePortFromUrl(): number | undefined { + const raw = new URLSearchParams(window.location.search).get("bridgePort") + if (raw !== null && raw !== "") { + const port = Number(raw) + if (Number.isInteger(port) && port > 0 && port < 65536) { + return port + } + } + return undefined +} + /** * A utility wrapper around the acquireVsCodeApi() function, which enables * message passing and state management between the webview and extension @@ -13,12 +73,21 @@ import { WebviewMessage } from "@roo/WebviewMessage" */ class VSCodeAPIWrapper { private readonly vsCodeApi: WebviewApi | undefined + private readonly browserBridge: BrowserBridgeClient | undefined constructor() { // Check if the acquireVsCodeApi function exists in the current development // context (i.e. VS Code development window or web browser) if (typeof acquireVsCodeApi === "function") { this.vsCodeApi = acquireVsCodeApi() + } else { + // Use the browser bridge only when the "Open in Chrome" flow opened + // this tab with a ?bridgePort=... query param. Otherwise (e.g. a + // plain dev-server tab) fall back to localStorage-only behavior. + const bridgePort = getBridgePortFromUrl() + if (bridgePort !== undefined) { + this.browserBridge = new BrowserBridgeClient(bridgePort) + } } } @@ -26,13 +95,15 @@ class VSCodeAPIWrapper { * Post a message (i.e. send arbitrary data) to the owner of the webview. * * @remarks When running webview code inside a web browser, postMessage will instead - * log the given message to the console. + * send the message over the browser bridge (socket.io) to the extension host. * * @param message Arbitrary data (must be JSON serializable) to send to the extension context. */ public postMessage(message: WebviewMessage) { if (this.vsCodeApi) { this.vsCodeApi.postMessage(message) + } else if (this.browserBridge) { + this.browserBridge.postMessage(message) } else { console.log(message) }