diff --git a/apps/code/src/main/services/browser-view/service.test.ts b/apps/code/src/main/services/browser-view/service.test.ts new file mode 100644 index 0000000000..7762d2972e --- /dev/null +++ b/apps/code/src/main/services/browser-view/service.test.ts @@ -0,0 +1,30 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("browserViewService", () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it.each([ + ["true", true], + ["false", false], + ])("defaults to %s in development mode", async (isDev, expected) => { + vi.stubEnv("POSTHOG_CODE_IS_DEV", isDev); + const { browserViewService } = await import("./service"); + + expect(browserViewService.isEnabled()).toBe(expected); + }); + + it("updates the attachment gate", async () => { + vi.stubEnv("POSTHOG_CODE_IS_DEV", "false"); + const { browserViewService } = await import("./service"); + + browserViewService.setEnabled(true); + + expect(browserViewService.isEnabled()).toBe(true); + }); +}); diff --git a/apps/code/src/main/services/browser-view/service.ts b/apps/code/src/main/services/browser-view/service.ts new file mode 100644 index 0000000000..3f709e956a --- /dev/null +++ b/apps/code/src/main/services/browser-view/service.ts @@ -0,0 +1,15 @@ +import { isDevBuild } from "../../utils/env"; + +class BrowserViewService { + private enabled = isDevBuild(); + + isEnabled(): boolean { + return this.enabled; + } + + setEnabled(enabled: boolean): void { + this.enabled = enabled; + } +} + +export const browserViewService = new BrowserViewService(); diff --git a/apps/code/src/main/trpc/router.ts b/apps/code/src/main/trpc/router.ts index 68ef63321c..ef17443ebc 100644 --- a/apps/code/src/main/trpc/router.ts +++ b/apps/code/src/main/trpc/router.ts @@ -50,6 +50,7 @@ import { uiRouter } from "@posthog/host-router/routers/ui.router"; import { updatesRouter } from "@posthog/host-router/routers/updates.router"; import { usageMonitorRouter } from "@posthog/host-router/routers/usage-monitor.router"; import { workspaceRouter } from "@posthog/host-router/routers/workspace.router"; +import { browserViewRouter } from "./routers/browser-view"; import { devRouter } from "./routers/dev"; import { discordPresenceRouter } from "./routers/discord-presence"; import { encryptionRouter } from "./routers/encryption"; @@ -64,6 +65,7 @@ export const trpcRouter = router({ auth: authRouter, autoresearch: autoresearchRouter, browserTabs: browserTabsRouter, + browserView: browserViewRouter, canvasData: canvasDataRouter, canvasTemplates: canvasTemplatesRouter, channelTasks: channelTasksRouter, diff --git a/apps/code/src/main/trpc/routers/browser-view.ts b/apps/code/src/main/trpc/routers/browser-view.ts new file mode 100644 index 0000000000..451e53612f --- /dev/null +++ b/apps/code/src/main/trpc/routers/browser-view.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; +import { browserViewService } from "../../services/browser-view/service"; +import { publicProcedure, router } from "../trpc"; + +export const browserViewRouter = router({ + setEnabled: publicProcedure + .input(z.object({ enabled: z.boolean() })) + .mutation(({ input }) => browserViewService.setEnabled(input.enabled)), +}); diff --git a/apps/code/src/main/utils/webview-attach-policy.test.ts b/apps/code/src/main/utils/webview-attach-policy.test.ts new file mode 100644 index 0000000000..77a1ceacb7 --- /dev/null +++ b/apps/code/src/main/utils/webview-attach-policy.test.ts @@ -0,0 +1,53 @@ +import { BROWSER_WEBVIEW_PARTITION } from "@shared/browser-view"; +import { describe, expect, it } from "vitest"; +import { + hardenWebviewPreferences, + isAllowedWebviewAttachment, +} from "./webview-attach-policy"; + +describe("isAllowedWebviewAttachment", () => { + it.each([ + ["https://posthog.com", BROWSER_WEBVIEW_PARTITION, true], + ["about:blank", BROWSER_WEBVIEW_PARTITION, true], + ["http://localhost:3000", BROWSER_WEBVIEW_PARTITION, true], + ["file:///etc/passwd", BROWSER_WEBVIEW_PARTITION, false], + ["https://posthog.com", "persist:attacker", false], + ["https://posthog.com", "", false], + ])("src %j partition %j -> allowed %s", (src, partition, allowed) => { + expect(isAllowedWebviewAttachment({ src, partition })).toBe(allowed); + }); +}); + +describe("hardenWebviewPreferences", () => { + it("overrides security-sensitive guest preferences", () => { + const preferences = { + preload: "/tmp/attacker.js", + nodeIntegration: true, + nodeIntegrationInSubFrames: true, + nodeIntegrationInWorker: true, + contextIsolation: false, + sandbox: false, + webSecurity: false, + allowRunningInsecureContent: true, + experimentalFeatures: true, + enableBlinkFeatures: "Serial", + webviewTag: true, + }; + + hardenWebviewPreferences(preferences); + + expect(preferences).toEqual({ + preload: undefined, + nodeIntegration: false, + nodeIntegrationInSubFrames: false, + nodeIntegrationInWorker: false, + contextIsolation: true, + sandbox: true, + webSecurity: true, + allowRunningInsecureContent: false, + experimentalFeatures: false, + enableBlinkFeatures: undefined, + webviewTag: false, + }); + }); +}); diff --git a/apps/code/src/main/utils/webview-attach-policy.ts b/apps/code/src/main/utils/webview-attach-policy.ts new file mode 100644 index 0000000000..0ce55a7832 --- /dev/null +++ b/apps/code/src/main/utils/webview-attach-policy.ts @@ -0,0 +1,41 @@ +import { BROWSER_WEBVIEW_PARTITION } from "@shared/browser-view"; +import { isAllowedWebviewNavigation } from "./webview-navigation-guard"; + +interface WebviewSecurityPreferences { + preload?: string; + nodeIntegration?: boolean; + nodeIntegrationInSubFrames?: boolean; + nodeIntegrationInWorker?: boolean; + contextIsolation?: boolean; + sandbox?: boolean; + webSecurity?: boolean; + allowRunningInsecureContent?: boolean; + experimentalFeatures?: boolean; + enableBlinkFeatures?: string; + webviewTag?: boolean; +} + +export function isAllowedWebviewAttachment( + params: Record, +): boolean { + return ( + params.partition === BROWSER_WEBVIEW_PARTITION && + isAllowedWebviewNavigation(params.src) + ); +} + +export function hardenWebviewPreferences( + preferences: WebviewSecurityPreferences, +): void { + preferences.preload = undefined; + preferences.nodeIntegration = false; + preferences.nodeIntegrationInSubFrames = false; + preferences.nodeIntegrationInWorker = false; + preferences.contextIsolation = true; + preferences.sandbox = true; + preferences.webSecurity = true; + preferences.allowRunningInsecureContent = false; + preferences.experimentalFeatures = false; + preferences.enableBlinkFeatures = undefined; + preferences.webviewTag = false; +} diff --git a/apps/code/src/main/utils/webview-navigation-guard.test.ts b/apps/code/src/main/utils/webview-navigation-guard.test.ts new file mode 100644 index 0000000000..547e1c4935 --- /dev/null +++ b/apps/code/src/main/utils/webview-navigation-guard.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { + isAllowedWebviewNavigation, + isAllowedWebviewRequest, + isBlockedWebviewHost, +} from "./webview-navigation-guard"; + +describe("isBlockedWebviewHost", () => { + it.each([ + ["169.254.169.254", true], + ["169.254.0.1", true], + // WHATWG URL folds decimal/hex IPv4 to dotted form before this runs. + [new URL("http://2852039166/").hostname, true], + // IPv6-mapped IPv4: OS still connects to the v4 metadata service. + [new URL("http://[::ffff:169.254.169.254]/").hostname, true], + ["metadata.google.internal", true], + ["METADATA.GOOGLE.INTERNAL", true], + ["localhost", false], + ["127.0.0.1", false], + ["192.168.1.5", false], + ["posthog.com", false], + // Not the metadata address — a normal 169.253.x host is allowed. + ["169.253.1.1", false], + ])("host %j -> blocked %s", (hostname, blocked) => { + expect(isBlockedWebviewHost(hostname)).toBe(blocked); + }); +}); + +describe("isAllowedWebviewRequest", () => { + it.each([ + ["https://posthog.com", "mainFrame", true], + ["http://posthog.com", "mainFrame", false], + ["https://posthog.com/app.js", "script", true], + ["http://localhost:3000/app.js", "script", true], + ["http://posthog.com/app.js", "script", false], + ["data:image/png;base64,AA==", "image", true], + ["blob:https://posthog.com/id", "xhr", true], + ["about:blank", "subFrame", true], + ["about:srcdoc", "subFrame", false], + ["file:///etc/passwd", "xhr", false], + ["custom-scheme://host/path", "xhr", false], + ["http://169.254.169.254/latest/meta-data/", "xhr", false], + ])("url %j resource %j -> allowed %s", (url, resourceType, allowed) => { + expect(isAllowedWebviewRequest(url, resourceType)).toBe(allowed); + }); +}); + +describe("isAllowedWebviewNavigation", () => { + it.each([ + ["https://posthog.com", true], + ["http://localhost:3000", true], + ["http://127.0.0.2:3000", true], + ["http://0.0.0.0:3000", true], + ["http://[::1]:3000", true], + ["http://posthog.com", false], + ["http://192.168.1.5", false], + ["about:blank", true], + ["about:srcdoc", false], + ["about:config", false], + // Blocked schemes fall through to search in the renderer; the guard vetoes. + ["file:///etc/passwd", false], + ["chrome://settings", false], + ["javascript:alert(1)", false], + ["data:text/html,

hi

", false], + // Metadata endpoint over an allowed scheme is still blocked by host. + ["http://169.254.169.254/latest/meta-data/", false], + ["http://metadata.google.internal/computeMetadata/v1/", false], + ["not a url", false], + ])("url %j -> allowed %s", (url, allowed) => { + expect(isAllowedWebviewNavigation(url)).toBe(allowed); + }); +}); diff --git a/apps/code/src/main/utils/webview-navigation-guard.ts b/apps/code/src/main/utils/webview-navigation-guard.ts new file mode 100644 index 0000000000..cd1671471b --- /dev/null +++ b/apps/code/src/main/utils/webview-navigation-guard.ts @@ -0,0 +1,82 @@ +// The authoritative gate for the in-app browser guest. It runs in the main +// process, where a guest page can't route around it; the renderer's +// normalizeAddress is only a convenience on top of this. about:blank is allowed +// solely as the src a new blank browser tab mounts with before +// the user enters a url. +const LOOPBACK_HOSTS = new Set(["localhost", "0.0.0.0", "[::1]"]); +const LOOPBACK_IPV4 = /^127\./; + +// Blocks the cloud instance-metadata endpoint. On cloud VMs it returns IAM / +// service-account credentials to any local caller, so a hostile page that +// redirects the webview there could exfiltrate them. Loopback and LAN stay +// allowed: browsing a local dev server is a first-class use of this browser. +// +// WHATWG URL canonicalizes decimal/hex/octal IPv4 (http://2852039166) back to +// dotted form before this runs, so those are covered by the v4 range. The +// entries below close the forms it does NOT fold together: the IPv6-mapped +// address (the OS still connects to the v4 metadata service) and GCP's +// metadata DNS name. +// +// Residual gap this cannot close: DNS rebinding — an attacker domain that +// resolves to 169.254.169.254 passes, because the real defense is checking the +// *resolved* IP at connect time, which will-navigate doesn't expose. Egress +// network policy on the sandbox is the actual boundary for that. +const BLOCKED_METADATA_HOST = /^169\.254\./; +const BLOCKED_METADATA_HOST_V6 = /^\[::ffff:a9fe:a9fe\]$/i; +const BLOCKED_METADATA_HOSTNAMES = new Set(["metadata.google.internal"]); + +export function isBlockedWebviewHost(hostname: string): boolean { + const host = hostname.toLowerCase(); + return ( + BLOCKED_METADATA_HOST.test(host) || + BLOCKED_METADATA_HOST_V6.test(host) || + BLOCKED_METADATA_HOSTNAMES.has(host) + ); +} + +export function safeProtocol(url: string): string { + try { + return new URL(url).protocol; + } catch { + return ""; + } +} + +function isLoopbackHost(hostname: string): boolean { + return LOOPBACK_HOSTS.has(hostname) || LOOPBACK_IPV4.test(hostname); +} + +export function isAllowedWebviewNavigation(url: string): boolean { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + if (parsed.href === "about:blank") return true; + if (isBlockedWebviewHost(parsed.hostname)) return false; + if (parsed.protocol === "https:") return true; + return parsed.protocol === "http:" && isLoopbackHost(parsed.hostname); +} + +export function isAllowedWebviewRequest( + url: string, + resourceType: string, +): boolean { + if (resourceType === "mainFrame") { + return isAllowedWebviewNavigation(url); + } + + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + + if (parsed.href === "about:blank") return true; + if (isBlockedWebviewHost(parsed.hostname)) return false; + if (parsed.protocol === "data:" || parsed.protocol === "blob:") return true; + if (parsed.protocol === "https:") return true; + return parsed.protocol === "http:" && isLoopbackHost(parsed.hostname); +} diff --git a/apps/code/src/main/utils/webview-permission-policy.test.ts b/apps/code/src/main/utils/webview-permission-policy.test.ts new file mode 100644 index 0000000000..9cc1543637 --- /dev/null +++ b/apps/code/src/main/utils/webview-permission-policy.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { isAllowedWebviewPermission } from "./webview-permission-policy"; + +describe("isAllowedWebviewPermission", () => { + it.each([ + "clipboard-read", + "media", + "geolocation", + "notifications", + "openExternal", + "future-electron-permission", + ])("denies %s", (permission) => { + expect(isAllowedWebviewPermission(permission)).toBe(false); + }); +}); diff --git a/apps/code/src/main/utils/webview-permission-policy.ts b/apps/code/src/main/utils/webview-permission-policy.ts new file mode 100644 index 0000000000..401a6b3602 --- /dev/null +++ b/apps/code/src/main/utils/webview-permission-policy.ts @@ -0,0 +1,5 @@ +const ALLOWED_WEBVIEW_PERMISSIONS: ReadonlySet = new Set(); + +export function isAllowedWebviewPermission(permission: string): boolean { + return ALLOWED_WEBVIEW_PERMISSIONS.has(permission); +} diff --git a/apps/code/src/main/window.ts b/apps/code/src/main/window.ts index 62edd9f0ea..3125c179d0 100644 --- a/apps/code/src/main/window.ts +++ b/apps/code/src/main/window.ts @@ -6,9 +6,11 @@ import { DARK_APP_BACKGROUND_COLOR } from "@posthog/shared/constants"; import { app, BrowserWindow, + session as electronSession, Menu, type MenuItemConstructorOptions, screen, + shell, } from "electron"; import { container } from "./di/container"; import { setupExternalLinkHandlers } from "./external-links"; @@ -16,6 +18,7 @@ import { buildApplicationMenu } from "./menu"; import type { ElectronMainWindow } from "./platform-adapters/electron-main-window"; import { posthogNodeAnalytics } from "./platform-adapters/posthog-analytics"; import { POSTHOG_SESSION_ID_ARG } from "./posthog-session-arg"; +import { browserViewService } from "./services/browser-view/service"; import { encodeDevFlagsForArg, readDevFlagsSync, @@ -32,6 +35,15 @@ import { type WindowStateSchema, windowStateStore, } from "./utils/store"; +import { + hardenWebviewPreferences, + isAllowedWebviewAttachment, +} from "./utils/webview-attach-policy"; +import { + isAllowedWebviewNavigation, + isAllowedWebviewRequest, +} from "./utils/webview-navigation-guard"; +import { isAllowedWebviewPermission } from "./utils/webview-permission-policy"; import { setupWindowZoom } from "./zoom"; const log = logger.scope("window"); @@ -120,6 +132,77 @@ export function focusMainWindow(reason: string): void { } } +const hardenedWebviewSessions = new WeakSet(); + +function hardenWebviewSession(session: Electron.Session): void { + if (hardenedWebviewSessions.has(session)) return; + hardenedWebviewSessions.add(session); + + session.setPermissionRequestHandler((_wc, permission, callback) => { + callback(isAllowedWebviewPermission(permission)); + }); + session.setPermissionCheckHandler((_wc, permission) => + isAllowedWebviewPermission(permission), + ); + session.webRequest.onBeforeRequest((details, callback) => { + const allowed = isAllowedWebviewRequest(details.url, details.resourceType); + if (!allowed) { + log.warn("Blocked disallowed webview request", { + resourceType: details.resourceType, + url: details.url, + }); + } + callback({ cancel: !allowed }); + }); +} + +function setupWebviewHandlers(window: BrowserWindow): void { + window.webContents.on( + "will-attach-webview", + (event, webPreferences, params) => { + if ( + !browserViewService.isEnabled() || + !isAllowedWebviewAttachment(params) + ) { + event.preventDefault(); + log.warn("Blocked disallowed webview attachment", { + src: params.src, + partition: params.partition, + }); + return; + } + hardenWebviewSession(electronSession.fromPartition(params.partition)); + hardenWebviewPreferences(webPreferences); + }, + ); + + window.webContents.on("did-attach-webview", (_event, guest) => { + hardenWebviewSession(guest.session); + + guest.setWindowOpenHandler(({ url }) => { + if (isAllowedWebviewNavigation(url)) { + void shell.openExternal(url); + } else { + log.warn("Blocked disallowed webview popup", { url }); + } + return { action: "deny" }; + }); + + const guard = ( + event: { preventDefault: () => void }, + url: string, + ): void => { + if (!isAllowedWebviewNavigation(url)) { + event.preventDefault(); + log.warn("Blocked disallowed webview navigation", { url }); + } + }; + guest.on("will-navigate", guard); + guest.on("will-redirect", guard); + guest.on("will-frame-navigate", (details) => guard(details, details.url)); + }); +} + function setupCrashLogging(window: BrowserWindow): void { window.webContents.on("render-process-gone", (_event, details) => { log.error("Renderer process gone", { @@ -251,6 +334,7 @@ export function createWindow(): void { webPreferences: { nodeIntegration: false, contextIsolation: true, + webviewTag: true, preload: path.join(__dirname, "preload.js"), enableBlinkFeatures: "GetDisplayMedia", partition: "persist:main", @@ -338,6 +422,7 @@ export function createWindow(): void { : pathToFileURL(rendererFilePath); setupExternalLinkHandlers(mainWindow, appHome); + setupWebviewHandlers(mainWindow); setupEditableContextMenu(mainWindow); setupCrashLogging(mainWindow); buildApplicationMenu(); diff --git a/apps/code/src/renderer/contributions/browser-view.contribution.ts b/apps/code/src/renderer/contributions/browser-view.contribution.ts new file mode 100644 index 0000000000..5e39bb7ea0 --- /dev/null +++ b/apps/code/src/renderer/contributions/browser-view.contribution.ts @@ -0,0 +1,19 @@ +import type { Contribution } from "@posthog/di/contribution"; +import { BROWSER_TAB_FLAG } from "@posthog/shared/constants"; +import type { FeatureFlags } from "@posthog/ui/features/feature-flags/identifiers"; +import { trpcClient } from "@renderer/trpc/client"; + +export class BrowserViewContribution implements Contribution { + constructor(private readonly featureFlags: FeatureFlags) {} + + start(): void { + const sync = (): void => { + const enabled = + import.meta.env.DEV || this.featureFlags.isEnabled(BROWSER_TAB_FLAG); + void trpcClient.browserView.setEnabled.mutate({ enabled }); + }; + + sync(); + this.featureFlags.onFlagsLoaded(sync); + } +} diff --git a/apps/code/src/renderer/desktop-contributions.ts b/apps/code/src/renderer/desktop-contributions.ts index c3565ea3eb..176572c91f 100644 --- a/apps/code/src/renderer/desktop-contributions.ts +++ b/apps/code/src/renderer/desktop-contributions.ts @@ -16,6 +16,7 @@ import { browserTabsUiModule } from "@posthog/ui/features/browser-tabs/browser-t import { cloneUiModule } from "@posthog/ui/features/clone/clone.module"; import { connectivityUiModule } from "@posthog/ui/features/connectivity/connectivity.module"; import { discordPresenceUiModule } from "@posthog/ui/features/discord-presence/discordPresence.module"; +import { FEATURE_FLAGS } from "@posthog/ui/features/feature-flags/identifiers"; import { fileWatcherUiModule } from "@posthog/ui/features/file-watcher/file-watcher.module"; import { focusUiModule } from "@posthog/ui/features/focus/focus.module"; import { notificationsUiModule } from "@posthog/ui/features/notifications/notifications.module"; @@ -27,6 +28,7 @@ import { AnalyticsBootContribution, InboxDemoDevContribution, } from "@renderer/contributions/app-boot.contributions"; +import { BrowserViewContribution } from "@renderer/contributions/browser-view.contribution"; import { container } from "@renderer/di/container"; export function registerDesktopContributions(): void { @@ -60,5 +62,8 @@ export function registerDesktopContributions(): void { } container.bind(CONTRIBUTION).to(AnalyticsBootContribution).inSingletonScope(); + container + .bind(CONTRIBUTION) + .toConstantValue(new BrowserViewContribution(container.get(FEATURE_FLAGS))); container.bind(CONTRIBUTION).to(InboxDemoDevContribution).inSingletonScope(); } diff --git a/apps/code/src/renderer/desktop-services.ts b/apps/code/src/renderer/desktop-services.ts index 140113505c..b6ee1ca066 100644 --- a/apps/code/src/renderer/desktop-services.ts +++ b/apps/code/src/renderer/desktop-services.ts @@ -70,6 +70,10 @@ import { type IAuthSideEffects, } from "@posthog/ui/features/auth/identifiers"; import { authKeys } from "@posthog/ui/features/auth/useCurrentUser"; +import { + BROWSER_VIEW_COMPONENT, + type BrowserViewComponent, +} from "@posthog/ui/features/browser/identifiers"; import { FEATURE_FLAGS, type FeatureFlags, @@ -121,6 +125,7 @@ import { import { ELEVENLABS_API_KEY_STORE_KEY } from "@posthog/workspace-server/services/speech/identifiers"; import { container } from "@renderer/di/container"; import { RendererAuthSideEffects } from "@renderer/platform-adapters/auth-side-effects"; +import { ElectronBrowserView } from "@renderer/platform-adapters/electron-browser-view"; import { gitCacheKeyProvider } from "@renderer/platform-adapters/git-cache-keys"; import { RendererHedgehogModeHost } from "@renderer/platform-adapters/hedgehog-mode-host"; import { setupStore } from "@renderer/platform-adapters/setup"; @@ -446,6 +451,10 @@ container container.bind(SETUP_STORE).toConstantValue(setupStore); +container + .bind(BROWSER_VIEW_COMPONENT) + .toConstantValue(ElectronBrowserView); + container .bind(HOST_CAPABILITIES) .toConstantValue({ localWorkspaces: true } satisfies HostCapabilities); diff --git a/apps/code/src/renderer/di/bindings.ts b/apps/code/src/renderer/di/bindings.ts index 89926b3b7f..a34bbca4cb 100644 --- a/apps/code/src/renderer/di/bindings.ts +++ b/apps/code/src/renderer/di/bindings.ts @@ -160,6 +160,10 @@ import { AUTH_SIDE_EFFECTS, type IAuthSideEffects, } from "@posthog/ui/features/auth/identifiers"; +import { + BROWSER_VIEW_COMPONENT, + type BrowserViewComponent, +} from "@posthog/ui/features/browser/identifiers"; import { BROWSER_TABS_CLIENT, type BrowserTabsClient, @@ -351,6 +355,7 @@ export interface RendererBindings { [FILE_WATCHER_CLIENT]: FileWatcherClient; [FEATURE_FLAGS]: FeatureFlags; [AUTH_SIDE_EFFECTS]: IAuthSideEffects; + [BROWSER_VIEW_COMPONENT]: BrowserViewComponent; [SETUP_STORE]: ISetupStore; [HOST_CAPABILITIES]: HostCapabilities; diff --git a/apps/code/src/renderer/platform-adapters/electron-browser-view.tsx b/apps/code/src/renderer/platform-adapters/electron-browser-view.tsx new file mode 100644 index 0000000000..0c959536a9 --- /dev/null +++ b/apps/code/src/renderer/platform-adapters/electron-browser-view.tsx @@ -0,0 +1,124 @@ +import type { + BrowserViewHandle, + BrowserViewProps, +} from "@posthog/ui/features/browser/identifiers"; +import { trpcClient } from "@renderer/trpc/client"; +import { BROWSER_WEBVIEW_PARTITION } from "@shared/browser-view"; +import { useEffect, useRef, useState } from "react"; + +interface ElectronWebviewElement extends HTMLElement, BrowserViewHandle { + getURL(): string; + canGoBack(): boolean; + canGoForward(): boolean; +} + +type WebviewNavigateEvent = Event & { url: string; isMainFrame?: boolean }; +type WebviewTitleEvent = Event & { title: string }; +type WebviewFailLoadEvent = Event & { + errorCode: number; + errorDescription: string; + isMainFrame: boolean; +}; + +export function ElectronBrowserView({ + initialUrl, + onReady, + onNavigate, + onTitleChange, + onLoadError, + onLoadingChange, +}: BrowserViewProps) { + const webviewRef = useRef(null); + const [hostEnabled, setHostEnabled] = useState(false); + + useEffect(() => { + let active = true; + trpcClient.browserView.setEnabled + .mutate({ enabled: true }) + .then(() => { + if (active) setHostEnabled(true); + }) + .catch(() => { + if (active) onLoadError("Browser view is unavailable"); + }); + return () => { + active = false; + }; + }, [onLoadError]); + + useEffect(() => { + if (!hostEnabled) return; + const webview = webviewRef.current; + if (!webview) return; + + let ready = false; + const readyTimeout = window.setTimeout(() => { + if (ready) return; + onReady(null); + onLoadError("Browser view failed to attach"); + }, 10_000); + const handleReady = () => { + if (ready) return; + ready = true; + window.clearTimeout(readyTimeout); + onReady(webview); + }; + const handleNavigate = (event: Event) => { + const navigation = event as WebviewNavigateEvent; + if (navigation.isMainFrame === false) return; + onNavigate({ + url: navigation.url ?? webview.getURL(), + canGoBack: webview.canGoBack(), + canGoForward: webview.canGoForward(), + }); + }; + const handleTitle = (event: Event) => { + onTitleChange((event as WebviewTitleEvent).title); + }; + const handleFailLoad = (event: Event) => { + const failure = event as WebviewFailLoadEvent; + if (failure.isMainFrame === false || failure.errorCode === -3) return; + onLoadError(failure.errorDescription || "Failed to load page"); + }; + const handleStartLoading = () => onLoadingChange(true); + const handleStopLoading = () => onLoadingChange(false); + + webview.addEventListener("dom-ready", handleReady); + webview.addEventListener("did-navigate", handleNavigate); + webview.addEventListener("did-navigate-in-page", handleNavigate); + webview.addEventListener("page-title-updated", handleTitle); + webview.addEventListener("did-fail-load", handleFailLoad); + webview.addEventListener("did-start-loading", handleStartLoading); + webview.addEventListener("did-stop-loading", handleStopLoading); + + return () => { + window.clearTimeout(readyTimeout); + onReady(null); + webview.removeEventListener("dom-ready", handleReady); + webview.removeEventListener("did-navigate", handleNavigate); + webview.removeEventListener("did-navigate-in-page", handleNavigate); + webview.removeEventListener("page-title-updated", handleTitle); + webview.removeEventListener("did-fail-load", handleFailLoad); + webview.removeEventListener("did-start-loading", handleStartLoading); + webview.removeEventListener("did-stop-loading", handleStopLoading); + }; + }, [ + hostEnabled, + onLoadError, + onLoadingChange, + onNavigate, + onReady, + onTitleChange, + ]); + + if (!hostEnabled) return null; + + return ( + + ); +} diff --git a/apps/code/src/shared/browser-view.ts b/apps/code/src/shared/browser-view.ts new file mode 100644 index 0000000000..fb03b4f666 --- /dev/null +++ b/apps/code/src/shared/browser-view.ts @@ -0,0 +1 @@ +export const BROWSER_WEBVIEW_PARTITION = "persist:browser"; diff --git a/apps/code/tests/e2e/tests/browser-webview.spec.ts b/apps/code/tests/e2e/tests/browser-webview.spec.ts new file mode 100644 index 0000000000..15f2f6683d --- /dev/null +++ b/apps/code/tests/e2e/tests/browser-webview.spec.ts @@ -0,0 +1,120 @@ +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { expect, test } from "../fixtures/electron"; + +test("browser webview upgrades, navigates, blocks unsafe URLs, and recovers", async ({ + window, +}) => { + const server = createServer((_request, response) => { + response.writeHead(200, { "content-type": "text/html" }); + response.end("Browser E2E
Browser works
"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address() as AddressInfo; + const url = `http://127.0.0.1:${address.port}`; + + try { + await window.evaluate(async () => { + const bridge = ( + window as unknown as { + electronTRPC: { + onMessage(callback: (message: unknown) => void): void; + sendMessage(message: unknown): void; + }; + } + ).electronTRPC; + const id = `browser-e2e-${crypto.randomUUID()}`; + await new Promise((resolve, reject) => { + bridge.onMessage((message) => { + const response = message as { + id?: string; + error?: { message?: string }; + result?: { type: string }; + }; + if (response.id !== id) return; + if (response.error) { + reject( + new Error(response.error.message ?? "Failed to enable webview"), + ); + return; + } + resolve(); + }); + bridge.sendMessage({ + method: "request", + operation: { + context: {}, + id, + input: { enabled: true }, + path: "browserView.setEnabled", + type: "mutation", + }, + }); + }); + }); + + const result = await window.evaluate(async (targetUrl) => { + type TestWebview = HTMLElement & { + getURL(): string; + loadURL(url: string): Promise; + reload(): void; + }; + const webview = document.createElement("webview") as TestWebview; + webview.setAttribute("partition", "persist:browser"); + webview.setAttribute("src", "about:blank"); + webview.style.width = "400px"; + webview.style.height = "300px"; + document.body.appendChild(webview); + + await new Promise((resolve, reject) => { + const timeout = globalThis.setTimeout( + () => reject(new Error("Timed out waiting for dom-ready")), + 10_000, + ); + webview.addEventListener( + "dom-ready", + () => { + globalThis.clearTimeout(timeout); + resolve(); + }, + { once: true }, + ); + }); + + const upgraded = + typeof webview.loadURL === "function" && + typeof webview.getURL === "function"; + await webview.loadURL(targetUrl); + const navigatedUrl = webview.getURL(); + + let unsafeRejected = false; + try { + await webview.loadURL("file:///etc/passwd"); + } catch { + unsafeRejected = true; + } + + const reloaded = new Promise((resolve) => { + webview.addEventListener("did-stop-loading", () => resolve(), { + once: true, + }); + }); + webview.reload(); + await reloaded; + + return { + navigatedUrl, + recoveredUrl: webview.getURL(), + unsafeRejected, + upgraded, + }; + }, url); + + expect(result.upgraded).toBe(true); + expect(result.navigatedUrl).toBe(`${url}/`); + expect(result.unsafeRejected).toBe(true); + expect(result.recoveredUrl).toBe(`${url}/`); + } finally { + server.close(); + } +}); diff --git a/packages/core/src/command-center/cells.ts b/packages/core/src/command-center/cells.ts index d7457526bc..529c615a13 100644 --- a/packages/core/src/command-center/cells.ts +++ b/packages/core/src/command-center/cells.ts @@ -1,9 +1,11 @@ import type { AgentSession, WorkspaceMode } from "@posthog/shared"; import type { Task } from "@posthog/shared/domain-types"; import { + getBrowserCellUrl, getTerminalCellCwd, getTerminalCellId, isBrainrotCell, + isBrowserCell, isTerminalCell, } from "./grid"; import { type CellStatus, deriveStatus, getRepoName } from "./status"; @@ -21,6 +23,7 @@ export interface CommandCenterCellData { // Standalone terminal slot, independent of any agent run. terminalId: string | null; terminalCwd: string | null; + browserUrl: string | null; } export interface BuildCellsInput { @@ -39,6 +42,7 @@ const EMPTY_CELL_DATA = { isBrainrot: false, terminalId: null, terminalCwd: null, + browserUrl: null, }; export function buildCommandCenterCells( @@ -60,6 +64,14 @@ export function buildCommandCenterCells( }; } + if (isBrowserCell(cellValue)) { + return { + ...EMPTY_CELL_DATA, + cellIndex, + browserUrl: getBrowserCellUrl(cellValue), + }; + } + const taskId = cellValue; const task = taskId ? taskById.get(taskId) : undefined; const session = taskId ? sessionByTaskId.get(taskId) : undefined; diff --git a/packages/core/src/command-center/grid.test.ts b/packages/core/src/command-center/grid.test.ts index e1af10334a..a886e5fb2c 100644 --- a/packages/core/src/command-center/grid.test.ts +++ b/packages/core/src/command-center/grid.test.ts @@ -2,13 +2,16 @@ import { describe, expect, it } from "vitest"; import { BRAINROT_CELL, clampZoom, + getBrowserCellUrl, getCellCount, getCellSessionId, getGridDimensions, getTerminalCellCwd, getTerminalCellId, isBrainrotCell, + isBrowserCell, isTerminalCell, + makeBrowserCellValue, makeTerminalCellValue, resizeCells, } from "./grid"; @@ -92,6 +95,35 @@ describe("terminal cells", () => { }); }); +describe("browser cells", () => { + it.each([ + "about:blank", + "https://posthog.com", + // A url containing the delimiter and prefix-like text must survive intact. + "https://example.com/x?to=__browser__:https://evil.com", + ])("round-trips %j through the cell value", (url) => { + const value = makeBrowserCellValue(url); + expect(isBrowserCell(value)).toBe(true); + expect(getBrowserCellUrl(value)).toBe(url); + }); + + it("round-trips an empty url (blank browser cell)", () => { + const value = makeBrowserCellValue(""); + expect(isBrowserCell(value)).toBe(true); + expect(getBrowserCellUrl(value)).toBe(""); + }); + + it.each([ + { value: "some-task-uuid", expected: false }, + { value: BRAINROT_CELL, expected: false }, + { value: makeTerminalCellValue("t1"), expected: false }, + { value: null, expected: false }, + ])("isBrowserCell($value) -> $expected", ({ value, expected }) => { + expect(isBrowserCell(value)).toBe(expected); + expect(getBrowserCellUrl(value)).toBeNull(); + }); +}); + describe("getCellSessionId", () => { it("formats the cell session id", () => { expect(getCellSessionId(2)).toBe("cc-cell-2"); diff --git a/packages/core/src/command-center/grid.ts b/packages/core/src/command-center/grid.ts index 7d2e5f8e16..6a4d8400a5 100644 --- a/packages/core/src/command-center/grid.ts +++ b/packages/core/src/command-center/grid.ts @@ -49,6 +49,23 @@ export function getTerminalCellCwd(value: string | null): string | null { return colon === -1 ? null : decodeURIComponent(rest.slice(colon + 1)); } +// Reserved prefix for standalone browser cells; the whole remainder is the +// url, so urls containing ":" or the prefix text are safe. Never collides with +// task ids (uuids), BRAINROT_CELL, or terminal cells. +export const BROWSER_CELL_PREFIX = "__browser__:"; + +export function isBrowserCell(value: string | null): value is string { + return value?.startsWith(BROWSER_CELL_PREFIX) ?? false; +} + +export function makeBrowserCellValue(url: string): string { + return `${BROWSER_CELL_PREFIX}${url}`; +} + +export function getBrowserCellUrl(value: string | null): string | null { + return isBrowserCell(value) ? value.slice(BROWSER_CELL_PREFIX.length) : null; +} + export function getGridDimensions(preset: LayoutPreset): GridDimensions { const [cols, rows] = preset.split("x").map(Number); return { cols, rows }; diff --git a/packages/core/src/panels/panelLayoutTransforms.test.ts b/packages/core/src/panels/panelLayoutTransforms.test.ts index a7a2c3a2cd..c42dbb9630 100644 --- a/packages/core/src/panels/panelLayoutTransforms.test.ts +++ b/packages/core/src/panels/panelLayoutTransforms.test.ts @@ -1,9 +1,13 @@ import { beforeEach, describe, expect, it } from "vitest"; import { + addBrowserTab, addRecentFile, closeTab, createInitialTaskLayout, openTab, + updateBrowserTabUrl, + updateTabLabel, + updateTabMetadata, } from "./panelLayoutTransforms"; import { createFileTabId, resetPanelIdCounter } from "./panelStoreHelpers"; import { findTabInTree } from "./panelTree"; @@ -75,6 +79,102 @@ describe("panelLayoutTransforms", () => { }); }); + describe("addBrowserTab", () => { + it("adds a browser tab carrying the initial url", () => { + const layout = createInitialTaskLayout(); + const next = applyUpdates( + layout, + addBrowserTab(layout, "main-panel", "https://posthog.com"), + ); + + expect(next.panelTree.type).toBe("leaf"); + if (next.panelTree.type !== "leaf") return; + const browserTab = next.panelTree.content.tabs.find( + (t) => t.data.type === "browser", + ); + expect(browserTab).toBeDefined(); + expect(browserTab?.data).toEqual({ + type: "browser", + url: "https://posthog.com", + }); + }); + }); + + describe("updateBrowserTabUrl", () => { + it("updates the url of an existing browser tab", () => { + const layout = createInitialTaskLayout(); + const added = applyUpdates( + layout, + addBrowserTab(layout, "main-panel", "about:blank"), + ); + expect(added.panelTree.type).toBe("leaf"); + if (added.panelTree.type !== "leaf") return; + const tabId = added.panelTree.content.tabs.find( + (t) => t.data.type === "browser", + )?.id; + if (!tabId) throw new Error("expected browser tab"); + + const next = applyUpdates( + added, + updateBrowserTabUrl(added, tabId, "https://example.com"), + ); + + const location = findTabInTree(next.panelTree, tabId); + expect(location?.tab.data).toEqual({ + type: "browser", + url: "https://example.com", + }); + }); + + it("leaves a non-browser tab untouched", () => { + const layout = createInitialTaskLayout(); + const next = applyUpdates( + layout, + updateBrowserTabUrl(layout, "shell", "https://example.com"), + ); + // The shell tab has no url; the guard must not graft one on. + expect(findTabInTree(next.panelTree, "shell")?.tab.data).toEqual( + findTabInTree(layout.panelTree, "shell")?.tab.data, + ); + }); + }); + + describe("updateTabLabel", () => { + it("renames an existing tab", () => { + const layout = createInitialTaskLayout(); + const next = applyUpdates( + layout, + updateTabLabel(layout, "shell", "Renamed"), + ); + expect(findTabInTree(next.panelTree, "shell")?.tab.label).toBe("Renamed"); + }); + + it("no-ops when the tab is gone", () => { + const layout = createInitialTaskLayout(); + expect(updateTabLabel(layout, "missing", "X")).toEqual({}); + }); + }); + + describe("updateTabMetadata", () => { + it("merges metadata into an existing tab", () => { + const layout = createInitialTaskLayout(); + const next = applyUpdates( + layout, + updateTabMetadata(layout, "shell", { hasUnsavedChanges: true }), + ); + expect( + findTabInTree(next.panelTree, "shell")?.tab.hasUnsavedChanges, + ).toBe(true); + }); + + it("no-ops when the tab is gone", () => { + const layout = createInitialTaskLayout(); + expect( + updateTabMetadata(layout, "missing", { hasUnsavedChanges: true }), + ).toEqual({}); + }); + }); + describe("addRecentFile", () => { it("dedupes and prepends, capping at the max", () => { const result = addRecentFile(["b", "a"], "a"); diff --git a/packages/core/src/panels/panelLayoutTransforms.ts b/packages/core/src/panels/panelLayoutTransforms.ts index af2cc91643..74dd227442 100644 --- a/packages/core/src/panels/panelLayoutTransforms.ts +++ b/packages/core/src/panels/panelLayoutTransforms.ts @@ -609,10 +609,13 @@ export function updateSizes( return { panelTree: updatedTree }; } -export function updateTabMetadata( +// Locates a tab by id and replaces it with `update(tab)`, leaving every other +// tab and the tree structure untouched. No-ops if the tab is gone. The three +// update-one-tab transforms below all share this walk, so it lives once here. +function updateTabById( layout: TaskLayout, tabId: string, - metadata: Partial>, + update: (tab: Tab) => Tab, ): Partial { const tabLocation = findTabInTree(layout.panelTree, tabId); if (!tabLocation) return {}; @@ -622,16 +625,13 @@ export function updateTabMetadata( tabLocation.panelId, (panel) => { if (panel.type !== "leaf") return panel; - - const updatedTabs = panel.content.tabs.map((tab) => - tab.id === tabId ? { ...tab, ...metadata } : tab, - ); - return { ...panel, content: { ...panel.content, - tabs: updatedTabs, + tabs: panel.content.tabs.map((tab) => + tab.id === tabId ? update(tab) : tab, + ), }, }; }, @@ -640,35 +640,20 @@ export function updateTabMetadata( return { panelTree: updatedTree }; } +export function updateTabMetadata( + layout: TaskLayout, + tabId: string, + metadata: Partial>, +): Partial { + return updateTabById(layout, tabId, (tab) => ({ ...tab, ...metadata })); +} + export function updateTabLabel( layout: TaskLayout, tabId: string, label: string, ): Partial { - const tabLocation = findTabInTree(layout.panelTree, tabId); - if (!tabLocation) return {}; - - const updatedTree = updateTreeNode( - layout.panelTree, - tabLocation.panelId, - (panel) => { - if (panel.type !== "leaf") return panel; - - const updatedTabs = panel.content.tabs.map((tab) => - tab.id === tabId ? { ...tab, label } : tab, - ); - - return { - ...panel, - content: { - ...panel.content, - tabs: updatedTabs, - }, - }; - }, - ); - - return { panelTree: updatedTree }; + return updateTabById(layout, tabId, (tab) => ({ ...tab, label })); } export function setActiveTab( @@ -687,17 +672,34 @@ export function setActiveTab( return { panelTree: updatedTree }; } +// Tab ids key the whole tree; the monotonic suffix stops two adds within the +// same millisecond from colliding. +let tabIdSeq = 0; +function uniqueTabId(prefix: string): string { + return `${prefix}-${Date.now()}-${tabIdSeq++}`; +} + export function addTerminalTab( layout: TaskLayout, panelId: string, ): Partial { - const tabId = `shell-${Date.now()}`; + const tabId = uniqueTabId("shell"); + return appendTab(layout, panelId, { + id: tabId, + label: "Terminal", + data: { type: "terminal", terminalId: tabId, cwd: "" }, + }); +} + +function appendTab( + layout: TaskLayout, + panelId: string, + tab: { id: string; label: string; data: TabData }, +): Partial { const updatedTree = updateTreeNode(layout.panelTree, panelId, (panel) => { if (panel.type !== "leaf") return panel; return addTabToPanel(panel, { - id: tabId, - label: "Terminal", - data: { type: "terminal", terminalId: tabId, cwd: "" }, + ...tab, component: null, draggable: true, closeable: true, @@ -707,6 +709,28 @@ export function addTerminalTab( return { panelTree: updatedTree }; } +export function addBrowserTab( + layout: TaskLayout, + panelId: string, + url: string, +): Partial { + return appendTab(layout, panelId, { + id: uniqueTabId("browser"), + label: "Browser", + data: { type: "browser", url }, + }); +} + +export function updateBrowserTabUrl( + layout: TaskLayout, + tabId: string, + url: string, +): Partial { + return updateTabById(layout, tabId, (tab) => + tab.data.type === "browser" ? { ...tab, data: { ...tab.data, url } } : tab, + ); +} + export function addActionTab( layout: TaskLayout, panelId: string, diff --git a/packages/core/src/panels/panelTypes.ts b/packages/core/src/panels/panelTypes.ts index 082ed291fb..26292d6274 100644 --- a/packages/core/src/panels/panelTypes.ts +++ b/packages/core/src/panels/panelTypes.ts @@ -44,6 +44,11 @@ export type TabData = | { type: "autoresearch"; } + | { + // `url` is the last committed location, so the tab restores on reload. + type: "browser"; + url: string; + } | { type: "other"; }; diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 90161b8a76..dd832451cc 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1,5 +1,6 @@ export { BILLING_FLAG, + BROWSER_TAB_FLAG, DISCOVERY_RUN_FLAG, EXPERIMENT_SUGGESTIONS_FLAG, HOME_TAB_FLAG, diff --git a/packages/shared/src/flags.ts b/packages/shared/src/flags.ts index d692da1fda..8a20a59919 100644 --- a/packages/shared/src/flags.ts +++ b/packages/shared/src/flags.ts @@ -17,6 +17,8 @@ export const DISCOVERY_RUN_FLAG = "posthog-code-discovery-run"; export const PROJECT_BLUEBIRD_FLAG = "project-bluebird"; export const TASKS_PREWARM_SANDBOX_FLAG = "tasks-prewarm-sandbox"; export const GLM_MODEL_FLAG = "posthog-code-glm-model"; +// Gates the in-app browser tab (the Globe "+" affordance in panel tab bars). +export const BROWSER_TAB_FLAG = "posthog-code-browser-tab"; /** Spoken narration (agent speaks via the `speak` tool). Gated for a staged rollout. */ export const SPOKEN_NARRATION_FLAG = "posthog-code-spoken-narration"; // Gates importing and relaying local MCP servers into cloud task runs. diff --git a/packages/ui/src/features/browser/BrowserPanel.interaction.test.tsx b/packages/ui/src/features/browser/BrowserPanel.interaction.test.tsx new file mode 100644 index 0000000000..9e3f4542da --- /dev/null +++ b/packages/ui/src/features/browser/BrowserPanel.interaction.test.tsx @@ -0,0 +1,113 @@ +import { ServiceProvider } from "@posthog/di/react"; +import { Theme } from "@radix-ui/themes"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Container } from "inversify"; +import { useEffect } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { BrowserPanel } from "./BrowserPanel"; +import { + BROWSER_VIEW_COMPONENT, + type BrowserViewHandle, + type BrowserViewProps, +} from "./identifiers"; + +const loadURL = vi.fn<(url: string) => Promise>(); + +const browserViewHandle: BrowserViewHandle = { + loadURL, + reload: vi.fn(), + stop: vi.fn(), + goBack: vi.fn(), + goForward: vi.fn(), +}; + +function FakeBrowserView({ onReady }: BrowserViewProps) { + useEffect(() => { + onReady(browserViewHandle); + return () => onReady(null); + }, [onReady]); + return
; +} + +let reportDeferredReady: (() => void) | undefined; + +function DeferredBrowserView({ onReady }: BrowserViewProps) { + reportDeferredReady = () => onReady(browserViewHandle); + return
; +} + +describe("BrowserPanel", () => { + beforeEach(() => { + loadURL.mockReset(); + reportDeferredReady = undefined; + }); + + it("navigates when Enter is pressed in the address input", async () => { + loadURL.mockResolvedValue(); + const container = new Container(); + container.bind(BROWSER_VIEW_COMPONENT).toConstantValue(FakeBrowserView); + const user = userEvent.setup(); + + render( + + + + + , + ); + + await user.type( + screen.getByRole("textbox", { name: "Address" }), + "posthog.com{Enter}", + ); + + expect(loadURL).toHaveBeenCalledWith("https://posthog.com"); + }); + + it("surfaces synchronous navigation failures", async () => { + loadURL.mockImplementation(() => { + throw new Error("webview unavailable"); + }); + const container = new Container(); + container.bind(BROWSER_VIEW_COMPONENT).toConstantValue(FakeBrowserView); + const user = userEvent.setup(); + + render( + + + + + , + ); + + await user.type( + screen.getByRole("textbox", { name: "Address" }), + "posthog.com{Enter}", + ); + + expect(screen.getByText("Failed to load page")).toBeInTheDocument(); + }); + + it("queues Enter navigation until the browser view is ready", async () => { + loadURL.mockResolvedValue(); + const container = new Container(); + container.bind(BROWSER_VIEW_COMPONENT).toConstantValue(DeferredBrowserView); + + render( + + + + + , + ); + + const address = screen.getByRole("textbox", { name: "Address" }); + fireEvent.change(address, { target: { value: "posthog.com" } }); + fireEvent.submit(address.closest("form") as HTMLFormElement); + + expect(loadURL).not.toHaveBeenCalled(); + act(() => reportDeferredReady?.()); + expect(loadURL).toHaveBeenCalledWith("https://posthog.com"); + }); +}); diff --git a/packages/ui/src/features/browser/BrowserPanel.test.ts b/packages/ui/src/features/browser/BrowserPanel.test.ts new file mode 100644 index 0000000000..45dd45b686 --- /dev/null +++ b/packages/ui/src/features/browser/BrowserPanel.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { normalizeAddress } from "./BrowserPanel"; + +describe("normalizeAddress", () => { + it.each([ + ["", "about:blank"], + [" ", "about:blank"], + ["about:blank", "about:blank"], + ["https://posthog.com", "https://posthog.com"], + ["http://example.com/path", "https://example.com/path"], + ["http://localhost:3000/path", "http://localhost:3000/path"], + ["http://127.0.0.2:8000", "http://127.0.0.2:8000"], + ["http://0.0.0.0:3000", "http://0.0.0.0:3000"], + ["http://[::1]:3000", "http://[::1]:3000"], + ["example.com", "https://example.com"], + ["example.com/path?q=1", "https://example.com/path?q=1"], + ["localhost:3000", "http://localhost:3000"], + ["localhost", "http://localhost"], + ["localhost/dashboard", "http://localhost/dashboard"], + ["127.0.0.1:8000", "http://127.0.0.1:8000"], + [ + "how to center a div", + "https://www.google.com/search?q=how%20to%20center%20a%20div", + ], + ["posthog", "https://www.google.com/search?q=posthog"], + ])("normalizes %j to %j", (input, expected) => { + expect(normalizeAddress(input)).toBe(expected); + }); + + it.each([ + ["file:///etc/passwd", "file%3A%2F%2F%2Fetc%2Fpasswd"], + ["chrome://settings", "chrome%3A%2F%2Fsettings"], + [ + "data:text/html,

hi

", + "data%3Atext%2Fhtml%2C%3Ch1%3Ehi%3C%2Fh1%3E", + ], + ["javascript:alert(1)", "javascript%3Aalert(1)"], + ])("routes disallowed scheme %j to search", (input, encoded) => { + expect(normalizeAddress(input)).toBe( + `https://www.google.com/search?q=${encoded}`, + ); + }); +}); diff --git a/packages/ui/src/features/browser/BrowserPanel.tsx b/packages/ui/src/features/browser/BrowserPanel.tsx new file mode 100644 index 0000000000..1bb483e0bc --- /dev/null +++ b/packages/ui/src/features/browser/BrowserPanel.tsx @@ -0,0 +1,288 @@ +import { + ArrowClockwise, + ArrowLeft, + ArrowRight, + Globe, + X, +} from "@phosphor-icons/react"; +import { useServiceOptional } from "@posthog/di/react"; +import { Button } from "@posthog/quill"; +import { BROWSER_TAB_FLAG } from "@posthog/shared/constants"; +import { + BROWSER_VIEW_COMPONENT, + type BrowserViewComponent, + type BrowserViewHandle, + type BrowserViewNavigation, +} from "@posthog/ui/features/browser/identifiers"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { Box, Flex, Text } from "@radix-ui/themes"; +import type React from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; + +export function useBrowserEnabled(): boolean { + const BrowserView = useServiceOptional( + BROWSER_VIEW_COMPONENT, + ); + const featureEnabled = useFeatureFlag(BROWSER_TAB_FLAG, import.meta.env.DEV); + return BrowserView !== undefined && featureEnabled; +} + +const DEFAULT_URL = "about:blank"; + +const LOOPBACK_HOST = + /^(localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[::1\])(?=[:/]|$)/i; + +// Anything else (file:, chrome:, data:, javascript:, ...) becomes a search. +// Keep in sync with the authoritative main-process guard (setupWebviewHandlers +// in window.ts) — this is convenience, that is the security boundary. +const HTTPS_SCHEME = /^https:\/\//i; +const HTTP_SCHEME = /^http:\/\//i; + +// Loopback defaults to http since dev servers rarely serve https. +export function normalizeAddress(input: string): string { + const trimmed = input.trim(); + if (!trimmed) return DEFAULT_URL; + if (HTTPS_SCHEME.test(trimmed) || trimmed === "about:blank") { + return trimmed; + } + if (HTTP_SCHEME.test(trimmed)) { + const withoutScheme = trimmed.replace(HTTP_SCHEME, ""); + if (LOOPBACK_HOST.test(withoutScheme)) return trimmed; + return `https://${withoutScheme}`; + } + // A schemeless "host:port" (e.g. localhost:3000) is a host, not a scheme. + const hasDisallowedScheme = + /^[a-z][a-z0-9+.-]*:/i.test(trimmed) && + !/^[^/]+:\d+(?:[/?#]|$)/.test(trimmed); + const looksLikeHost = + !hasDisallowedScheme && + !/\s/.test(trimmed) && + (trimmed.includes(".") || LOOPBACK_HOST.test(trimmed)); + if (looksLikeHost) { + const scheme = LOOPBACK_HOST.test(trimmed) ? "http" : "https"; + return `${scheme}://${trimmed}`; + } + return `https://www.google.com/search?q=${encodeURIComponent(trimmed)}`; +} + +interface BrowserPanelProps { + url: string; + // Debounced settled main-frame url, for hosts that persist location. + onUrlChange?: (url: string) => void; + // Deduped page title, for hosts that show a label. + onTitleChange?: (title: string) => void; +} + +export function BrowserPanel({ + url, + onUrlChange, + onTitleChange, +}: BrowserPanelProps) { + const BrowserView = useServiceOptional( + BROWSER_VIEW_COMPONENT, + ); + const browserViewRef = useRef(null); + const pendingNavigationRef = useRef(null); + // src is set once so re-renders never reload the page; the value comes off + // disk and must not be trusted as a raw src. + const initialUrl = useRef(normalizeAddress(url)); + const [address, setAddress] = useState(url === DEFAULT_URL ? "" : url); + const [canGoBack, setCanGoBack] = useState(false); + const [canGoForward, setCanGoForward] = useState(false); + const [loadError, setLoadError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + // Refs so the debounce timer and event effect don't re-arm every render. + const onUrlChangeRef = useRef(onUrlChange); + onUrlChangeRef.current = onUrlChange; + const onTitleChangeRef = useRef(onTitleChange); + onTitleChangeRef.current = onTitleChange; + + const persistTimer = useRef | null>(null); + const pendingUrl = useRef(null); + const lastLabel = useRef(null); + + // Hosts persist to disk on every write and SPAs fire many navigation events; + // coalesce so only the settled url hits storage. + const persistUrl = useCallback((next: string) => { + pendingUrl.current = next; + if (persistTimer.current) clearTimeout(persistTimer.current); + persistTimer.current = setTimeout(() => { + if (pendingUrl.current !== null) { + onUrlChangeRef.current?.(pendingUrl.current); + } + pendingUrl.current = null; + persistTimer.current = null; + }, 500); + }, []); + + // Flush on unmount so the last location isn't lost inside the debounce window. + useEffect( + () => () => { + if (persistTimer.current) clearTimeout(persistTimer.current); + if (pendingUrl.current !== null) { + onUrlChangeRef.current?.(pendingUrl.current); + } + }, + [], + ); + + const loadUrl = useCallback((handle: BrowserViewHandle, target: string) => { + setLoadError(null); + try { + void handle.loadURL(target).catch(() => { + setLoadError("Failed to load page"); + }); + } catch { + setLoadError("Failed to load page"); + } + }, []); + const handleReady = useCallback( + (handle: BrowserViewHandle | null) => { + browserViewRef.current = handle; + if (!handle || pendingNavigationRef.current === null) return; + const target = pendingNavigationRef.current; + pendingNavigationRef.current = null; + loadUrl(handle, target); + }, + [loadUrl], + ); + const handleNavigate = useCallback( + (navigation: BrowserViewNavigation) => { + setAddress(navigation.url === DEFAULT_URL ? "" : navigation.url); + setCanGoBack(navigation.canGoBack); + setCanGoForward(navigation.canGoForward); + setLoadError(null); + persistUrl(navigation.url); + }, + [persistUrl], + ); + const handleTitleChange = useCallback((title: string) => { + if (title && title !== lastLabel.current) { + lastLabel.current = title; + onTitleChangeRef.current?.(title); + } + }, []); + const handleLoadError = useCallback((message: string) => { + setLoadError(message); + }, []); + const handleLoadingChange = useCallback((loading: boolean) => { + setIsLoading(loading); + }, []); + const handleReload = useCallback(() => { + setLoadError(null); + browserViewRef.current?.reload(); + }, []); + const handleReloadOrStop = useCallback(() => { + if (isLoading) { + browserViewRef.current?.stop(); + return; + } + handleReload(); + }, [handleReload, isLoading]); + + const navigate = useCallback( + (raw: string) => { + const target = normalizeAddress(raw); + const browserView = browserViewRef.current; + if (!browserView) { + pendingNavigationRef.current = target; + return; + } + loadUrl(browserView, target); + }, + [loadUrl], + ); + + const onSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + navigate(address); + }, + [address, navigate], + ); + if (!BrowserView) return null; + + return ( + + + + + +
+ setAddress(e.target.value)} + placeholder="Search or enter address" + spellCheck={false} + className="h-[24px] w-full rounded-(--radius-2) border-0 bg-(--gray-3) px-2 text-(--gray-12) text-[12px] outline-none focus:bg-(--gray-4)" + /> +
+
+ + +
+ {loadError && ( + + + + {loadError} + + + )} + + + + ); +} diff --git a/packages/ui/src/features/browser/BrowserUnavailable.tsx b/packages/ui/src/features/browser/BrowserUnavailable.tsx new file mode 100644 index 0000000000..3de621382a --- /dev/null +++ b/packages/ui/src/features/browser/BrowserUnavailable.tsx @@ -0,0 +1,24 @@ +import { Globe } from "@phosphor-icons/react"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@posthog/quill"; + +export function BrowserUnavailable() { + return ( + + + + + + Browser unavailable + + This host does not support embedded browser tabs. + + + + ); +} diff --git a/packages/ui/src/features/browser/identifiers.ts b/packages/ui/src/features/browser/identifiers.ts new file mode 100644 index 0000000000..b8adce2cce --- /dev/null +++ b/packages/ui/src/features/browser/identifiers.ts @@ -0,0 +1,30 @@ +import type { ComponentType } from "react"; + +export interface BrowserViewHandle { + loadURL(url: string): Promise; + reload(): void; + stop(): void; + goBack(): void; + goForward(): void; +} + +export interface BrowserViewNavigation { + url: string; + canGoBack: boolean; + canGoForward: boolean; +} + +export interface BrowserViewProps { + initialUrl: string; + onReady: (handle: BrowserViewHandle | null) => void; + onNavigate: (navigation: BrowserViewNavigation) => void; + onTitleChange: (title: string) => void; + onLoadError: (message: string) => void; + onLoadingChange: (isLoading: boolean) => void; +} + +export type BrowserViewComponent = ComponentType; + +export const BROWSER_VIEW_COMPONENT = Symbol.for( + "posthog.ui.BrowserViewComponent", +); diff --git a/packages/ui/src/features/command-center/commandCenterStore.ts b/packages/ui/src/features/command-center/commandCenterStore.ts index 14f32f66ab..e490d76740 100644 --- a/packages/ui/src/features/command-center/commandCenterStore.ts +++ b/packages/ui/src/features/command-center/commandCenterStore.ts @@ -2,7 +2,9 @@ import { BRAINROT_CELL, clampZoom, getCellCount, + isBrowserCell, type LayoutPreset, + makeBrowserCellValue, makeTerminalCellValue, resizeCells, ZOOM_STEP, @@ -39,6 +41,8 @@ interface CommandCenterStoreActions { terminalId: string, cwd?: string, ) => void; + setBrowserCell: (cellIndex: number, url: string) => void; + updateBrowserCellUrl: (cellIndex: number, url: string) => void; autofillCells: (taskIds: string[]) => void; clearCell: (cellIndex: number) => void; removeTaskById: (taskId: string) => void; @@ -136,6 +140,28 @@ export const useCommandCenterStore = create()( }; }), + setBrowserCell: (cellIndex, url) => + set((state) => { + if (cellIndex < 0 || cellIndex >= state.cells.length) return state; + const cells = [...state.cells]; + cells[cellIndex] = makeBrowserCellValue(url); + return { + cells, + activeTaskId: null, + activeCellIndex: cellIndex, + creatingCells: state.creatingCells.filter((i) => i !== cellIndex), + hasAutofilled: true, + }; + }), + + updateBrowserCellUrl: (cellIndex, url) => + set((state) => { + if (!isBrowserCell(state.cells[cellIndex] ?? null)) return state; + const cells = [...state.cells]; + cells[cellIndex] = makeBrowserCellValue(url); + return { cells }; + }), + autofillCells: (taskIds) => set((state) => { // Grid already full: nothing to place, but the bootstrap is done. diff --git a/packages/ui/src/features/command-center/components/CommandCenterPanel.tsx b/packages/ui/src/features/command-center/components/CommandCenterPanel.tsx index db0efcd93a..b87a0b1686 100644 --- a/packages/ui/src/features/command-center/components/CommandCenterPanel.tsx +++ b/packages/ui/src/features/command-center/components/CommandCenterPanel.tsx @@ -4,6 +4,7 @@ import { Desktop, Folder, GitFork, + Globe, Lightning, Plus, Terminal, @@ -12,6 +13,10 @@ import { import { isBrainrotCell } from "@posthog/core/command-center/grid"; import { ANALYTICS_EVENTS, type WorkspaceMode } from "@posthog/shared"; import type { Task } from "@posthog/shared/domain-types"; +import { + BrowserPanel, + useBrowserEnabled, +} from "@posthog/ui/features/browser/BrowserPanel"; import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; import { destroyShellTerminal } from "@posthog/ui/features/terminal/destroyShellTerminal"; import { ShellTerminal } from "@posthog/ui/features/terminal/ShellTerminal"; @@ -19,7 +24,13 @@ import { openTask } from "@posthog/ui/router/useOpenTask"; import { track } from "@posthog/ui/shell/analytics"; import { secureRandomString } from "@posthog/ui/utils/random"; import { Flex, Spinner, Text } from "@radix-ui/themes"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from "react"; import { useFolders } from "../../folders/useFolders"; import { useCloudPrUrl } from "../../git-interaction/useCloudPrUrl"; import { useDraftStore } from "../../message-editor/draftStore"; @@ -116,11 +127,13 @@ function EmptyCell({ cellIndex }: { cellIndex: number }) { const assignTask = useCommandCenterStore((s) => s.assignTask); const setBrainrotCell = useCommandCenterStore((s) => s.setBrainrotCell); const setTerminalCell = useCommandCenterStore((s) => s.setTerminalCell); + const setBrowserCell = useCommandCenterStore((s) => s.setBrowserCell); const startCreating = useCommandCenterStore((s) => s.startCreating); const stopCreating = useCommandCenterStore((s) => s.stopCreating); const layout = useCommandCenterStore((s) => s.layout); const cells = useCommandCenterStore((s) => s.cells); const brainrotMode = useSettingsStore((s) => s.brainrotMode); + const browserEnabled = useBrowserEnabled(); const clearDraft = useDraftStore((s) => s.actions.setDraft); const sessionId = getCellSessionId(cellIndex); @@ -140,6 +153,10 @@ function EmptyCell({ cellIndex }: { cellIndex: number }) { [setTerminalCell, cellIndex], ); + const handleNewBrowser = useCallback(() => { + setBrowserCell(cellIndex, "about:blank"); + }, [setBrowserCell, cellIndex]); + const handleTaskCreated = useCallback( (task: Task) => { assignTask(cellIndex, task.id); @@ -199,6 +216,7 @@ function EmptyCell({ cellIndex }: { cellIndex: number }) { onOpenChange={setSelectorOpen} onNewTask={() => startCreating(cellIndex)} onNewTerminal={handleNewTerminal} + onNewBrowser={browserEnabled ? handleNewBrowser : undefined} onBrainrot={brainrotMode ? handleBrainrot : undefined} > + + + {children} + + + ); +} + function TerminalCell({ cellIndex, terminalId, @@ -300,37 +366,59 @@ function TerminalCell({ }, [stateKey, clearCell, cellIndex]); return ( - - - - - Terminal - - {folderName && ( + } + title="Terminal" + headerExtra={ + folderName ? ( {folderName} - )} - - - - - - + ) : undefined + } + onRemove={handleRemove} + > + + + ); +} + +function hostnameOf(url: string): string | null { + try { + return new URL(url).hostname || null; + } catch { + return null; + } +} + +function BrowserCell({ cellIndex, url }: { cellIndex: number; url: string }) { + const clearCell = useCommandCenterStore((s) => s.clearCell); + const updateBrowserCellUrl = useCommandCenterStore( + (s) => s.updateBrowserCellUrl, + ); + // Page title once loaded; before that (e.g. a just-restored cell) the + // persisted url's hostname beats a bare "Browser". + const [title, setTitle] = useState(null); + const label = title ?? hostnameOf(url) ?? "Browser"; + + const onUrlChange = useCallback( + (next: string) => updateBrowserCellUrl(cellIndex, next), + [updateBrowserCellUrl, cellIndex], + ); + + return ( + } + title={label} + onRemove={() => clearCell(cellIndex)} + > + + ); } @@ -426,6 +514,11 @@ export function CommandCenterPanel({ ); } + // Empty-string url is a valid (blank) browser cell, so check against null. + if (cell.browserUrl !== null) { + return ; + } + if (!cell.taskId || !cell.task) { return ; } diff --git a/packages/ui/src/features/command-center/components/TaskSelector.tsx b/packages/ui/src/features/command-center/components/TaskSelector.tsx index a3764fd826..02f6ac23b0 100644 --- a/packages/ui/src/features/command-center/components/TaskSelector.tsx +++ b/packages/ui/src/features/command-center/components/TaskSelector.tsx @@ -1,6 +1,7 @@ import { ArrowLeft, Folder, + Globe, Lightning, Plus, Terminal, @@ -19,6 +20,7 @@ interface TaskSelectorProps { onOpenChange: (open: boolean) => void; onNewTask?: () => void; onNewTerminal?: (cwd?: string) => void; + onNewBrowser?: () => void; onBrainrot?: () => void; children: ReactNode; } @@ -29,6 +31,7 @@ export function TaskSelector({ onOpenChange, onNewTask, onNewTerminal, + onNewBrowser, onBrainrot, children, }: TaskSelectorProps) { @@ -81,6 +84,11 @@ export function TaskSelector({ onBrainrot?.(); }, [handleOpenChange, onBrainrot]); + const handleNewBrowser = useCallback(() => { + handleOpenChange(false); + onNewBrowser?.(); + }, [handleOpenChange, onNewBrowser]); + return ( )} + {onNewBrowser && ( + + )} {onBrainrot && ( + ); + }, +); diff --git a/packages/ui/src/features/panels/components/TabbedPanel.tsx b/packages/ui/src/features/panels/components/TabbedPanel.tsx index f36f35085f..3fca73cb66 100644 --- a/packages/ui/src/features/panels/components/TabbedPanel.tsx +++ b/packages/ui/src/features/panels/components/TabbedPanel.tsx @@ -1,14 +1,17 @@ import { useDroppable } from "@dnd-kit/react"; -import { Plus, SquareSplitHorizontalIcon } from "@phosphor-icons/react"; +import { SquareSplitHorizontalIcon } from "@phosphor-icons/react"; import { useHostTRPCClient } from "@posthog/host-router/react"; import { PanelDropZones } from "@posthog/ui/features/panels/components/PanelDropZones"; import type { SplitDirection } from "@posthog/ui/features/panels/panelLayoutStore"; import type { PanelContent } from "@posthog/ui/features/panels/panelTypes"; +import type { AddableTabKind } from "@posthog/ui/features/panels/tabAvailability"; import { Tooltip } from "@posthog/ui/primitives/Tooltip"; import { Box, Flex } from "@radix-ui/themes"; import type React from "react"; -import { forwardRef, useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef } from "react"; +import { AddTabControl } from "./AddTabControl"; import { PanelTab } from "./PanelTab"; +import { TabBarButton } from "./TabBarButton"; const activeTabStyle: React.CSSProperties = { height: "100%", @@ -24,36 +27,6 @@ const hiddenTabStyle: React.CSSProperties = { pointerEvents: "none", }; -interface TabBarButtonProps { - ariaLabel: string; - onClick: () => void; - children: React.ReactNode; -} - -const TabBarButton = forwardRef( - function TabBarButton({ ariaLabel, onClick, children, ...props }, ref) { - const [isHovered, setIsHovered] = useState(false); - - return ( - - ); - }, -); - interface TabbedPanelProps { panelId: string; content: PanelContent; @@ -64,7 +37,8 @@ interface TabbedPanelProps { onPanelFocus?: (panelId: string) => void; draggingTabId?: string | null; draggingTabPanelId?: string | null; - onAddTerminal?: () => void; + onAddTab?: (kind: AddableTabKind) => void; + addableTabKinds?: readonly AddableTabKind[]; onSplitPanel?: (direction: SplitDirection) => void; rightContent?: React.ReactNode; emptyState?: React.ReactNode; @@ -80,7 +54,8 @@ export const TabbedPanel: React.FC = ({ onPanelFocus, draggingTabId = null, draggingTabPanelId = null, - onAddTerminal, + onAddTab, + addableTabKinds = [], onSplitPanel, rightContent, emptyState, @@ -195,12 +170,11 @@ export const TabbedPanel: React.FC = ({ badge={tab.badge} /> ))} - {content.droppable && onAddTerminal && ( - - - - - + {content.droppable && onAddTab && ( + )} {/* Spacer to increase DND area */} {content.droppable && ( diff --git a/packages/ui/src/features/panels/hooks/usePanelLayoutHooks.tsx b/packages/ui/src/features/panels/hooks/usePanelLayoutHooks.tsx index e08c176990..154ba8fcb8 100644 --- a/packages/ui/src/features/panels/hooks/usePanelLayoutHooks.tsx +++ b/packages/ui/src/features/panels/hooks/usePanelLayoutHooks.tsx @@ -2,6 +2,7 @@ import { ChartLineUp, ChatCenteredText, FileText, + Globe, Scroll, Terminal, } from "@phosphor-icons/react"; @@ -27,6 +28,7 @@ export interface PanelLayoutState { keepTab: (taskId: string, panelId: string, tabId: string) => void; setFocusedPanel: (taskId: string, panelId: string) => void; addTerminalTab: (taskId: string, panelId: string) => void; + addBrowserTab: (taskId: string, panelId: string, url: string) => void; splitPanel: ( taskId: string, tabId: string, @@ -51,6 +53,7 @@ export function usePanelLayoutState(taskId: string): PanelLayoutState { keepTab: state.keepTab, setFocusedPanel: state.setFocusedPanel, addTerminalTab: state.addTerminalTab, + addBrowserTab: state.addBrowserTab, splitPanel: state.splitPanel, draggingTabId: state.getLayout(taskId)?.draggingTabId ?? null, draggingTabPanelId: state.getLayout(taskId)?.draggingTabPanelId ?? null, @@ -119,6 +122,8 @@ export function useTabInjection( icon = ; } else if (tab.data.type === "autoresearch") { icon = ; + } else if (tab.data.type === "browser") { + icon = ; } } diff --git a/packages/ui/src/features/panels/panelLayoutStore.ts b/packages/ui/src/features/panels/panelLayoutStore.ts index 5607c32a5a..aca4ed5ba9 100644 --- a/packages/ui/src/features/panels/panelLayoutStore.ts +++ b/packages/ui/src/features/panels/panelLayoutStore.ts @@ -1,6 +1,7 @@ import { addRecentFile, addActionTab as coreAddActionTab, + addBrowserTab as coreAddBrowserTab, addTerminalTab as coreAddTerminalTab, closeOtherTabs as coreCloseOtherTabs, closeTab as coreCloseTab, @@ -12,6 +13,7 @@ import { openTabInSplit as coreOpenTabInSplit, reorderTabs as coreReorderTabs, setActiveTab as coreSetActiveTab, + updateBrowserTabUrl as coreUpdateBrowserTabUrl, updateSizes as coreUpdateSizes, updateTabLabel as coreUpdateTabLabel, updateTabMetadata as coreUpdateTabMetadata, @@ -105,6 +107,8 @@ export interface PanelLayoutStore { updateTabLabel: (taskId: string, tabId: string, label: string) => void; setFocusedPanel: (taskId: string, panelId: string) => void; addTerminalTab: (taskId: string, panelId: string) => void; + addBrowserTab: (taskId: string, panelId: string, url: string) => void; + updateBrowserTabUrl: (taskId: string, tabId: string, url: string) => void; addActionTab: ( taskId: string, panelId: string, @@ -484,6 +488,32 @@ export const usePanelLayoutStore = createWithEqualityFn()( ); }, + addBrowserTab: (taskId, panelId, url) => { + set((state) => + updateTaskLayout( + state, + taskId, + (layout) => + coreAddBrowserTab(layout, panelId, url) as Partial, + ), + ); + }, + + updateBrowserTabUrl: (taskId, tabId, url) => { + set((state) => + updateTaskLayout( + state, + taskId, + (layout) => + coreUpdateBrowserTabUrl( + layout, + tabId, + url, + ) as Partial, + ), + ); + }, + addActionTab: (taskId, panelId, action) => { set((state) => updateTaskLayout( diff --git a/packages/ui/src/features/panels/tabAvailability.test.ts b/packages/ui/src/features/panels/tabAvailability.test.ts new file mode 100644 index 0000000000..e825da6fb4 --- /dev/null +++ b/packages/ui/src/features/panels/tabAvailability.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { + getAddableTabKinds, + isPanelTabAvailable, + isPersistedPanelTabVisible, + type TabAvailability, +} from "./tabAvailability"; + +describe("tab availability", () => { + it.each<[string, TabAvailability, string[], boolean, boolean]>([ + [ + "desktop local", + { browserEnabled: true, terminalEnabled: true }, + ["terminal", "browser"], + true, + true, + ], + [ + "desktop cloud", + { browserEnabled: true, terminalEnabled: false }, + ["browser"], + false, + true, + ], + [ + "web", + { browserEnabled: false, terminalEnabled: false }, + [], + false, + false, + ], + [ + "browser flag disabled", + { browserEnabled: false, terminalEnabled: true }, + ["terminal"], + true, + false, + ], + ])( + "%s exposes only supported tab kinds", + (_name, availability, addableKinds, terminalVisible, browserVisible) => { + expect(getAddableTabKinds(availability)).toEqual(addableKinds); + expect(isPanelTabAvailable("terminal", availability)).toBe( + terminalVisible, + ); + expect(isPanelTabAvailable("browser", availability)).toBe(browserVisible); + expect(isPanelTabAvailable("logs", availability)).toBe(true); + }, + ); +}); + +describe("persisted tab visibility", () => { + it("keeps unsupported browser tabs visible", () => { + expect( + isPersistedPanelTabVisible("browser", { + browserEnabled: false, + terminalEnabled: true, + }), + ).toBe(true); + }); +}); diff --git a/packages/ui/src/features/panels/tabAvailability.ts b/packages/ui/src/features/panels/tabAvailability.ts new file mode 100644 index 0000000000..af9772258f --- /dev/null +++ b/packages/ui/src/features/panels/tabAvailability.ts @@ -0,0 +1,35 @@ +import type { Tab } from "./panelTypes"; + +export type AddableTabKind = "terminal" | "browser"; + +export interface TabAvailability { + browserEnabled: boolean; + terminalEnabled: boolean; +} + +export function getAddableTabKinds({ + browserEnabled, + terminalEnabled, +}: TabAvailability): AddableTabKind[] { + const kinds: AddableTabKind[] = []; + if (terminalEnabled) kinds.push("terminal"); + if (browserEnabled) kinds.push("browser"); + return kinds; +} + +export function isPanelTabAvailable( + type: Tab["data"]["type"], + availability: TabAvailability, +): boolean { + if (type === "terminal") return availability.terminalEnabled; + if (type === "browser") return availability.browserEnabled; + return true; +} + +export function isPersistedPanelTabVisible( + type: Tab["data"]["type"], + availability: TabAvailability, +): boolean { + if (type === "browser") return true; + return isPanelTabAvailable(type, availability); +} diff --git a/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx b/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx index c59affe899..274097ef85 100644 --- a/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx +++ b/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx @@ -1,5 +1,7 @@ import type { Task } from "@posthog/shared/domain-types"; import { AutoresearchPanel } from "../../autoresearch/AutoresearchPanel"; +import { useBrowserEnabled } from "../../browser/BrowserPanel"; +import { BrowserUnavailable } from "../../browser/BrowserUnavailable"; import { CodeEditorPanel } from "../../code-editor/components/CodeEditorPanel"; import { LazyCloudReviewPage as CloudReviewPage, @@ -12,6 +14,7 @@ import { CanvasInstructionsTab } from "./CanvasInstructionsTab"; import { ChangesPanel } from "./ChangesPanel"; import { ChannelContextTab } from "./ChannelContextTab"; import { FileTreePanel } from "./FileTreePanel"; +import { TaskBrowserTab } from "./TaskBrowserTab"; import { TaskLogsPanel } from "./TaskLogsPanel"; import { TaskShellPanel } from "./TaskShellPanel"; @@ -27,6 +30,7 @@ export function TabContentRenderer({ task, }: TabContentRendererProps) { const isCloud = useIsWorkspaceCloudRun(taskId); + const browserEnabled = useBrowserEnabled(); const { data } = tab; switch (data.type) { @@ -76,6 +80,10 @@ export function TabContentRenderer({ case "autoresearch": return ; + case "browser": + if (!browserEnabled) return ; + return ; + case "other": switch (tab.id) { case "files": diff --git a/packages/ui/src/features/task-detail/components/TaskBrowserTab.tsx b/packages/ui/src/features/task-detail/components/TaskBrowserTab.tsx new file mode 100644 index 0000000000..5606464634 --- /dev/null +++ b/packages/ui/src/features/task-detail/components/TaskBrowserTab.tsx @@ -0,0 +1,31 @@ +import { BrowserPanel } from "@posthog/ui/features/browser/BrowserPanel"; +import { useCallback } from "react"; +import { usePanelLayoutStore } from "../../panels/panelLayoutStore"; + +interface TaskBrowserTabProps { + url: string; + tabId: string; + taskId: string; +} + +export function TaskBrowserTab({ url, tabId, taskId }: TaskBrowserTabProps) { + const updateBrowserTabUrl = usePanelLayoutStore((s) => s.updateBrowserTabUrl); + const updateTabLabel = usePanelLayoutStore((s) => s.updateTabLabel); + + const onUrlChange = useCallback( + (next: string) => updateBrowserTabUrl(taskId, tabId, next), + [updateBrowserTabUrl, taskId, tabId], + ); + const onTitleChange = useCallback( + (title: string) => updateTabLabel(taskId, tabId, title), + [updateTabLabel, taskId, tabId], + ); + + return ( + + ); +}