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 ( + + + + + +
+ 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} + + + )} + {/* 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 ( + + ); +}