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..4b1a3f69a1
--- /dev/null
+++ b/apps/code/src/main/utils/webview-navigation-guard.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest";
+import {
+ isAllowedWebviewNavigation,
+ 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("isAllowedWebviewNavigation", () => {
+ it.each([
+ ["https://posthog.com", true],
+ ["http://localhost:3000", true],
+ ["about:blank", true],
+ // 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..3fc6f9c046
--- /dev/null
+++ b/apps/code/src/main/utils/webview-navigation-guard.ts
@@ -0,0 +1,55 @@
+// 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:" is allowed
+// solely for about:blank — the src a new blank browser tab mounts with before
+// the user enters a url.
+const ALLOWED_WEBVIEW_SCHEMES = new Set(["http:", "https:", "about:"]);
+
+// 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 "";
+ }
+}
+
+export function isAllowedWebviewNavigation(url: string): boolean {
+ let parsed: URL;
+ try {
+ parsed = new URL(url);
+ } catch {
+ return false;
+ }
+ return (
+ ALLOWED_WEBVIEW_SCHEMES.has(parsed.protocol) &&
+ !isBlockedWebviewHost(parsed.hostname)
+ );
+}
diff --git a/apps/code/src/main/window.ts b/apps/code/src/main/window.ts
index fef9bcaa16..dd857cefeb 100644
--- a/apps/code/src/main/window.ts
+++ b/apps/code/src/main/window.ts
@@ -30,6 +30,10 @@ import {
type WindowStateSchema,
windowStateStore,
} from "./utils/store";
+import {
+ isAllowedWebviewNavigation,
+ safeProtocol,
+} from "./utils/webview-navigation-guard";
const log = logger.scope("window");
const trpcLog = logger.scope("host-trpc");
@@ -120,6 +124,88 @@ function setupExternalLinkHandlers(window: BrowserWindow): void {
});
}
+// The guest runs on a shared persisted profile, so a single grant would stick
+// across every tab and task — deny powerful permissions outright.
+const DENIED_WEBVIEW_PERMISSIONS = new Set([
+ "media", // camera + microphone
+ "geolocation",
+ "notifications",
+ "midi",
+ "midiSysex",
+ "hid",
+ "serial",
+ "usb",
+ "pointerLock",
+ "idle-detection",
+ "openExternal", // popups are already routed through our own handler
+]);
+
+// Every browser webview shares the one persist:browser session, and Electron's
+// setPermissionRequestHandler REPLACES the session's previous handler rather
+// than stacking. Re-installing on every webview attach would mean the latest
+// attach silently wins; this WeakSet makes installation once-per-session so
+// that can never happen.
+const hardenedWebviewSessions = new WeakSet();
+
+function hardenWebviewSession(session: Electron.Session): void {
+ if (hardenedWebviewSessions.has(session)) return;
+ hardenedWebviewSessions.add(session);
+
+ // Chromium consults two hooks when a page uses a permission-gated API:
+ // setPermissionRequestHandler decides explicit requests (the ones that would
+ // show a prompt, e.g. getUserMedia), and setPermissionCheckHandler answers
+ // synchronous status probes (navigator.permissions.query). Both must deny
+ // the same list, otherwise a page could see "granted" via the check path
+ // while actual requests are refused, or vice versa.
+ session.setPermissionRequestHandler((_wc, permission, callback) => {
+ callback(!DENIED_WEBVIEW_PERMISSIONS.has(permission));
+ });
+ session.setPermissionCheckHandler(
+ (_wc, permission) => !DENIED_WEBVIEW_PERMISSIONS.has(permission),
+ );
+}
+
+// Hardens guests used by the in-app browser tab. The guest renders
+// arbitrary untrusted web content inside a privileged app window.
+function setupWebviewHandlers(window: BrowserWindow): void {
+ // Strip any preload / node access an attacker page might request.
+ window.webContents.on("will-attach-webview", (_event, webPreferences) => {
+ webPreferences.preload = undefined;
+ webPreferences.nodeIntegration = false;
+ webPreferences.contextIsolation = true;
+ });
+
+ window.webContents.on("did-attach-webview", (_event, guest) => {
+ hardenWebviewSession(guest.session);
+
+ guest.setWindowOpenHandler(({ url }) => {
+ // http(s)-only: a hostile page must not launch external protocol
+ // handlers (smb:, file:, custom app URIs) via window.open.
+ if (/^https?:$/i.test(safeProtocol(url))) {
+ shell.openExternal(url);
+ } else {
+ log.warn("Blocked webview popup to non-http(s) target", { url });
+ }
+ return { action: "deny" };
+ });
+
+ const guard = (
+ event: { preventDefault: () => void },
+ url: string,
+ ): void => {
+ if (!isAllowedWebviewNavigation(url)) {
+ event.preventDefault();
+ log.warn("Blocked disallowed webview navigation", { url });
+ }
+ };
+ // will-navigate + will-redirect cover top-level loads and redirect chains
+ // (the SSRF-to-metadata vector); will-frame-navigate covers sub-frames.
+ 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", {
@@ -230,6 +316,7 @@ export function createWindow(): void {
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
+ webviewTag: true,
preload: path.join(__dirname, "preload.js"),
enableBlinkFeatures: "GetDisplayMedia",
partition: "persist:main",
@@ -312,6 +399,7 @@ export function createWindow(): void {
});
setupExternalLinkHandlers(mainWindow);
+ setupWebviewHandlers(mainWindow);
setupEditableContextMenu(mainWindow);
setupCrashLogging(mainWindow);
buildApplicationMenu();
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 b0a3336e29..b944f65c46 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 c97a4d7da3..7088728cf6 100644
--- a/packages/shared/src/flags.ts
+++ b/packages/shared/src/flags.ts
@@ -11,3 +11,5 @@ 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";
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..f29aa2f830
--- /dev/null
+++ b/packages/ui/src/features/browser/BrowserPanel.test.ts
@@ -0,0 +1,39 @@
+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", "http://example.com/path"],
+ ["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..b00f1ce13d
--- /dev/null
+++ b/packages/ui/src/features/browser/BrowserPanel.tsx
@@ -0,0 +1,290 @@
+import {
+ ArrowClockwise,
+ ArrowLeft,
+ ArrowRight,
+ Globe,
+ X,
+} from "@phosphor-icons/react";
+import { Button } from "@posthog/quill";
+import { BROWSER_TAB_FLAG } from "@posthog/shared/constants";
+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 {
+ return useFeatureFlag(BROWSER_TAB_FLAG) || import.meta.env.DEV;
+}
+
+// Declared locally so @posthog/ui doesn't depend on electron types.
+interface WebviewElement extends HTMLElement {
+ getURL(): string;
+ loadURL(url: string): Promise;
+ reload(): void;
+ stop(): void;
+ // The webContents.navigationHistory.* deprecation does not apply here: these
+ // are element methods, still non-deprecated in Electron 42, and the
+ // element exposes no navigationHistory. Revisit only if Electron deprecates
+ // the tag methods themselves (the fix would be a main-process hop, not a
+ // rename).
+ goBack(): void;
+ goForward(): void;
+ canGoBack(): boolean;
+ canGoForward(): boolean;
+}
+
+const DEFAULT_URL = "about:blank";
+
+const LOOPBACK_HOST = /^(localhost|127\.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 ALLOWED_SCHEME = /^(https?):\/\//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 (ALLOWED_SCHEME.test(trimmed) || trimmed === "about:blank") {
+ return trimmed;
+ }
+ // 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)}`;
+}
+
+// Electron DOM events carry their payload as extra props on Event.
+type WebviewNavigateEvent = Event & { url: string; isMainFrame?: boolean };
+type WebviewTitleEvent = Event & { title: string };
+type WebviewFailLoadEvent = Event & {
+ errorCode: number;
+ errorDescription: string;
+ isMainFrame: boolean;
+};
+
+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 webviewRef = 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);
+ }
+ },
+ [],
+ );
+
+ useEffect(() => {
+ const webview = webviewRef.current;
+ if (!webview) return;
+
+ const onNavigate = (e: Event) => {
+ const ev = e as WebviewNavigateEvent;
+ // Subframe navigations must not hijack the address bar or persisted url.
+ if (ev.isMainFrame === false) return;
+ const next = ev.url ?? webview.getURL();
+ setAddress(next === DEFAULT_URL ? "" : next);
+ setCanGoBack(webview.canGoBack());
+ setCanGoForward(webview.canGoForward());
+ setLoadError(null);
+ persistUrl(next);
+ };
+
+ const onTitle = (e: Event) => {
+ const { title } = e as WebviewTitleEvent;
+ // SPAs rewrite the title constantly; skip the host write when unchanged.
+ if (title && title !== lastLabel.current) {
+ lastLabel.current = title;
+ onTitleChangeRef.current?.(title);
+ }
+ };
+
+ const onFailLoad = (e: Event) => {
+ const ev = e as WebviewFailLoadEvent;
+ // Ignore subframe failures and user-aborted loads (errorCode -3).
+ if (ev.isMainFrame === false || ev.errorCode === -3) return;
+ setLoadError(ev.errorDescription || "Failed to load page");
+ };
+
+ const onStartLoading = () => setIsLoading(true);
+ const onStopLoading = () => setIsLoading(false);
+
+ webview.addEventListener("did-navigate", onNavigate);
+ webview.addEventListener("did-navigate-in-page", onNavigate);
+ webview.addEventListener("page-title-updated", onTitle);
+ webview.addEventListener("did-fail-load", onFailLoad);
+ webview.addEventListener("did-start-loading", onStartLoading);
+ webview.addEventListener("did-stop-loading", onStopLoading);
+
+ return () => {
+ webview.removeEventListener("did-navigate", onNavigate);
+ webview.removeEventListener("did-navigate-in-page", onNavigate);
+ webview.removeEventListener("page-title-updated", onTitle);
+ webview.removeEventListener("did-fail-load", onFailLoad);
+ webview.removeEventListener("did-start-loading", onStartLoading);
+ webview.removeEventListener("did-stop-loading", onStopLoading);
+ };
+ }, [persistUrl]);
+
+ const navigate = useCallback((raw: string) => {
+ const webview = webviewRef.current;
+ if (!webview) return;
+ setLoadError(null);
+ // Aborted / guard-vetoed loads already surface via did-fail-load.
+ webview.loadURL(normalizeAddress(raw)).catch(() => {});
+ }, []);
+
+ const onSubmit = useCallback(
+ (e: React.FormEvent) => {
+ e.preventDefault();
+ navigate(address);
+ },
+ [address, navigate],
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {loadError && (
+
+
+
+ {loadError}
+
+
+ )}
+ {/* Shared persisted profile across all browser tabs/tasks is intentional
+ (stay logged in to e.g. GitHub); trade-off: shared cookies/storage.
+ No `allowpopups` — popups are denied and routed to the OS browser by
+ the guest's window-open handler (window.ts). */}
+ }
+ src={initialUrl.current}
+ partition="persist:browser"
+ style={{ height: "100%", width: "100%" }}
+ />
+
+
+ );
+}
diff --git a/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx b/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx
index 0d6568a6b9..f29c23798e 100644
--- a/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx
+++ b/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx
@@ -7,7 +7,7 @@ import { useIsWorkspaceCloudRun } from "../../workspace/useWorkspace";
import { useTabInjection } from "../hooks/usePanelLayoutHooks";
import type { SplitDirection } from "../panelLayoutStore";
import type { LeafPanel } from "../panelTypes";
-import { TabbedPanel } from "./TabbedPanel";
+import { type AddableTabKind, TabbedPanel } from "./TabbedPanel";
interface LeafNodeRendererProps {
node: LeafPanel;
@@ -21,7 +21,7 @@ interface LeafNodeRendererProps {
draggingTabPanelId: string | null;
onActiveTabChange: (panelId: string, tabId: string) => void;
onPanelFocus: (panelId: string) => void;
- onAddTerminal: (panelId: string) => void;
+ onAddTab: (panelId: string, kind: AddableTabKind) => void;
onSplitPanel: (panelId: string, direction: SplitDirection) => void;
}
@@ -37,7 +37,7 @@ export const LeafNodeRenderer: React.FC = ({
draggingTabPanelId,
onActiveTabChange,
onPanelFocus,
- onAddTerminal,
+ onAddTab,
onSplitPanel,
}) => {
const isCloud = useIsWorkspaceCloudRun(taskId);
@@ -90,7 +90,7 @@ export const LeafNodeRenderer: React.FC = ({
onPanelFocus={onPanelFocus}
draggingTabId={draggingTabId}
draggingTabPanelId={draggingTabPanelId}
- onAddTerminal={isCloud ? undefined : () => onAddTerminal(node.id)}
+ onAddTab={isCloud ? undefined : (kind) => onAddTab(node.id, kind)}
onSplitPanel={(direction) => onSplitPanel(node.id, direction)}
emptyState={cloudEmptyState}
/>
diff --git a/packages/ui/src/features/panels/components/PanelLayout.tsx b/packages/ui/src/features/panels/components/PanelLayout.tsx
index 9141acdbc2..852608f617 100644
--- a/packages/ui/src/features/panels/components/PanelLayout.tsx
+++ b/packages/ui/src/features/panels/components/PanelLayout.tsx
@@ -14,6 +14,7 @@ import { usePanelLayoutStore } from "../panelLayoutStore";
import type { PanelNode } from "../panelTypes";
import { GroupNodeRenderer } from "./GroupNodeRenderer";
import { LeafNodeRenderer } from "./LeafNodeRenderer";
+import type { AddableTabKind } from "./TabbedPanel";
interface PanelLayoutProps {
taskId: string;
@@ -65,9 +66,13 @@ const PanelLayoutRenderer: React.FC<{
[layoutState, taskId],
);
- const handleAddTerminal = useCallback(
- (panelId: string) => {
- layoutState.addTerminalTab(taskId, panelId);
+ const handleAddTab = useCallback(
+ (panelId: string, kind: AddableTabKind) => {
+ if (kind === "browser") {
+ layoutState.addBrowserTab(taskId, panelId, "about:blank");
+ } else {
+ layoutState.addTerminalTab(taskId, panelId);
+ }
},
[layoutState, taskId],
);
@@ -127,7 +132,7 @@ const PanelLayoutRenderer: React.FC<{
draggingTabPanelId={layoutState.draggingTabPanelId}
onActiveTabChange={handleSetActiveTab}
onPanelFocus={handlePanelFocus}
- onAddTerminal={handleAddTerminal}
+ onAddTab={handleAddTab}
onSplitPanel={handleSplitPanel}
/>
);
@@ -155,7 +160,7 @@ const PanelLayoutRenderer: React.FC<{
handleCloseTabsToRight,
handleKeepTab,
handlePanelFocus,
- handleAddTerminal,
+ handleAddTab,
handleSplitPanel,
setGroupRef,
handleLayout,
diff --git a/packages/ui/src/features/panels/components/TabbedPanel.tsx b/packages/ui/src/features/panels/components/TabbedPanel.tsx
index f36f35085f..d64ab63c2a 100644
--- a/packages/ui/src/features/panels/components/TabbedPanel.tsx
+++ b/packages/ui/src/features/panels/components/TabbedPanel.tsx
@@ -1,6 +1,18 @@
import { useDroppable } from "@dnd-kit/react";
-import { Plus, SquareSplitHorizontalIcon } from "@phosphor-icons/react";
+import {
+ Globe,
+ Plus,
+ SquareSplitHorizontalIcon,
+ Terminal,
+} from "@phosphor-icons/react";
import { useHostTRPCClient } from "@posthog/host-router/react";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@posthog/quill";
+import { useBrowserEnabled } from "@posthog/ui/features/browser/BrowserPanel";
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";
@@ -10,6 +22,10 @@ import type React from "react";
import { forwardRef, useCallback, useEffect, useRef, useState } from "react";
import { PanelTab } from "./PanelTab";
+// One kind-dispatching callback so new kinds don't thread another onAddX prop
+// through every panel component.
+export type AddableTabKind = "terminal" | "browser";
+
const activeTabStyle: React.CSSProperties = {
height: "100%",
width: "100%",
@@ -26,12 +42,18 @@ const hiddenTabStyle: React.CSSProperties = {
interface TabBarButtonProps {
ariaLabel: string;
- onClick: () => void;
+ dataAttr?: string;
+ // Optional so the button can serve as a DropdownMenuTrigger render target,
+ // where the trigger injects its own click handling.
+ onClick?: () => void;
children: React.ReactNode;
}
const TabBarButton = forwardRef(
- function TabBarButton({ ariaLabel, onClick, children, ...props }, ref) {
+ function TabBarButton(
+ { ariaLabel, dataAttr, onClick, children, ...props },
+ ref,
+ ) {
const [isHovered, setIsHovered] = useState(false);
return (
@@ -39,6 +61,7 @@ const TabBarButton = forwardRef(
ref={ref}
type="button"
aria-label={ariaLabel}
+ data-attr={dataAttr}
onClick={onClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
@@ -64,7 +87,7 @@ interface TabbedPanelProps {
onPanelFocus?: (panelId: string) => void;
draggingTabId?: string | null;
draggingTabPanelId?: string | null;
- onAddTerminal?: () => void;
+ onAddTab?: (kind: AddableTabKind) => void;
onSplitPanel?: (direction: SplitDirection) => void;
rightContent?: React.ReactNode;
emptyState?: React.ReactNode;
@@ -80,13 +103,15 @@ export const TabbedPanel: React.FC = ({
onPanelFocus,
draggingTabId = null,
draggingTabPanelId = null,
- onAddTerminal,
+ onAddTab,
onSplitPanel,
rightContent,
emptyState,
}) => {
const hostClient = useHostTRPCClient();
+ const browserEnabled = useBrowserEnabled();
+
const handleSplitClick = async () => {
const result = await hostClient.contextMenu.showSplitContextMenu.mutate();
const direction = (result.direction as SplitDirection | null) ?? null;
@@ -195,13 +220,51 @@ export const TabbedPanel: React.FC = ({
badge={tab.badge}
/>
))}
- {content.droppable && onAddTerminal && (
+ {/* With only one addable kind a menu is pointless — the "+" adds
+ a terminal directly, as it always has. */}
+ {content.droppable && onAddTab && !browserEnabled && (
-
+ onAddTab("terminal")}
+ >
)}
+ {content.droppable && onAddTab && browserEnabled && (
+
+
+
+
+ }
+ />
+
+ onAddTab("terminal")}
+ >
+
+ Terminal
+
+ onAddTab("browser")}
+ >
+
+ Browser
+
+
+
+ )}
{/* 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/task-detail/components/TabContentRenderer.tsx b/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx
index c59affe899..9267d11b7f 100644
--- a/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx
+++ b/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx
@@ -12,6 +12,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";
@@ -76,6 +77,9 @@ export function TabContentRenderer({
case "autoresearch":
return ;
+ case "browser":
+ 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 (
+
+ );
+}