From 63d6267dd8e86ebb6c7663db25ff394031c83a57 Mon Sep 17 00:00:00 2001 From: hnbdr Date: Thu, 10 Sep 2026 20:27:27 +0300 Subject: [PATCH] feat(webview): add dev-only browser bridge for standalone Chrome UI Runs the Zoo Code interface as the main page of a regular Chrome tab instead of a nested VSCode webview iframe. Inside the iframe the UI shares one DevTools panel with the rest of the window and cannot load DevTools extensions, which makes inspection awkward. As a top-level document the UI gets its own DevTools with installable extensions (React DevTools, etc.), and the extension IPC becomes plain socket.io traffic that can be monitored live in the DevTools network panel as websocket frames. - Add BrowserBridgeServer: a loopback-only socket.io server owned by each ClineProvider, with a virtual webview that keeps the existing WebviewMessage/ExtensionMessage protocol unchanged. - Register a dev-only `zoo-code.openInBrowser` command (activated only with ROO_BROWSER_BRIDGE=1 in Development mode; intentionally not contributed in package.json so end users never see it) that starts the bridge, swaps the real webview for a placeholder with a link, and opens the browser tab. - webview-ui: BrowserBridgeClient in VSCodeAPIWrapper activates when the tab is opened with ?bridgePort=..., plus a dark-theme fallback for --vscode-* CSS variables in standalone browser mode. --- .vscode/launch.json | 5 +- pnpm-lock.yaml | 137 +++++++++ .../__tests__/registerCommands.spec.ts | 135 ++++++++- src/activate/registerCommands.ts | 54 ++++ src/core/webview/ClineProvider.ts | 94 +++++- .../webview/__tests__/ClineProvider.spec.ts | 66 +++++ .../webview/__tests__/browserBridge.spec.ts | 156 ++++++++++ src/core/webview/browserBridge.ts | 272 ++++++++++++++++++ src/package.json | 2 + webview-ui/package.json | 1 + webview-ui/src/index.css | 178 ++++++++++++ webview-ui/src/utils/vscode.ts | 73 ++++- 12 files changed, 1159 insertions(+), 14 deletions(-) create mode 100644 src/core/webview/__tests__/browserBridge.spec.ts create mode 100644 src/core/webview/browserBridge.ts 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) }