From 23735078ec32b804811ec01f965e1479f58d82c6 Mon Sep 17 00:00:00 2001 From: Daniel Visca Date: Fri, 31 Jul 2026 11:19:02 -0400 Subject: [PATCH 01/10] feat(product-view): embedded browser Product tab with environment registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 1 of the Product View: a top-level Product tab that embeds the user's own site (live origin or localhost dev server) in a sandboxed WebContentsView. - @posthog/platform: IEmbeddedBrowser interface + EMBEDDED_BROWSER token, HostCapabilities.embeddedBrowser flag (desktop true, web false) - apps/code: ElectronEmbeddedBrowser adapter (own cookie partition, http(s)-only navigation, window.open denied → external browser) - @posthog/core: ProductViewService (main container) — navigation guards, page-state stream, product-URL suggestions from app_urls + $pageview hosts - @posthog/workspace-server: product_environments sqlite table + service (per-project page-origin/data-project registry, tab restore via currentUrl) - host-router: productView router (one-line forwards + event subscription) - @posthog/ui: /product route, ProductView (toolbar, URL bar, environment switcher, data-project badge), environment picker with data-derived suggestions; Product appView tab target + command palette entry Generated-By: PostHog Code Task-Id: 1120fcea-fc07-4c54-8dbb-50b7b712f9cb --- apps/code/src/main/di/bindings.ts | 3 + apps/code/src/main/di/container.ts | 11 + .../electron-embedded-browser.ts | 240 +++++++++++++++++ apps/code/src/main/trpc/router.ts | 2 + apps/code/src/renderer/desktop-services.ts | 5 +- apps/web/src/web-container.ts | 5 +- packages/core/src/product-view/identifiers.ts | 6 + .../src/product-view/product-view.module.ts | 7 + .../product-view/productSuggestions.test.ts | 82 ++++++ .../src/product-view/productSuggestions.ts | 89 ++++++ .../core/src/product-view/productView.test.ts | 108 ++++++++ packages/core/src/product-view/productView.ts | 159 +++++++++++ packages/core/src/product-view/schemas.ts | 50 ++++ packages/host-router/src/router.ts | 2 + .../src/routers/product-view.router.ts | 112 ++++++++ packages/platform/package.json | 4 + packages/platform/src/embedded-browser.ts | 121 +++++++++ packages/platform/src/host-capabilities.ts | 7 + packages/platform/tsup.config.ts | 1 + packages/shared/src/analytics-events.ts | 1 + packages/shared/src/index.ts | 4 + packages/shared/src/product-view-schemas.ts | 23 ++ .../features/browser-tabs/BrowserTabStrip.tsx | 13 +- .../ui/src/features/command/CommandMenu.tsx | 13 + .../product-view/ProductEnvironmentPicker.tsx | 134 ++++++++++ .../src/features/product-view/ProductView.tsx | 253 ++++++++++++++++++ .../product-view/useProductEnvironments.ts | 52 ++++ .../features/product-view/useProductView.ts | 124 +++++++++ packages/ui/src/router/navigationBridge.ts | 4 + packages/ui/src/router/routeTree.gen.ts | 21 ++ packages/ui/src/router/routes/product.tsx | 11 + packages/ui/src/router/useAppView.ts | 3 + packages/ui/src/shell/useHostCapabilities.ts | 5 +- .../workspace-server/src/db/identifiers.ts | 3 + .../migrations/0023_product_environments.sql | 12 + .../src/db/migrations/meta/_journal.json | 7 + .../src/db/repositories.module.ts | 5 + .../product-environments-repository.ts | 79 ++++++ packages/workspace-server/src/db/schema.ts | 26 ++ packages/workspace-server/src/di/tokens.ts | 3 + .../product-view/product-view.module.ts | 9 + .../src/services/product-view/schemas.ts | 30 +++ .../src/services/product-view/service.test.ts | 114 ++++++++ .../src/services/product-view/service.ts | 86 ++++++ 44 files changed, 2045 insertions(+), 4 deletions(-) create mode 100644 apps/code/src/main/platform-adapters/electron-embedded-browser.ts create mode 100644 packages/core/src/product-view/identifiers.ts create mode 100644 packages/core/src/product-view/product-view.module.ts create mode 100644 packages/core/src/product-view/productSuggestions.test.ts create mode 100644 packages/core/src/product-view/productSuggestions.ts create mode 100644 packages/core/src/product-view/productView.test.ts create mode 100644 packages/core/src/product-view/productView.ts create mode 100644 packages/core/src/product-view/schemas.ts create mode 100644 packages/host-router/src/routers/product-view.router.ts create mode 100644 packages/platform/src/embedded-browser.ts create mode 100644 packages/shared/src/product-view-schemas.ts create mode 100644 packages/ui/src/features/product-view/ProductEnvironmentPicker.tsx create mode 100644 packages/ui/src/features/product-view/ProductView.tsx create mode 100644 packages/ui/src/features/product-view/useProductEnvironments.ts create mode 100644 packages/ui/src/features/product-view/useProductView.ts create mode 100644 packages/ui/src/router/routes/product.tsx create mode 100644 packages/workspace-server/src/db/migrations/0023_product_environments.sql create mode 100644 packages/workspace-server/src/db/repositories/product-environments-repository.ts create mode 100644 packages/workspace-server/src/services/product-view/product-view.module.ts create mode 100644 packages/workspace-server/src/services/product-view/schemas.ts create mode 100644 packages/workspace-server/src/services/product-view/service.test.ts create mode 100644 packages/workspace-server/src/services/product-view/service.ts diff --git a/apps/code/src/main/di/bindings.ts b/apps/code/src/main/di/bindings.ts index 6cc67ecbf5..2e6b196a81 100644 --- a/apps/code/src/main/di/bindings.ts +++ b/apps/code/src/main/di/bindings.ts @@ -115,6 +115,7 @@ import type { APP_METRICS_SERVICE } from "@posthog/platform/app-metrics"; import type { BUNDLED_RESOURCES_SERVICE } from "@posthog/platform/bundled-resources"; import type { CLIPBOARD_SERVICE } from "@posthog/platform/clipboard"; import type { CONTEXT_MENU_SERVICE } from "@posthog/platform/context-menu"; +import type { EMBEDDED_BROWSER } from "@posthog/platform/embedded-browser"; import type { CRYPTO_SERVICE } from "@posthog/platform/crypto"; import type { DEEP_LINK_SERVICE } from "@posthog/platform/deep-link"; import type { DEV_HOST_ACTIONS_SERVICE } from "@posthog/platform/dev-host-actions"; @@ -233,6 +234,7 @@ import type { ElectronAppMetrics } from "../platform-adapters/electron-app-metri import type { ElectronBundledResources } from "../platform-adapters/electron-bundled-resources"; import type { ElectronClipboard } from "../platform-adapters/electron-clipboard"; import type { ElectronContextMenu } from "../platform-adapters/electron-context-menu"; +import type { ElectronEmbeddedBrowser } from "../platform-adapters/electron-embedded-browser"; import type { ElectronCrypto } from "../platform-adapters/electron-crypto"; import type { ElectronDevHostActions } from "../platform-adapters/electron-dev-host-actions"; import type { ElectronDialog } from "../platform-adapters/electron-dialog"; @@ -333,6 +335,7 @@ export interface MainBindings { [UPDATER_SERVICE]: ElectronUpdater; [NOTIFIER_SERVICE]: ElectronNotifier; [CONTEXT_MENU_SERVICE]: ElectronContextMenu; + [EMBEDDED_BROWSER]: ElectronEmbeddedBrowser; [BUNDLED_RESOURCES_SERVICE]: ElectronBundledResources; [IMAGE_PROCESSOR_SERVICE]: ElectronImageProcessor; [WORKSPACE_SETTINGS_SERVICE]: ElectronWorkspaceSettings; diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index 04c245598f..34af470a07 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -25,6 +25,7 @@ import { MCP_RELAY_EXECUTOR, } from "@posthog/core/cloud-task/identifiers"; import { contextMenuCoreModule } from "@posthog/core/context-menu/context-menu.module"; +import { productViewCoreModule } from "@posthog/core/product-view/product-view.module"; import { CONTEXT_MENU_CONTROLLER, CONTEXT_MENU_EXTERNAL_APPS_SERVICE, @@ -105,6 +106,7 @@ import { APP_METRICS_SERVICE } from "@posthog/platform/app-metrics"; import { BUNDLED_RESOURCES_SERVICE } from "@posthog/platform/bundled-resources"; import { CLIPBOARD_SERVICE } from "@posthog/platform/clipboard"; import { CONTEXT_MENU_SERVICE } from "@posthog/platform/context-menu"; +import { EMBEDDED_BROWSER } from "@posthog/platform/embedded-browser"; import { CRYPTO_SERVICE } from "@posthog/platform/crypto"; import { DEEP_LINK_SERVICE } from "@posthog/platform/deep-link"; import { DEV_HOST_ACTIONS_SERVICE } from "@posthog/platform/dev-host-actions"; @@ -154,6 +156,7 @@ import { import { authProxyModule } from "@posthog/workspace-server/services/auth-proxy/auth-proxy.module"; import { AUTH_PROXY_AUTH } from "@posthog/workspace-server/services/auth-proxy/identifiers"; import { browserTabsModule } from "@posthog/workspace-server/services/browser-tabs/browser-tabs.module"; +import { productEnvironmentsModule } from "@posthog/workspace-server/services/product-view/product-view.module"; import { claudeCliSessionsModule } from "@posthog/workspace-server/services/claude-cli-sessions/claude-cli-sessions.module"; import { enrichmentModule } from "@posthog/workspace-server/services/enrichment/enrichment.module"; import { @@ -238,6 +241,7 @@ import { ElectronAppMetrics } from "../platform-adapters/electron-app-metrics"; import { ElectronBundledResources } from "../platform-adapters/electron-bundled-resources"; import { ElectronClipboard } from "../platform-adapters/electron-clipboard"; import { ElectronContextMenu } from "../platform-adapters/electron-context-menu"; +import { ElectronEmbeddedBrowser } from "../platform-adapters/electron-embedded-browser"; import { ElectronCrypto } from "../platform-adapters/electron-crypto"; import { ElectronDevHostActions } from "../platform-adapters/electron-dev-host-actions"; import { ElectronDialog } from "../platform-adapters/electron-dialog"; @@ -357,6 +361,7 @@ container.bind(POWER_MANAGER_SERVICE).to(ElectronPowerManager); container.bind(UPDATER_SERVICE).to(ElectronUpdater); container.bind(NOTIFIER_SERVICE).to(ElectronNotifier); container.bind(CONTEXT_MENU_SERVICE).to(ElectronContextMenu); +container.bind(EMBEDDED_BROWSER).to(ElectronEmbeddedBrowser); container.bind(BUNDLED_RESOURCES_SERVICE).to(ElectronBundledResources); container.bind(IMAGE_PROCESSOR_SERVICE).to(ElectronImageProcessor); container.bind(WORKSPACE_SETTINGS_SERVICE).to(ElectronWorkspaceSettings); @@ -813,6 +818,12 @@ container.load(canvasCoreModule); // service in the main process; resolved by the host-router browserTabs router. container.load(browserTabsModule); +// Product View: the embedded-browser orchestration service (core) and the +// per-project product-environment registry (workspace-server sqlite), both +// resolved by the host-router productView router. +container.load(productViewCoreModule); +container.load(productEnvironmentsModule); + container.bind(MAIN_DEV_FLAGS_SERVICE).to(DevFlagsService); container.bind(MAIN_DEV_METRICS_SERVICE).to(DevMetricsService); container.bind(MAIN_DEV_NETWORK_SERVICE).to(DevNetworkService); diff --git a/apps/code/src/main/platform-adapters/electron-embedded-browser.ts b/apps/code/src/main/platform-adapters/electron-embedded-browser.ts new file mode 100644 index 0000000000..59b4b180bb --- /dev/null +++ b/apps/code/src/main/platform-adapters/electron-embedded-browser.ts @@ -0,0 +1,240 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { + EmbeddedBrowserBounds, + EmbeddedBrowserCreateOptions, + EmbeddedBrowserEvent, + EmbeddedBrowserOverlayPayload, + EmbeddedBrowserPageState, + IEmbeddedBrowser, +} from "@posthog/platform/embedded-browser"; +import { MAIN_WINDOW_SERVICE } from "@posthog/platform/main-window"; +import { TypedEventEmitter } from "@posthog/shared"; +import { shell, WebContentsView } from "electron"; +import { inject, injectable } from "inversify"; +import { logger } from "../utils/logger"; +import type { ElectronMainWindow } from "./electron-main-window"; + +const log = logger.scope("embedded-browser"); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +type Events = { event: EmbeddedBrowserEvent }; + +function isWebUrl(raw: string): boolean { + try { + const url = new URL(raw); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} + +/** + * Desktop implementation of the Product View browser surface: one + * `WebContentsView` per view id, attached to the single main window. The view + * paints natively above the renderer, so the renderer only ever drives bounds + * and visibility; element-anchored overlay UI renders inside the page via the + * product-view preload (isolated world — the page can't see or spoof it). + * + * Security posture: fully sandboxed guest, its own cookie partition (the user + * signs into THEIR product there; the app session at `persist:main` is never + * shared), http(s)-only navigation, window.open denied (opens externally). + */ +@injectable() +export class ElectronEmbeddedBrowser + extends TypedEventEmitter + implements IEmbeddedBrowser +{ + private readonly views = new Map(); + + constructor( + @inject(MAIN_WINDOW_SERVICE) + private readonly mainWindow: ElectronMainWindow, + ) { + super(); + this.setMaxListeners(0); + } + + async create(options: EmbeddedBrowserCreateOptions): Promise { + const existing = this.views.get(options.viewId); + if (existing) { + // Re-opening a kept-alive view (tab switch back): re-glue and re-show it; + // only navigate when the caller actually wants a different page. + this.setBounds(options.viewId, options.bounds); + existing.setVisible(true); + if (existing.webContents.getURL() !== options.url) { + await this.navigate(options.viewId, options.url); + } + this.emitPageState(options.viewId, existing); + return; + } + + const window = this.mainWindow.getBrowserWindow(); + if (!window) throw new Error("No main window to attach the view to"); + + const view = new WebContentsView({ + webPreferences: { + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + partition: "persist:product-view", + preload: path.join(__dirname, "productViewPreload.js"), + }, + }); + this.views.set(options.viewId, view); + this.wireEvents(options.viewId, view); + window.contentView.addChildView(view); + this.setBounds(options.viewId, options.bounds); + + try { + await view.webContents.loadURL(options.url); + } catch (error) { + // Load failures (bad host, offline) keep the view alive — the page-state + // stream reports isLoading=false and the user can retry from the URL bar. + log.warn("initial load failed", { url: options.url, error }); + } + } + + async navigate(viewId: string, url: string): Promise { + const view = this.mustGet(viewId); + try { + await view.webContents.loadURL(url); + } catch (error) { + log.warn("navigation failed", { url, error }); + this.emitPageState(viewId, view); + } + } + + goBack(viewId: string): void { + this.views.get(viewId)?.webContents.navigationHistory.goBack(); + } + + goForward(viewId: string): void { + this.views.get(viewId)?.webContents.navigationHistory.goForward(); + } + + reload(viewId: string): void { + this.views.get(viewId)?.webContents.reload(); + } + + setBounds(viewId: string, bounds: EmbeddedBrowserBounds): void { + const view = this.views.get(viewId); + const window = this.mainWindow.getBrowserWindow(); + if (!view || !window) return; + // The renderer reports CSS pixels; the window may be zoomed (Cmd+/-), so + // scale to the host page's zoom factor to land on real window coordinates. + const zoom = window.webContents.getZoomFactor(); + view.setBounds({ + x: Math.round(bounds.x * zoom), + y: Math.round(bounds.y * zoom), + width: Math.max(0, Math.round(bounds.width * zoom)), + height: Math.max(0, Math.round(bounds.height * zoom)), + }); + } + + setVisible(viewId: string, visible: boolean): void { + this.views.get(viewId)?.setVisible(visible); + } + + async destroy(viewId: string): Promise { + const view = this.views.get(viewId); + if (!view) return; + this.views.delete(viewId); + const window = this.mainWindow.getBrowserWindow(); + window?.contentView.removeChildView(view); + view.webContents.close(); + this.emit("event", { type: "view-destroyed", viewId }); + } + + getPageState(viewId: string): EmbeddedBrowserPageState | null { + const view = this.views.get(viewId); + return view ? this.pageState(viewId, view) : null; + } + + setInspectMode(viewId: string, enabled: boolean): void { + this.views + .get(viewId) + ?.webContents.send("product-view:set-inspect-mode", { enabled }); + } + + pushOverlayData(payload: EmbeddedBrowserOverlayPayload): void { + this.views + .get(payload.viewId) + ?.webContents.send("product-view:overlay-data", { + items: payload.items, + }); + } + + events(signal?: AbortSignal): AsyncIterable { + return this.toIterable("event", { signal }); + } + + private mustGet(viewId: string): WebContentsView { + const view = this.views.get(viewId); + if (!view) throw new Error(`Unknown product view: ${viewId}`); + return view; + } + + private pageState( + viewId: string, + view: WebContentsView, + ): EmbeddedBrowserPageState { + const wc = view.webContents; + return { + viewId, + url: wc.getURL(), + title: wc.getTitle(), + canGoBack: wc.navigationHistory.canGoBack(), + canGoForward: wc.navigationHistory.canGoForward(), + isLoading: wc.isLoading(), + }; + } + + private emitPageState(viewId: string, view: WebContentsView): void { + this.emit("event", { + type: "page-state", + state: this.pageState(viewId, view), + }); + } + + private wireEvents(viewId: string, view: WebContentsView): void { + const wc = view.webContents; + const push = () => this.emitPageState(viewId, view); + wc.on("did-navigate", push); + wc.on("did-navigate-in-page", push); + wc.on("page-title-updated", push); + wc.on("did-start-loading", push); + wc.on("did-stop-loading", push); + + // The guest stays a plain web page: block non-web schemes and keep + // popups/new windows in the system browser rather than spawning windows. + wc.on("will-navigate", (event, url) => { + if (!isWebUrl(url)) event.preventDefault(); + }); + wc.setWindowOpenHandler(({ url }) => { + if (isWebUrl(url)) void shell.openExternal(url); + return { action: "deny" }; + }); + + // Preload → host messages (inspector reports). Validated downstream by + // the core service; here we only namespace-check and forward. + wc.ipc.on("product-view:elements-reported", (_event, payload) => { + this.emit("event", { + type: "elements-reported", + viewId, + pageUrl: wc.getURL(), + elements: Array.isArray(payload?.elements) ? payload.elements : [], + }); + }); + wc.ipc.on("product-view:element-selected", (_event, payload) => { + if (!payload?.element) return; + this.emit("event", { + type: "element-selected", + viewId, + pageUrl: wc.getURL(), + element: payload.element, + }); + }); + } +} diff --git a/apps/code/src/main/trpc/router.ts b/apps/code/src/main/trpc/router.ts index 362a9de753..a3fcbcb2d3 100644 --- a/apps/code/src/main/trpc/router.ts +++ b/apps/code/src/main/trpc/router.ts @@ -39,6 +39,7 @@ import { onboardingImportRouter } from "@posthog/host-router/routers/onboarding- import { osRouter } from "@posthog/host-router/routers/os.router"; import { piSessionRouter } from "@posthog/host-router/routers/pi-session.router"; import { processTrackingRouter } from "@posthog/host-router/routers/process-tracking.router"; +import { productViewRouter } from "@posthog/host-router/routers/product-view.router"; import { provisioningRouter } from "@posthog/host-router/routers/provisioning.router"; import { releaseFeedRouter } from "@posthog/host-router/routers/release-feed.router"; import { secureStoreRouter } from "@posthog/host-router/routers/secure-store.router"; @@ -102,6 +103,7 @@ export const trpcRouter = router({ os: osRouter, piSession: piSessionRouter, processTracking: processTrackingRouter, + productView: productViewRouter, provisioning: provisioningRouter, sleep: sleepRouter, suspension: suspensionRouter, diff --git a/apps/code/src/renderer/desktop-services.ts b/apps/code/src/renderer/desktop-services.ts index 140113505c..667d969c13 100644 --- a/apps/code/src/renderer/desktop-services.ts +++ b/apps/code/src/renderer/desktop-services.ts @@ -448,4 +448,7 @@ container.bind(SETUP_STORE).toConstantValue(setupStore); container .bind(HOST_CAPABILITIES) - .toConstantValue({ localWorkspaces: true } satisfies HostCapabilities); + .toConstantValue({ + localWorkspaces: true, + embeddedBrowser: true, + } satisfies HostCapabilities); diff --git a/apps/web/src/web-container.ts b/apps/web/src/web-container.ts index 1321eafa37..1e545364ba 100644 --- a/apps/web/src/web-container.ts +++ b/apps/web/src/web-container.ts @@ -459,7 +459,10 @@ container.bind(POWER_MANAGER_SERVICE).toConstantValue(webPowerManager); // otherwise reach for local folders/worktrees/terminal. container .bind(HOST_CAPABILITIES) - .toConstantValue({ localWorkspaces: false } satisfies HostCapabilities); + .toConstantValue({ + localWorkspaces: false, + embeddedBrowser: false, + } satisfies HostCapabilities); container.load(authUiModule); diff --git a/packages/core/src/product-view/identifiers.ts b/packages/core/src/product-view/identifiers.ts new file mode 100644 index 0000000000..6ee8d1799a --- /dev/null +++ b/packages/core/src/product-view/identifiers.ts @@ -0,0 +1,6 @@ +// DI token for the Product View orchestration service. Lives in @posthog/core +// so host-router routers and the host DI container can reference it without +// depending on the desktop main process (where the service is bound). +export const PRODUCT_VIEW_SERVICE = Symbol.for( + "posthog.core.productView.service", +); diff --git a/packages/core/src/product-view/product-view.module.ts b/packages/core/src/product-view/product-view.module.ts new file mode 100644 index 0000000000..ddf24dec2f --- /dev/null +++ b/packages/core/src/product-view/product-view.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { PRODUCT_VIEW_SERVICE } from "./identifiers"; +import { ProductViewService } from "./productView"; + +export const productViewCoreModule = new ContainerModule(({ bind }) => { + bind(PRODUCT_VIEW_SERVICE).to(ProductViewService).inSingletonScope(); +}); diff --git a/packages/core/src/product-view/productSuggestions.test.ts b/packages/core/src/product-view/productSuggestions.test.ts new file mode 100644 index 0000000000..47920e1a69 --- /dev/null +++ b/packages/core/src/product-view/productSuggestions.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { buildHostsQuery, shapeUrlSuggestions } from "./productSuggestions"; + +describe("buildHostsQuery", () => { + it("aggregates pageview hosts over the last 7 days", () => { + const sql = buildHostsQuery(); + expect(sql).toContain("properties.$host"); + expect(sql).toContain("'$pageview'"); + expect(sql).toContain("INTERVAL 7 DAY"); + expect(sql).toMatch(/LIMIT \d+/); + }); +}); + +describe("shapeUrlSuggestions", () => { + it("merges app_urls and pageview hosts, app_urls first", () => { + const suggestions = shapeUrlSuggestions( + ["https://us.posthog.com", "https://posthog.com"], + [ + ["us.posthog.com", 120000], + ["app.example.com", 500], + ], + ); + expect(suggestions.map((s) => s.url)).toEqual([ + "https://us.posthog.com", + "https://posthog.com", + "https://app.example.com", + ]); + expect(suggestions[0].source).toBe("app_urls"); + expect(suggestions[2]).toMatchObject({ + source: "pageview_hosts", + eventCount: 500, + }); + }); + + it("dedupes hosts already covered by app_urls", () => { + const suggestions = shapeUrlSuggestions( + ["https://us.posthog.com"], + [["us.posthog.com", 99]], + ); + expect(suggestions).toHaveLength(1); + }); + + it("drops invalid app_urls and malformed host rows", () => { + const suggestions = shapeUrlSuggestions( + ["not a url", "https://ok.example", 42 as unknown as string], + [ + [null, 1], + ["", 2], + ["good.example", 3], + ], + ); + expect(suggestions.map((s) => s.url)).toEqual([ + "https://ok.example", + "https://good.example", + ]); + }); + + it("keeps a wildcard-free origin only (strips paths, keeps localhost ports)", () => { + const suggestions = shapeUrlSuggestions( + ["https://app.example.com/replay/*"], + [["localhost:8010", 10]], + ); + expect(suggestions.map((s) => s.url)).toEqual([ + "https://app.example.com", + "http://localhost:8010", + ]); + }); + + it("uses http for localhost hosts and https for everything else", () => { + const suggestions = shapeUrlSuggestions( + [], + [ + ["127.0.0.1:3000", 5], + ["prod.example.com", 9], + ], + ); + expect(suggestions.map((s) => s.url)).toEqual([ + "http://127.0.0.1:3000", + "https://prod.example.com", + ]); + }); +}); diff --git a/packages/core/src/product-view/productSuggestions.ts b/packages/core/src/product-view/productSuggestions.ts new file mode 100644 index 0000000000..4da9af7248 --- /dev/null +++ b/packages/core/src/product-view/productSuggestions.ts @@ -0,0 +1,89 @@ +/** + * Pure builders + shapers behind ProductViewService.suggestProductUrls(): + * candidate "this is your product" origins for a PostHog project, derived from + * the project's own data — its configured toolbar `app_urls` and the hosts its + * `$pageview` events actually report. Query building and row shaping are pure + * so they're unit-testable without the client (agent-analytics pattern). + */ + +export interface ProductUrlSuggestion { + /** A bare origin, e.g. `https://us.posthog.com` or `http://localhost:8010`. */ + url: string; + source: "app_urls" | "pageview_hosts"; + /** 7-day $pageview volume for pageview-host suggestions. */ + eventCount?: number; +} + +const HOSTS_LIMIT = 10; + +/** Top $pageview hosts over the last 7 days — no user input interpolated. */ +export function buildHostsQuery(): string { + return [ + "SELECT properties.$host AS host, count() AS pageviews", + "FROM events", + "WHERE event = '$pageview' AND timestamp > now() - INTERVAL 7 DAY", + "GROUP BY host", + "ORDER BY pageviews DESC", + `LIMIT ${HOSTS_LIMIT}`, + ].join("\n"); +} + +function isLocalHost(host: string): boolean { + const name = host.split(":")[0]; + return name === "localhost" || name === "127.0.0.1" || name === "0.0.0.0"; +} + +/** Normalize an app_urls entry (may carry paths or `/*` wildcards) to a bare + * origin; null when it isn't a usable http(s) URL. */ +function originFromAppUrl(value: unknown): string | null { + if (typeof value !== "string" || value.length === 0) return null; + try { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + return url.origin; + } catch { + return null; + } +} + +/** A `$host` property is `host[:port]`; scheme is not captured, so assume + * https except for local hosts (which are plain-http dev servers). */ +function originFromHost(value: unknown): string | null { + if (typeof value !== "string" || value.length === 0) return null; + const scheme = isLocalHost(value) ? "http" : "https"; + try { + return new URL(`${scheme}://${value}`).origin; + } catch { + return null; + } +} + +export function shapeUrlSuggestions( + appUrls: unknown[], + hostRows: unknown[], +): ProductUrlSuggestion[] { + const suggestions: ProductUrlSuggestion[] = []; + const seen = new Set(); + + for (const entry of appUrls) { + const origin = originFromAppUrl(entry); + if (!origin || seen.has(origin)) continue; + seen.add(origin); + suggestions.push({ url: origin, source: "app_urls" }); + } + + for (const row of hostRows) { + if (!Array.isArray(row)) continue; + const origin = originFromHost(row[0]); + if (!origin || seen.has(origin)) continue; + seen.add(origin); + const count = typeof row[1] === "number" ? row[1] : undefined; + suggestions.push({ + url: origin, + source: "pageview_hosts", + ...(count !== undefined ? { eventCount: count } : {}), + }); + } + + return suggestions; +} diff --git a/packages/core/src/product-view/productView.test.ts b/packages/core/src/product-view/productView.test.ts new file mode 100644 index 0000000000..bdd6cf461b --- /dev/null +++ b/packages/core/src/product-view/productView.test.ts @@ -0,0 +1,108 @@ +import type { RootLogger } from "@posthog/di/logger"; +import type { IEmbeddedBrowser } from "@posthog/platform/embedded-browser"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ProductViewService } from "./productView"; + +const runHogQLQuery = vi.fn(); +vi.mock("../canvas/posthogApi", () => ({ + runHogQLQuery: (...args: unknown[]) => runHogQLQuery(...args), +})); + +const fakeLogger = { + scope: () => ({ warn: vi.fn() }), +} as unknown as RootLogger; + +function makeBrowser() { + return { + create: vi.fn().mockResolvedValue(undefined), + navigate: vi.fn().mockResolvedValue(undefined), + goBack: vi.fn(), + goForward: vi.fn(), + reload: vi.fn(), + setBounds: vi.fn(), + setVisible: vi.fn(), + destroy: vi.fn().mockResolvedValue(undefined), + getPageState: vi.fn().mockReturnValue(null), + setInspectMode: vi.fn(), + pushOverlayData: vi.fn(), + events: vi.fn(), + } satisfies IEmbeddedBrowser; +} + +function makeAuth(overrides: Partial> = {}) { + return { + getValidAccessToken: vi + .fn() + .mockResolvedValue({ + accessToken: "t", + apiHost: "https://us.posthog.com", + }), + getState: vi.fn().mockReturnValue({ currentProjectId: 2 }), + authenticatedFetch: vi.fn(), + ...overrides, + } as never; +} + +describe("ProductViewService navigation guards", () => { + let browser: ReturnType; + let service: ProductViewService; + + beforeEach(() => { + browser = makeBrowser(); + service = new ProductViewService(browser, makeAuth(), fakeLogger); + }); + + const bounds = { x: 0, y: 0, width: 800, height: 600 }; + + it("opens plain web URLs", async () => { + await service.open({ viewId: "v1", url: "https://posthog.com", bounds }); + expect(browser.create).toHaveBeenCalledWith({ + viewId: "v1", + url: "https://posthog.com/", + bounds, + }); + }); + + it.each(["file:///etc/passwd", "chrome://settings", "javascript:alert(1)"])( + "refuses to open %s", + async (url) => { + await expect(service.open({ viewId: "v1", url, bounds })).rejects.toThrow( + /http/i, + ); + expect(browser.create).not.toHaveBeenCalled(); + }, + ); + + it("refuses to navigate to a non-web URL", async () => { + await expect(service.navigate("v1", "file:///x")).rejects.toThrow(/http/i); + expect(browser.navigate).not.toHaveBeenCalled(); + }); +}); + +describe("ProductViewService.suggestProductUrls", () => { + it("merges app_urls with pageview hosts and survives either source failing", async () => { + const browser = makeBrowser(); + const auth = makeAuth({ + authenticatedFetch: vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ app_urls: ["https://us.posthog.com"] }), + }), + }); + runHogQLQuery.mockResolvedValue({ + columns: ["host", "pageviews"], + results: [["eu.posthog.com", 42]], + }); + + const service = new ProductViewService(browser, auth, fakeLogger); + const suggestions = await service.suggestProductUrls(); + expect(suggestions.map((s) => s.url)).toEqual([ + "https://us.posthog.com", + "https://eu.posthog.com", + ]); + + // HogQL source failing must not sink the whole call. + runHogQLQuery.mockRejectedValue(new Error("query down")); + const degraded = await service.suggestProductUrls(); + expect(degraded.map((s) => s.url)).toEqual(["https://us.posthog.com"]); + }); +}); diff --git a/packages/core/src/product-view/productView.ts b/packages/core/src/product-view/productView.ts new file mode 100644 index 0000000000..2f9c1d60f9 --- /dev/null +++ b/packages/core/src/product-view/productView.ts @@ -0,0 +1,159 @@ +import type { AuthService } from "@posthog/core/auth/auth"; +import { AUTH_SERVICE } from "@posthog/core/auth/auth.module"; +import { + ROOT_LOGGER, + type RootLogger, + type ScopedLogger, +} from "@posthog/di/logger"; +import { + EMBEDDED_BROWSER, + type EmbeddedBrowserBounds, + type EmbeddedBrowserEvent, + type EmbeddedBrowserPageState, + type IEmbeddedBrowser, +} from "@posthog/platform/embedded-browser"; +import { inject, injectable } from "inversify"; +import { runHogQLQuery } from "../canvas/posthogApi"; +import { + buildHostsQuery, + type ProductUrlSuggestion, + shapeUrlSuggestions, +} from "./productSuggestions"; + +export interface IProductViewService { + open(input: { + viewId: string; + url: string; + bounds: EmbeddedBrowserBounds; + }): Promise; + navigate(viewId: string, url: string): Promise; + goBack(viewId: string): void; + goForward(viewId: string): void; + reload(viewId: string): void; + setBounds(viewId: string, bounds: EmbeddedBrowserBounds): void; + setVisible(viewId: string, visible: boolean): void; + destroy(viewId: string): Promise; + getPageState(viewId: string): EmbeddedBrowserPageState | null; + setInspectMode(viewId: string, enabled: boolean): void; + events(signal?: AbortSignal): AsyncIterable; + suggestProductUrls(): Promise; +} + +/** The embedded browser must only ever load plain web pages. */ +function assertHttpUrl(raw: string): string { + const url = new URL(raw); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("Product View can only open http(s) URLs"); + } + return url.toString(); +} + +/** + * Orchestrates the Product View's embedded browser: validates navigation, + * fans out page events, and derives "this is your product" URL suggestions + * from the selected PostHog project's own data. Runs in the host's main + * container (like CanvasDataService) — the renderer reaches it over tRPC and + * PostHog credentials never leave the host side. + */ +@injectable() +export class ProductViewService implements IProductViewService { + private readonly log: ScopedLogger; + + constructor( + @inject(EMBEDDED_BROWSER) + private readonly embeddedBrowser: IEmbeddedBrowser, + @inject(AUTH_SERVICE) + private readonly authService: AuthService, + @inject(ROOT_LOGGER) + rootLogger: RootLogger, + ) { + this.log = rootLogger.scope("product-view"); + } + + async open(input: { + viewId: string; + url: string; + bounds: EmbeddedBrowserBounds; + }): Promise { + const url = assertHttpUrl(input.url); + await this.embeddedBrowser.create({ ...input, url }); + } + + async navigate(viewId: string, url: string): Promise { + await this.embeddedBrowser.navigate(viewId, assertHttpUrl(url)); + } + + goBack(viewId: string): void { + this.embeddedBrowser.goBack(viewId); + } + + goForward(viewId: string): void { + this.embeddedBrowser.goForward(viewId); + } + + reload(viewId: string): void { + this.embeddedBrowser.reload(viewId); + } + + setBounds(viewId: string, bounds: EmbeddedBrowserBounds): void { + this.embeddedBrowser.setBounds(viewId, bounds); + } + + setVisible(viewId: string, visible: boolean): void { + this.embeddedBrowser.setVisible(viewId, visible); + } + + async destroy(viewId: string): Promise { + await this.embeddedBrowser.destroy(viewId); + } + + getPageState(viewId: string): EmbeddedBrowserPageState | null { + return this.embeddedBrowser.getPageState(viewId); + } + + setInspectMode(viewId: string, enabled: boolean): void { + this.embeddedBrowser.setInspectMode(viewId, enabled); + } + + events(signal?: AbortSignal): AsyncIterable { + return this.embeddedBrowser.events(signal); + } + + /** + * Candidate product origins for the current project: configured toolbar + * `app_urls` first, then the hosts $pageview traffic actually reports. + * Either source failing degrades to the other rather than throwing. + */ + async suggestProductUrls(): Promise { + const [appUrls, hostRows] = await Promise.all([ + this.fetchAppUrls().catch((error) => { + this.log.warn("app_urls fetch failed", { error }); + return [] as unknown[]; + }), + runHogQLQuery(this.authService, buildHostsQuery(), { + refresh: "blocking", + }) + .then((r) => r.results) + .catch((error) => { + this.log.warn("pageview hosts query failed", { error }); + return [] as unknown[]; + }), + ]); + return shapeUrlSuggestions(appUrls, hostRows); + } + + private async fetchAppUrls(): Promise { + const { apiHost } = await this.authService.getValidAccessToken(); + const projectId = this.authService.getState().currentProjectId; + if (projectId == null) throw new Error("No PostHog project selected"); + const response = await this.authService.authenticatedFetch( + fetch, + `${apiHost}/api/projects/${projectId}/`, + ); + if (!response.ok) { + throw new Error(`Project fetch failed (${response.status})`); + } + const body = (await response.json()) as { app_urls?: unknown }; + return Array.isArray(body.app_urls) ? body.app_urls : []; + } +} diff --git a/packages/core/src/product-view/schemas.ts b/packages/core/src/product-view/schemas.ts new file mode 100644 index 0000000000..61e9717c01 --- /dev/null +++ b/packages/core/src/product-view/schemas.ts @@ -0,0 +1,50 @@ +import { z } from "zod"; + +/** Renderer slot rect, window-content-relative CSS pixels. */ +export const embeddedBrowserBoundsSchema = z.object({ + x: z.number(), + y: z.number(), + width: z.number().min(0), + height: z.number().min(0), +}); +export type EmbeddedBrowserBoundsInput = z.infer< + typeof embeddedBrowserBoundsSchema +>; + +export const productViewPageStateSchema = z.object({ + viewId: z.string(), + url: z.string(), + title: z.string(), + canGoBack: z.boolean(), + canGoForward: z.boolean(), + isLoading: z.boolean(), +}); + +export const openProductViewInput = z.object({ + viewId: z.string(), + url: z.string(), + bounds: embeddedBrowserBoundsSchema, +}); + +export const navigateProductViewInput = z.object({ + viewId: z.string(), + url: z.string(), +}); + +export const productViewIdInput = z.object({ viewId: z.string() }); + +export const setProductViewBoundsInput = z.object({ + viewId: z.string(), + bounds: embeddedBrowserBoundsSchema, +}); + +export const setProductViewVisibleInput = z.object({ + viewId: z.string(), + visible: z.boolean(), +}); + +export const productUrlSuggestionSchema = z.object({ + url: z.string(), + source: z.enum(["app_urls", "pageview_hosts"]), + eventCount: z.number().optional(), +}); diff --git a/packages/host-router/src/router.ts b/packages/host-router/src/router.ts index fe5934f569..e7b796960b 100644 --- a/packages/host-router/src/router.ts +++ b/packages/host-router/src/router.ts @@ -38,6 +38,7 @@ import { onboardingImportRouter } from "./routers/onboarding-import.router"; import { osRouter } from "./routers/os.router"; import { piSessionRouter } from "./routers/pi-session.router"; import { processTrackingRouter } from "./routers/process-tracking.router"; +import { productViewRouter } from "./routers/product-view.router"; import { provisioningRouter } from "./routers/provisioning.router"; import { releaseFeedRouter } from "./routers/release-feed.router"; import { secureStoreRouter } from "./routers/secure-store.router"; @@ -93,6 +94,7 @@ export const hostRouter = router({ os: osRouter, piSession: piSessionRouter, processTracking: processTrackingRouter, + productView: productViewRouter, provisioning: provisioningRouter, secureStore: secureStoreRouter, shell: shellRouter, diff --git a/packages/host-router/src/routers/product-view.router.ts b/packages/host-router/src/routers/product-view.router.ts new file mode 100644 index 0000000000..ff5ce17f09 --- /dev/null +++ b/packages/host-router/src/routers/product-view.router.ts @@ -0,0 +1,112 @@ +import { PRODUCT_VIEW_SERVICE } from "@posthog/core/product-view/identifiers"; +import type { IProductViewService } from "@posthog/core/product-view/productView"; +import { + navigateProductViewInput, + openProductViewInput, + productUrlSuggestionSchema, + productViewIdInput, + productViewPageStateSchema, + setProductViewBoundsInput, + setProductViewVisibleInput, +} from "@posthog/core/product-view/schemas"; +import type { ServiceResolver } from "@posthog/host-trpc/context"; +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import { PRODUCT_ENVIRONMENTS_SERVICE } from "@posthog/workspace-server/di/tokens"; +import { + listProductEnvironmentsInput, + productEnvironmentSchema, + removeProductEnvironmentInput, + saveProductEnvironmentInput, + touchProductEnvironmentInput, +} from "@posthog/workspace-server/services/product-view/schemas"; +import type { IProductEnvironmentsService } from "@posthog/workspace-server/services/product-view/service"; +import { z } from "zod"; + +const view = (container: ServiceResolver) => + container.get(PRODUCT_VIEW_SERVICE); +const envs = (container: ServiceResolver) => + container.get(PRODUCT_ENVIRONMENTS_SERVICE); + +export const productViewRouter = router({ + // ── Environments: which sites this project's Product tab can browse ── + listEnvironments: publicProcedure + .input(listProductEnvironmentsInput) + .output(z.array(productEnvironmentSchema)) + .query(({ ctx, input }) => envs(ctx.container).list(input.projectId)), + + saveEnvironment: publicProcedure + .input(saveProductEnvironmentInput) + .output(productEnvironmentSchema) + .mutation(({ ctx, input }) => envs(ctx.container).save(input)), + + removeEnvironment: publicProcedure + .input(removeProductEnvironmentInput) + .mutation(({ ctx, input }) => envs(ctx.container).remove(input.id)), + + touchEnvironment: publicProcedure + .input(touchProductEnvironmentInput) + .output(productEnvironmentSchema.nullable()) + .mutation(({ ctx, input }) => + envs(ctx.container).touch(input.id, input.currentUrl), + ), + + // ── The embedded browser view ── + open: publicProcedure + .input(openProductViewInput) + .mutation(({ ctx, input }) => view(ctx.container).open(input)), + + navigate: publicProcedure + .input(navigateProductViewInput) + .mutation(({ ctx, input }) => + view(ctx.container).navigate(input.viewId, input.url), + ), + + goBack: publicProcedure + .input(productViewIdInput) + .mutation(({ ctx, input }) => view(ctx.container).goBack(input.viewId)), + + goForward: publicProcedure + .input(productViewIdInput) + .mutation(({ ctx, input }) => view(ctx.container).goForward(input.viewId)), + + reload: publicProcedure + .input(productViewIdInput) + .mutation(({ ctx, input }) => view(ctx.container).reload(input.viewId)), + + setBounds: publicProcedure + .input(setProductViewBoundsInput) + .mutation(({ ctx, input }) => + view(ctx.container).setBounds(input.viewId, input.bounds), + ), + + setVisible: publicProcedure + .input(setProductViewVisibleInput) + .mutation(({ ctx, input }) => + view(ctx.container).setVisible(input.viewId, input.visible), + ), + + setInspectMode: publicProcedure + .input(z.object({ viewId: z.string(), enabled: z.boolean() })) + .mutation(({ ctx, input }) => + view(ctx.container).setInspectMode(input.viewId, input.enabled), + ), + + destroy: publicProcedure + .input(productViewIdInput) + .mutation(({ ctx, input }) => view(ctx.container).destroy(input.viewId)), + + getPageState: publicProcedure + .input(productViewIdInput) + .output(productViewPageStateSchema.nullable()) + .query(({ ctx, input }) => view(ctx.container).getPageState(input.viewId)), + + suggestUrls: publicProcedure + .output(z.array(productUrlSuggestionSchema)) + .query(({ ctx }) => view(ctx.container).suggestProductUrls()), + + onEvents: publicProcedure.subscription(async function* (opts) { + for await (const event of view(opts.ctx.container).events(opts.signal)) { + yield event; + } + }), +}); diff --git a/packages/platform/package.json b/packages/platform/package.json index d59ff4b010..85bbcf3db8 100644 --- a/packages/platform/package.json +++ b/packages/platform/package.json @@ -8,6 +8,10 @@ "types": "./dist/host-capabilities.d.ts", "import": "./dist/host-capabilities.js" }, + "./embedded-browser": { + "types": "./dist/embedded-browser.d.ts", + "import": "./dist/embedded-browser.js" + }, "./url-launcher": { "types": "./dist/url-launcher.d.ts", "import": "./dist/url-launcher.js" diff --git a/packages/platform/src/embedded-browser.ts b/packages/platform/src/embedded-browser.ts new file mode 100644 index 0000000000..97b5bd8440 --- /dev/null +++ b/packages/platform/src/embedded-browser.ts @@ -0,0 +1,121 @@ +/** + * A host-provided browser surface embedded inside the app window, used by the + * Product View to show the user's own product (live site or localhost dev + * server) beneath an analytics overlay. The desktop host implements it with an + * Electron `WebContentsView` owned by the main process; the renderer only + * drives bounds/visibility/navigation and consumes the event stream. + * + * Everything that crosses this interface is display-ready data. PostHog + * credentials never pass through it in either direction: analytics queries run + * host-side and only shaped overlay payloads go down; element descriptors + * reported by the page come up untrusted and are validated by the consumer. + */ + +export interface EmbeddedBrowserBounds { + /** Window-content-relative CSS pixels (the renderer slot's client rect). */ + x: number; + y: number; + width: number; + height: number; +} + +export interface EmbeddedBrowserPageState { + viewId: string; + url: string; + title: string; + canGoBack: boolean; + canGoForward: boolean; + isLoading: boolean; +} + +/** + * An interactive element the in-page inspector reported. All string fields are + * length-capped by the preload; consumers still treat them as untrusted input. + */ +export interface EmbeddedBrowserElement { + /** Stable hash of the element's descriptor, the overlay's join key. */ + selectorHash: string; + tag: string; + /** `data-attr` value, PostHog's preferred stable selector. */ + dataAttr: string | null; + id: string | null; + classes: string[]; + href: string | null; + /** Trimmed visible text (capped). */ + text: string | null; + /** nth-child path fallback used when nothing stronger identifies it. */ + nthChildPath: string; +} + +/** Per-element visual instruction pushed down to the in-page overlay. */ +export interface EmbeddedBrowserOverlayItem { + selectorHash: string; + halo: "green" | "amber" | "red"; + /** Compact badge text shown in inspect mode (e.g. "12.4k · 0.8%"). */ + label: string | null; +} + +export interface EmbeddedBrowserOverlayPayload { + viewId: string; + items: EmbeddedBrowserOverlayItem[]; +} + +/** A network request observed on the embedded page (CDP capture). */ +export interface EmbeddedBrowserNetworkSample { + viewId: string; + url: string; + method: string; + status: number | null; + /** Milliseconds from request start to response end, when known. */ + durationMs: number | null; + /** W3C trace id extracted from the `traceparent` request header, if any. */ + traceId: string | null; + /** selectorHash of the interaction this request was attributed to, if any. */ + interactionSelectorHash: string | null; + timestamp: number; +} + +export type EmbeddedBrowserEvent = + | { type: "page-state"; state: EmbeddedBrowserPageState } + | { + type: "elements-reported"; + viewId: string; + pageUrl: string; + elements: EmbeddedBrowserElement[]; + } + | { + type: "element-selected"; + viewId: string; + pageUrl: string; + element: EmbeddedBrowserElement; + } + | { type: "network-sample"; sample: EmbeddedBrowserNetworkSample } + | { type: "view-destroyed"; viewId: string }; + +export interface EmbeddedBrowserCreateOptions { + viewId: string; + url: string; + bounds: EmbeddedBrowserBounds; +} + +export interface IEmbeddedBrowser { + /** Create (or navigate an existing) view and attach it to the app window. */ + create(options: EmbeddedBrowserCreateOptions): Promise; + navigate(viewId: string, url: string): Promise; + goBack(viewId: string): void; + goForward(viewId: string): void; + reload(viewId: string): void; + setBounds(viewId: string, bounds: EmbeddedBrowserBounds): void; + setVisible(viewId: string, visible: boolean): void; + destroy(viewId: string): Promise; + /** Current page state, or null when the view doesn't exist. */ + getPageState(viewId: string): EmbeddedBrowserPageState | null; + /** Toggle the in-page inspector's hover/select mode. */ + setInspectMode(viewId: string, enabled: boolean): void; + /** Push display-ready overlay data into the in-page renderer. */ + pushOverlayData(payload: EmbeddedBrowserOverlayPayload): void; + /** One shared event stream; consumers filter by viewId. */ + events(signal?: AbortSignal): AsyncIterable; +} + +export const EMBEDDED_BROWSER = Symbol.for("posthog.platform.embeddedBrowser"); diff --git a/packages/platform/src/host-capabilities.ts b/packages/platform/src/host-capabilities.ts index a65a05bb9f..02a211aa88 100644 --- a/packages/platform/src/host-capabilities.ts +++ b/packages/platform/src/host-capabilities.ts @@ -11,6 +11,13 @@ export interface HostCapabilities { * (connected-GitHub-org) repositories and cloud workspaces. */ readonly localWorkspaces: boolean; + /** + * Whether the host can embed a live browser view inside the app window + * (the Product View surface). Desktop (Electron) attaches a + * `WebContentsView`; the web host cannot embed arbitrary cross-origin + * sites, so the UI hides the Product entry points there. + */ + readonly embeddedBrowser: boolean; } export const HOST_CAPABILITIES = Symbol.for( diff --git a/packages/platform/tsup.config.ts b/packages/platform/tsup.config.ts index 2caee254ae..b1eddf7631 100644 --- a/packages/platform/tsup.config.ts +++ b/packages/platform/tsup.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from "tsup"; export default defineConfig({ entry: [ "src/host-capabilities.ts", + "src/embedded-browser.ts", "src/url-launcher.ts", "src/storage-paths.ts", "src/app-meta.ts", diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index eea1430b6f..0bb92e25a1 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -57,6 +57,7 @@ export type CommandMenuAction = | "open-command-center" | "open-inbox" | "open-loops" + | "open-product-view" | "open-usage" | "search-files" | "open-file" diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 56ce3353c3..e3b2eea69b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -65,6 +65,10 @@ export { type WindowBounds, windowBoundsSchema, } from "./browser-tabs-schemas"; +export { + type ProductEnvironment, + productEnvironmentSchema, +} from "./product-view-schemas"; export type { CloudRunSource, PrAuthorshipMode } from "./cloud"; export { CLOUD_PROMPT_PREFIX, diff --git a/packages/shared/src/product-view-schemas.ts b/packages/shared/src/product-view-schemas.ts new file mode 100644 index 0000000000..cb4d219c63 --- /dev/null +++ b/packages/shared/src/product-view-schemas.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; + +/** + * A Product View environment: which site the Product tab browses and which + * PostHog project's analytics overlay onto it. Page source and data source are + * decoupled so a localhost checkout can render production analytics. Persisted + * per PostHog project by workspace-server; rendered by the UI — hence the + * shape lives here, host-neutral. + */ +export const productEnvironmentSchema = z.object({ + id: z.string(), + projectId: z.number(), + label: z.string(), + /** Bare http(s) origin the embedded browser opens. */ + pageOrigin: z.string(), + /** PostHog project whose analytics overlay onto the page. */ + dataProjectId: z.number(), + /** Last URL visited in this environment, for tab restore. */ + currentUrl: z.string().nullable(), + createdAt: z.number(), + lastActiveAt: z.number(), +}); +export type ProductEnvironment = z.infer; diff --git a/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx b/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx index ebf71c7ceb..b181b281f2 100644 --- a/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx +++ b/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx @@ -1,5 +1,6 @@ import { BrainIcon, + GlobeIcon, PlugsConnectedIcon, RobotIcon, SquaresFourIcon, @@ -125,7 +126,13 @@ type TabRef = { // The top-level app pages that can be a tab. Keyed by useAppView's view.type; // each maps to its canonical route (a task/canvas/channel tab has its own // route, these don't) plus the strip's label + icon. -type AppView = "inbox" | "agents" | "skills" | "mcp-servers" | "command-center"; +type AppView = + | "inbox" + | "agents" + | "skills" + | "mcp-servers" + | "command-center" + | "product"; const APP_VIEW_META: Record = { inbox: { label: "Inbox", icon: }, @@ -139,6 +146,7 @@ const APP_VIEW_META: Record = { label: "Command center", icon: , }, + product: { label: "Product", icon: }, }; function isAppView(value: string): value is AppView { @@ -614,6 +622,9 @@ export function BrowserTabStrip() { case "command-center": navigate({ to: "/command-center", state }); break; + case "product": + navigate({ to: "/product", state }); + break; default: { // Exhaustiveness guard: a new AppView value fails to compile here // until its canonical route is wired above — so the tab-target set diff --git a/packages/ui/src/features/command/CommandMenu.tsx b/packages/ui/src/features/command/CommandMenu.tsx index cba2fe82bf..fecc3c7dea 100644 --- a/packages/ui/src/features/command/CommandMenu.tsx +++ b/packages/ui/src/features/command/CommandMenu.tsx @@ -64,6 +64,7 @@ import { navigateToCommandCenter, navigateToInbox, navigateToLoops, + navigateToProductView, } from "@posthog/ui/router/navigationBridge"; import { useAppView } from "@posthog/ui/router/useAppView"; import { openTask, openTaskInput } from "@posthog/ui/router/useOpenTask"; @@ -74,6 +75,7 @@ import { DesktopIcon, FileTextIcon, GearIcon, + GlobeIcon, HomeIcon, LightningBoltIcon, MagnifyingGlassIcon, @@ -285,6 +287,17 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { navigateToCommandCenter(); }, }, + { + id: "product-view", + label: "Product", + keywords: "browser site website live overlay analytics product view", + icon: , + action: "open-product-view", + onRun: () => { + closeSettingsDialog(); + navigateToProductView(); + }, + }, ...(loopsEnabled ? [ { diff --git a/packages/ui/src/features/product-view/ProductEnvironmentPicker.tsx b/packages/ui/src/features/product-view/ProductEnvironmentPicker.tsx new file mode 100644 index 0000000000..8422cf5300 --- /dev/null +++ b/packages/ui/src/features/product-view/ProductEnvironmentPicker.tsx @@ -0,0 +1,134 @@ +import { GlobeIcon, MonitorIcon, PlusIcon } from "@phosphor-icons/react"; +import { + Button, + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, + Input, +} from "@posthog/quill"; +import type { ProductEnvironment } from "@posthog/shared"; +import { useState } from "react"; +import { + useProductUrlSuggestions, + useSaveProductEnvironment, +} from "./useProductEnvironments"; + +function labelForSuggestion(url: string): string { + try { + const host = new URL(url).hostname; + return host === "localhost" || host === "127.0.0.1" ? "Local dev" : host; + } catch { + return url; + } +} + +function compactCount(value: number): string { + return Intl.NumberFormat("en", { notation: "compact" }).format(value); +} + +/** + * First-run (and add-environment) screen for the Product tab: pick where your + * product lives. Suggestions come from the project's own PostHog data — its + * toolbar app URLs and the hosts $pageview traffic reports — plus a manual + * field for anything else (e.g. http://localhost:8010 from `hogli start`). + */ +export function ProductEnvironmentPicker(props: { + projectId: number; + onCreated: (environment: ProductEnvironment) => void; +}) { + const { projectId, onCreated } = props; + const { data: suggestions, isLoading } = useProductUrlSuggestions(true); + const save = useSaveProductEnvironment(); + const [customUrl, setCustomUrl] = useState(""); + const [error, setError] = useState(null); + + const create = (url: string, label: string) => { + setError(null); + save.mutate( + { projectId, label, pageOrigin: url, dataProjectId: projectId }, + { + onSuccess: onCreated, + onError: (e) => setError(e.message), + }, + ); + }; + + return ( + + + + + + Open your product + + Browse your live site — or a local checkout — with PostHog data + overlaid on it. Pick where your product lives; analytics come from + this project either way. + + + +
+ {isLoading && ( + + Looking at your project's traffic… + + )} + {(suggestions ?? []).map((s) => ( + + ))} +
{ + e.preventDefault(); + if (customUrl.trim()) { + create(customUrl.trim(), labelForSuggestion(customUrl.trim())); + } + }} + > + setCustomUrl(e.target.value)} + placeholder="https://your-product.com or http://localhost:8010" + aria-label="Product URL" + /> + +
+ {error && {error}} +
+
+
+ ); +} diff --git a/packages/ui/src/features/product-view/ProductView.tsx b/packages/ui/src/features/product-view/ProductView.tsx new file mode 100644 index 0000000000..3cf30f5291 --- /dev/null +++ b/packages/ui/src/features/product-view/ProductView.tsx @@ -0,0 +1,253 @@ +import { + ArrowClockwiseIcon, + ArrowLeftIcon, + ArrowRightIcon, + CaretDownIcon, + GlobeIcon, + MonitorIcon, + PlusIcon, + TrashIcon, +} from "@phosphor-icons/react"; +import { useHostTRPC } from "@posthog/host-router/react"; +import { + Badge, + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, + Input, +} from "@posthog/quill"; +import type { ProductEnvironment } from "@posthog/shared"; +import { useProjects } from "@posthog/ui/features/projects/useProjects"; +import { useHostCapabilities } from "@posthog/ui/shell/useHostCapabilities"; +import { useMutation } from "@tanstack/react-query"; +import { useEffect, useMemo, useState } from "react"; +import { ProductEnvironmentPicker } from "./ProductEnvironmentPicker"; +import { + useProductEnvironments, + useRemoveProductEnvironment, +} from "./useProductEnvironments"; +import { useProductViewPageState, useProductViewSlot } from "./useProductView"; + +function isLocalOrigin(origin: string): boolean { + return origin.includes("//localhost") || origin.includes("//127.0.0.1"); +} + +/** + * The Product tab: a live embedded browser showing the user's product with + * PostHog context around it. The page itself is painted by a host-owned + * native view glued to the slot div; this component renders the chrome + * (toolbar, environment switcher) and drives the view over tRPC. + */ +export function ProductView() { + const capabilities = useHostCapabilities(); + const { currentProject, currentProjectId } = useProjects(); + const projectId = + typeof currentProjectId === "number" ? currentProjectId : null; + const { data: environments, isLoading } = useProductEnvironments(projectId); + const [activeEnvId, setActiveEnvId] = useState(null); + const [adding, setAdding] = useState(false); + + const activeEnv = useMemo(() => { + if (!environments?.length) return null; + return environments.find((e) => e.id === activeEnvId) ?? environments[0]; + }, [environments, activeEnvId]); + + if (!capabilities.embeddedBrowser) { + return ( + + + + + + Product view isn't available here + + This host can't embed a live browser. Open PostHog Code on desktop + to browse your product with data overlaid. + + + + ); + } + + if (projectId == null || isLoading) return null; + + if (!activeEnv || adding) { + return ( + { + setActiveEnvId(env.id); + setAdding(false); + }} + /> + ); + } + + return ( + setAdding(true)} + /> + ); +} + +function ProductBrowser(props: { + environment: ProductEnvironment; + environments: ProductEnvironment[]; + dataProjectName: string; + onSwitchEnvironment: (id: string) => void; + onAddEnvironment: () => void; +}) { + const { + environment, + environments, + dataProjectName, + onSwitchEnvironment, + onAddEnvironment, + } = props; + const trpc = useHostTRPC(); + const viewId = `product-${environment.id}`; + const initialUrl = environment.currentUrl ?? environment.pageOrigin; + + const slotRef = useProductViewSlot({ viewId, url: initialUrl }); + const pageState = useProductViewPageState(viewId); + + const navigate = useMutation(trpc.productView.navigate.mutationOptions()); + const goBack = useMutation(trpc.productView.goBack.mutationOptions()); + const goForward = useMutation(trpc.productView.goForward.mutationOptions()); + const reload = useMutation(trpc.productView.reload.mutationOptions()); + const touch = useMutation( + trpc.productView.touchEnvironment.mutationOptions(), + ); + const removeEnvironment = useRemoveProductEnvironment(); + + const [draftUrl, setDraftUrl] = useState(null); + const currentUrl = pageState?.url || initialUrl; + + // Remember where this environment is parked (debounced) so the tab restores + // to the same page next launch. + const touchMutate = touch.mutate; + useEffect(() => { + if (!pageState?.url) return; + const handle = setTimeout(() => { + touchMutate({ id: environment.id, currentUrl: pageState.url }); + }, 1000); + return () => clearTimeout(handle); + }, [pageState?.url, environment.id, touchMutate]); + + const submitUrl = () => { + if (draftUrl == null) return; + let url = draftUrl.trim(); + if (url && !/^https?:\/\//.test(url)) url = `https://${url}`; + setDraftUrl(null); + if (url && url !== currentUrl) navigate.mutate({ viewId, url }); + }; + + return ( +
+
+ + + + setDraftUrl(e.target.value)} + onFocus={(e) => e.target.select()} + onBlur={() => setDraftUrl(null)} + onKeyDown={(e) => { + if (e.key === "Enter") submitUrl(); + if (e.key === "Escape") setDraftUrl(null); + }} + aria-label="Page URL" + spellCheck={false} + /> + + + {isLocalOrigin(environment.pageOrigin) ? ( + + ) : ( + + )} + {environment.label} + + + } + /> + + {environments.map((env) => ( + onSwitchEnvironment(env.id)} + > + {isLocalOrigin(env.pageOrigin) ? ( + + ) : ( + + )} + {env.label} + + {env.pageOrigin.replace(/^https?:\/\//, "")} + + + ))} + + + + Add environment… + + removeEnvironment.mutate({ id: environment.id })} + > + + Remove this environment + + + + + Data: {dataProjectName} + +
+ {/* The native browser view is glued to this slot's rect by the host. */} +
+
+ ); +} diff --git a/packages/ui/src/features/product-view/useProductEnvironments.ts b/packages/ui/src/features/product-view/useProductEnvironments.ts new file mode 100644 index 0000000000..efcf17100c --- /dev/null +++ b/packages/ui/src/features/product-view/useProductEnvironments.ts @@ -0,0 +1,52 @@ +import { useHostTRPC } from "@posthog/host-router/react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +/** The saved product environments for a PostHog project (most recent first). */ +export function useProductEnvironments(projectId: number | null) { + const trpc = useHostTRPC(); + return useQuery( + trpc.productView.listEnvironments.queryOptions( + { projectId: projectId ?? -1 }, + { enabled: projectId != null }, + ), + ); +} + +export function useSaveProductEnvironment() { + const trpc = useHostTRPC(); + const queryClient = useQueryClient(); + return useMutation( + trpc.productView.saveEnvironment.mutationOptions({ + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: trpc.productView.listEnvironments.queryKey(), + }); + }, + }), + ); +} + +export function useRemoveProductEnvironment() { + const trpc = useHostTRPC(); + const queryClient = useQueryClient(); + return useMutation( + trpc.productView.removeEnvironment.mutationOptions({ + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: trpc.productView.listEnvironments.queryKey(), + }); + }, + }), + ); +} + +/** Candidate product URLs derived from the project's own PostHog data. */ +export function useProductUrlSuggestions(enabled: boolean) { + const trpc = useHostTRPC(); + return useQuery( + trpc.productView.suggestUrls.queryOptions(undefined, { + enabled, + staleTime: 5 * 60 * 1000, + }), + ); +} diff --git a/packages/ui/src/features/product-view/useProductView.ts b/packages/ui/src/features/product-view/useProductView.ts new file mode 100644 index 0000000000..9df6edb91f --- /dev/null +++ b/packages/ui/src/features/product-view/useProductView.ts @@ -0,0 +1,124 @@ +import { useHostTRPC } from "@posthog/host-router/react"; +import { useCommandMenuStore } from "@posthog/ui/shell/commandMenuStore"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useSubscription } from "@trpc/tanstack-react-query"; +import { useEffect, useRef, useState } from "react"; + +export interface ProductViewPageState { + viewId: string; + url: string; + title: string; + canGoBack: boolean; + canGoForward: boolean; + isLoading: boolean; +} + +/** Live page state of one embedded view, seeded by query, kept fresh by the + * host event stream. */ +export function useProductViewPageState(viewId: string) { + const trpc = useHostTRPC(); + const [pageState, setPageState] = useState(null); + + const { data: initial } = useQuery( + trpc.productView.getPageState.queryOptions({ viewId }), + ); + useEffect(() => { + if (initial) setPageState((prev) => prev ?? initial); + }, [initial]); + + useSubscription( + trpc.productView.onEvents.subscriptionOptions(undefined, { + onData: (event) => { + if (event.type === "page-state" && event.state.viewId === viewId) { + setPageState(event.state); + } + }, + }), + ); + + return pageState; +} + +/** + * Own the embedded view behind a slot element: open it once the slot has real + * bounds, keep the native view glued to the slot's rect, and hide it while the + * command menu is over it or the slot is unmounted. The native view paints + * ABOVE the renderer, so visibility must be driven from here — nothing in the + * DOM can cover it. + */ +export function useProductViewSlot(input: { viewId: string; url: string }) { + const { viewId, url } = input; + const trpc = useHostTRPC(); + const slotRef = useRef(null); + const openedRef = useRef(false); + + const open = useMutation(trpc.productView.open.mutationOptions()); + const setBounds = useMutation(trpc.productView.setBounds.mutationOptions()); + const setVisible = useMutation(trpc.productView.setVisible.mutationOptions()); + const commandMenuOpen = useCommandMenuStore((s) => s.isOpen); + + const openMutate = open.mutateAsync; + const setBoundsMutate = setBounds.mutate; + const setVisibleMutate = setVisible.mutate; + + useEffect(() => { + const slot = slotRef.current; + if (!slot) return; + + let frame: number | null = null; + let lastRect = ""; + + const report = () => { + frame = null; + const rect = slot.getBoundingClientRect(); + const bounds = { + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.round(rect.width), + height: Math.round(rect.height), + }; + if (bounds.width === 0 || bounds.height === 0) return; + const key = JSON.stringify(bounds); + if (key === lastRect) return; + lastRect = key; + if (!openedRef.current) { + openedRef.current = true; + void openMutate({ viewId, url, bounds }).catch(() => { + openedRef.current = false; + }); + } else { + setBoundsMutate({ viewId, bounds }); + } + }; + const schedule = () => { + if (frame == null) frame = requestAnimationFrame(report); + }; + + schedule(); + const observer = new ResizeObserver(schedule); + observer.observe(slot); + // Layout shifts that move the slot without resizing it (sidebar toggle, + // panel collapse) resize an ancestor — observing the body catches them. + observer.observe(document.body); + window.addEventListener("resize", schedule); + + return () => { + observer.disconnect(); + window.removeEventListener("resize", schedule); + if (frame != null) cancelAnimationFrame(frame); + // Keep the view (and the page in it) alive across tab switches; just + // stop painting over whatever replaces this slot. + setVisibleMutate({ viewId, visible: false }); + openedRef.current = false; + }; + }, [viewId, url, openMutate, setBoundsMutate, setVisibleMutate]); + + // The command menu is a renderer overlay and cannot paint over the native + // view — hide the view while the menu is open. + useEffect(() => { + if (!openedRef.current) return; + setVisibleMutate({ viewId, visible: !commandMenuOpen }); + }, [commandMenuOpen, viewId, setVisibleMutate]); + + return slotRef; +} diff --git a/packages/ui/src/router/navigationBridge.ts b/packages/ui/src/router/navigationBridge.ts index 4360eda430..f3c0c7bc8f 100644 --- a/packages/ui/src/router/navigationBridge.ts +++ b/packages/ui/src/router/navigationBridge.ts @@ -203,6 +203,10 @@ export function navigateToMcpServers(): void { void getRouterOrNull()?.navigate({ to: "/mcp-servers" }); } +export function navigateToProductView(): void { + void getRouterOrNull()?.navigate({ to: "/product" }); +} + // Channels-space mirrors. These render the same shared views as their /code (or // top-level) counterparts but under /website, so navigating from the channels // sidebar keeps the channels chrome instead of switching back to Code. The diff --git a/packages/ui/src/router/routeTree.gen.ts b/packages/ui/src/router/routeTree.gen.ts index 98bd7a2d28..783bd9dc0a 100644 --- a/packages/ui/src/router/routeTree.gen.ts +++ b/packages/ui/src/router/routeTree.gen.ts @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as WebsiteRouteImport } from './routes/website' import { Route as UsageRouteImport } from './routes/usage' import { Route as SkillsRouteImport } from './routes/skills' +import { Route as ProductRouteImport } from './routes/product' import { Route as McpServersRouteImport } from './routes/mcp-servers' import { Route as CommandCenterRouteImport } from './routes/command-center' import { Route as IndexRouteImport } from './routes/index' @@ -95,6 +96,11 @@ const SkillsRoute = SkillsRouteImport.update({ path: '/skills', getParentRoute: () => rootRouteImport, } as any) +const ProductRoute = ProductRouteImport.update({ + id: '/product', + path: '/product', + getParentRoute: () => rootRouteImport, +} as any) const McpServersRoute = McpServersRouteImport.update({ id: '/mcp-servers', path: '/mcp-servers', @@ -457,6 +463,7 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/command-center': typeof CommandCenterRoute '/mcp-servers': typeof McpServersRoute + '/product': typeof ProductRoute '/skills': typeof SkillsRoute '/usage': typeof UsageRoute '/website': typeof WebsiteRouteWithChildren @@ -529,6 +536,7 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/command-center': typeof CommandCenterRoute '/mcp-servers': typeof McpServersRoute + '/product': typeof ProductRoute '/skills': typeof SkillsRoute '/usage': typeof UsageRoute '/code/archived': typeof CodeArchivedRoute @@ -590,6 +598,7 @@ export interface FileRoutesById { '/': typeof IndexRoute '/command-center': typeof CommandCenterRoute '/mcp-servers': typeof McpServersRoute + '/product': typeof ProductRoute '/skills': typeof SkillsRoute '/usage': typeof UsageRoute '/website': typeof WebsiteRouteWithChildren @@ -664,6 +673,7 @@ export interface FileRouteTypes { | '/' | '/command-center' | '/mcp-servers' + | '/product' | '/skills' | '/usage' | '/website' @@ -736,6 +746,7 @@ export interface FileRouteTypes { | '/' | '/command-center' | '/mcp-servers' + | '/product' | '/skills' | '/usage' | '/code/archived' @@ -796,6 +807,7 @@ export interface FileRouteTypes { | '/' | '/command-center' | '/mcp-servers' + | '/product' | '/skills' | '/usage' | '/website' @@ -869,6 +881,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute CommandCenterRoute: typeof CommandCenterRoute McpServersRoute: typeof McpServersRoute + ProductRoute: typeof ProductRoute SkillsRoute: typeof SkillsRoute UsageRoute: typeof UsageRoute WebsiteRoute: typeof WebsiteRouteWithChildren @@ -910,6 +923,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SkillsRouteImport parentRoute: typeof rootRouteImport } + '/product': { + id: '/product' + path: '/product' + fullPath: '/product' + preLoaderRoute: typeof ProductRouteImport + parentRoute: typeof rootRouteImport + } '/mcp-servers': { id: '/mcp-servers' path: '/mcp-servers' @@ -1621,6 +1641,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, CommandCenterRoute: CommandCenterRoute, McpServersRoute: McpServersRoute, + ProductRoute: ProductRoute, SkillsRoute: SkillsRoute, UsageRoute: UsageRoute, WebsiteRoute: WebsiteRouteWithChildren, diff --git a/packages/ui/src/router/routes/product.tsx b/packages/ui/src/router/routes/product.tsx new file mode 100644 index 0000000000..eecb56b03f --- /dev/null +++ b/packages/ui/src/router/routes/product.tsx @@ -0,0 +1,11 @@ +import { ProductView } from "@posthog/ui/features/product-view/ProductView"; +import { + AppPageSkeleton, + withRouteSkeleton, +} from "@posthog/ui/router/routeSkeletons"; +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/product")({ + component: ProductView, + ...withRouteSkeleton(AppPageSkeleton), +}); diff --git a/packages/ui/src/router/useAppView.ts b/packages/ui/src/router/useAppView.ts index 3b623ccbf0..d6c03a0dda 100644 --- a/packages/ui/src/router/useAppView.ts +++ b/packages/ui/src/router/useAppView.ts @@ -19,6 +19,7 @@ export type AppViewType = | "command-center" | "skills" | "mcp-servers" + | "product" | "settings"; export interface AppView { @@ -80,6 +81,8 @@ function deriveFromMatches(matches: Match[]): AppView { case "/mcp-servers": case "/website/mcp-servers": return { type: "mcp-servers" }; + case "/product": + return { type: "product" }; case "/settings/$category": case "/settings/": return { type: "settings" }; diff --git a/packages/ui/src/shell/useHostCapabilities.ts b/packages/ui/src/shell/useHostCapabilities.ts index 756ad86c6f..3b946a3619 100644 --- a/packages/ui/src/shell/useHostCapabilities.ts +++ b/packages/ui/src/shell/useHostCapabilities.ts @@ -7,7 +7,10 @@ import { // Hosts that predate the token (and Storybook/tests, which have no host binding) // default to the desktop posture, so existing behavior is unchanged unless a host // explicitly opts out. -const DEFAULT_CAPABILITIES: HostCapabilities = { localWorkspaces: true }; +const DEFAULT_CAPABILITIES: HostCapabilities = { + localWorkspaces: true, + embeddedBrowser: true, +}; /** Read the current host's coarse capabilities. Safe when unbound. */ export function useHostCapabilities(): HostCapabilities { diff --git a/packages/workspace-server/src/db/identifiers.ts b/packages/workspace-server/src/db/identifiers.ts index bf60e0335f..3a149077fb 100644 --- a/packages/workspace-server/src/db/identifiers.ts +++ b/packages/workspace-server/src/db/identifiers.ts @@ -36,3 +36,6 @@ export const CLAUDE_SESSION_IMPORT_REPOSITORY = Symbol.for( export const BROWSER_TABS_REPOSITORY = Symbol.for( "posthog.workspace.browserTabsRepository", ); +export const PRODUCT_ENVIRONMENTS_REPOSITORY = Symbol.for( + "posthog.workspace.productEnvironmentsRepository", +); diff --git a/packages/workspace-server/src/db/migrations/0023_product_environments.sql b/packages/workspace-server/src/db/migrations/0023_product_environments.sql new file mode 100644 index 0000000000..d354cc1ebc --- /dev/null +++ b/packages/workspace-server/src/db/migrations/0023_product_environments.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS `product_environments` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` integer NOT NULL, + `label` text NOT NULL, + `page_origin` text NOT NULL, + `data_project_id` integer NOT NULL, + `current_url` text, + `created_at` integer NOT NULL, + `last_active_at` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `product_environments_project_idx` ON `product_environments` (`project_id`); diff --git a/packages/workspace-server/src/db/migrations/meta/_journal.json b/packages/workspace-server/src/db/migrations/meta/_journal.json index c36e3f08f0..b688716993 100644 --- a/packages/workspace-server/src/db/migrations/meta/_journal.json +++ b/packages/workspace-server/src/db/migrations/meta/_journal.json @@ -162,6 +162,13 @@ "when": 1784804000000, "tag": "0022_archive_task_details", "breakpoints": true + }, + { + "idx": 23, + "version": "6", + "when": 1785500000000, + "tag": "0023_product_environments", + "breakpoints": true } ] } diff --git a/packages/workspace-server/src/db/repositories.module.ts b/packages/workspace-server/src/db/repositories.module.ts index 624f54d279..8bd0d55269 100644 --- a/packages/workspace-server/src/db/repositories.module.ts +++ b/packages/workspace-server/src/db/repositories.module.ts @@ -7,6 +7,7 @@ import { BROWSER_TABS_REPOSITORY, CLAUDE_SESSION_IMPORT_REPOSITORY, DEFAULT_ADDITIONAL_DIRECTORY_REPOSITORY, + PRODUCT_ENVIRONMENTS_REPOSITORY, REPOSITORY_REPOSITORY, SUSPENSION_REPOSITORY, TASK_METADATA_REPOSITORY, @@ -20,6 +21,7 @@ import { AutoresearchRunRepository } from "./repositories/autoresearch-run-repos import { BrowserTabsRepository } from "./repositories/browser-tabs-repository"; import { ClaudeSessionImportRepository } from "./repositories/claude-session-import-repository"; import { DefaultAdditionalDirectoryRepository } from "./repositories/default-additional-directory-repository"; +import { ProductEnvironmentsRepository } from "./repositories/product-environments-repository"; import { RepositoryRepository } from "./repositories/repository-repository"; import { SuspensionRepositoryImpl } from "./repositories/suspension-repository"; import { TaskMetadataRepository } from "./repositories/task-metadata-repository"; @@ -47,4 +49,7 @@ export const repositoriesModule = new ContainerModule(({ bind }) => { .to(ClaudeSessionImportRepository) .inSingletonScope(); bind(BROWSER_TABS_REPOSITORY).to(BrowserTabsRepository).inSingletonScope(); + bind(PRODUCT_ENVIRONMENTS_REPOSITORY) + .to(ProductEnvironmentsRepository) + .inSingletonScope(); }); diff --git a/packages/workspace-server/src/db/repositories/product-environments-repository.ts b/packages/workspace-server/src/db/repositories/product-environments-repository.ts new file mode 100644 index 0000000000..1050a696b5 --- /dev/null +++ b/packages/workspace-server/src/db/repositories/product-environments-repository.ts @@ -0,0 +1,79 @@ +import { desc, eq } from "drizzle-orm"; +import { inject, injectable } from "inversify"; +import { DATABASE_SERVICE } from "../identifiers"; +import { productEnvironments } from "../schema"; +import type { DatabaseService } from "../service"; + +export interface ProductEnvironmentRow { + id: string; + projectId: number; + label: string; + pageOrigin: string; + dataProjectId: number; + currentUrl: string | null; + createdAt: number; + lastActiveAt: number; +} + +export interface IProductEnvironmentsRepository { + listByProject(projectId: number): ProductEnvironmentRow[]; + findById(id: string): ProductEnvironmentRow | null; + upsert(row: ProductEnvironmentRow): void; + remove(id: string): void; +} + +@injectable() +export class ProductEnvironmentsRepository + implements IProductEnvironmentsRepository +{ + constructor( + @inject(DATABASE_SERVICE) + private readonly databaseService: DatabaseService, + ) {} + + private get db() { + return this.databaseService.db; + } + + listByProject(projectId: number): ProductEnvironmentRow[] { + return this.db + .select() + .from(productEnvironments) + .where(eq(productEnvironments.projectId, projectId)) + .orderBy(desc(productEnvironments.lastActiveAt)) + .all(); + } + + findById(id: string): ProductEnvironmentRow | null { + const row = this.db + .select() + .from(productEnvironments) + .where(eq(productEnvironments.id, id)) + .get(); + return row ?? null; + } + + upsert(row: ProductEnvironmentRow): void { + this.db + .insert(productEnvironments) + .values(row) + .onConflictDoUpdate({ + target: productEnvironments.id, + set: { + label: row.label, + pageOrigin: row.pageOrigin, + dataProjectId: row.dataProjectId, + currentUrl: row.currentUrl, + lastActiveAt: row.lastActiveAt, + }, + }) + .run(); + } + + remove(id: string): void { + this.db + .delete(productEnvironments) + .where(eq(productEnvironments.id, id)) + .run(); + } +} diff --git a/packages/workspace-server/src/db/schema.ts b/packages/workspace-server/src/db/schema.ts index 6168b2990c..3a9e242441 100644 --- a/packages/workspace-server/src/db/schema.ts +++ b/packages/workspace-server/src/db/schema.ts @@ -263,3 +263,29 @@ export const browserTabs = sqliteTable( }, (t) => [index("browser_tabs_window_idx").on(t.windowId)], ); + +/** + * Product View environments: the sites a PostHog project's Product tab can + * browse (live origin or localhost dev server), each paired with the PostHog + * project whose analytics overlay onto the page. Page source and data source + * are deliberately decoupled — a local checkout can render prod analytics. + */ +export const productEnvironments = sqliteTable( + "product_environments", + { + id: id(), + /** PostHog project this environment belongs to (whose picker lists it). */ + projectId: integer().notNull(), + label: text().notNull(), + /** Bare http(s) origin the embedded browser opens. */ + pageOrigin: text().notNull(), + /** PostHog project whose analytics overlay onto the page. */ + dataProjectId: integer().notNull(), + /** Last URL visited in this environment, for tab restore. */ + currentUrl: text(), + /** Epoch ms. */ + createdAt: integer().notNull(), + lastActiveAt: integer().notNull(), + }, + (t) => [index("product_environments_project_idx").on(t.projectId)], +); diff --git a/packages/workspace-server/src/di/tokens.ts b/packages/workspace-server/src/di/tokens.ts index f7a2ef21f5..06d42531af 100644 --- a/packages/workspace-server/src/di/tokens.ts +++ b/packages/workspace-server/src/di/tokens.ts @@ -17,3 +17,6 @@ export const ENVIRONMENT_SERVICE = Symbol.for( export const BROWSER_TABS_SERVICE = Symbol.for( "posthog.workspace.browser-tabs-service", ); +export const PRODUCT_ENVIRONMENTS_SERVICE = Symbol.for( + "posthog.workspace.product-environments-service", +); diff --git a/packages/workspace-server/src/services/product-view/product-view.module.ts b/packages/workspace-server/src/services/product-view/product-view.module.ts new file mode 100644 index 0000000000..dd64b96000 --- /dev/null +++ b/packages/workspace-server/src/services/product-view/product-view.module.ts @@ -0,0 +1,9 @@ +import { ContainerModule } from "inversify"; +import { PRODUCT_ENVIRONMENTS_SERVICE } from "../../di/tokens"; +import { ProductEnvironmentsService } from "./service"; + +export const productEnvironmentsModule = new ContainerModule(({ bind }) => { + bind(PRODUCT_ENVIRONMENTS_SERVICE) + .to(ProductEnvironmentsService) + .inSingletonScope(); +}); diff --git a/packages/workspace-server/src/services/product-view/schemas.ts b/packages/workspace-server/src/services/product-view/schemas.ts new file mode 100644 index 0000000000..623a424a23 --- /dev/null +++ b/packages/workspace-server/src/services/product-view/schemas.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; + +// The environment record shape lives in @posthog/shared (the UI renders it); +// this module re-exports it beside the service's input schemas. +export { + type ProductEnvironment, + productEnvironmentSchema, +} from "@posthog/shared"; + +export const listProductEnvironmentsInput = z.object({ + projectId: z.number(), +}); + +export const saveProductEnvironmentInput = z.object({ + id: z.string().optional(), + projectId: z.number(), + label: z.string().min(1).max(120), + pageOrigin: z.string().min(1).max(2000), + dataProjectId: z.number(), +}); +export type SaveProductEnvironmentInput = z.infer< + typeof saveProductEnvironmentInput +>; + +export const removeProductEnvironmentInput = z.object({ id: z.string() }); + +export const touchProductEnvironmentInput = z.object({ + id: z.string(), + currentUrl: z.string().max(4000), +}); diff --git a/packages/workspace-server/src/services/product-view/service.test.ts b/packages/workspace-server/src/services/product-view/service.test.ts new file mode 100644 index 0000000000..f408a8cdaa --- /dev/null +++ b/packages/workspace-server/src/services/product-view/service.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { ProductEnvironmentsRepository } from "../../db/repositories/product-environments-repository"; +import type { DatabaseService } from "../../db/service"; +import { createTestDb, type TestDatabase } from "../../db/test-helpers"; +import { ProductEnvironmentsService } from "./service"; + +let testDb: TestDatabase; +let service: ProductEnvironmentsService; + +beforeEach(() => { + testDb = createTestDb(); + const databaseService = { db: testDb.db } as unknown as DatabaseService; + service = new ProductEnvironmentsService( + new ProductEnvironmentsRepository(databaseService), + ); +}); + +afterEach(() => { + testDb.close(); +}); + +const input = ( + overrides: Partial[0]> = {}, +) => ({ + projectId: 2, + label: "Production", + pageOrigin: "https://us.posthog.com", + dataProjectId: 2, + ...overrides, +}); + +describe("ProductEnvironmentsService", () => { + it("saves an environment and lists it for its project", () => { + const saved = service.save(input()); + + expect(saved.id).toBeTruthy(); + expect(saved.currentUrl).toBeNull(); + + const listed = service.list(2); + expect(listed).toHaveLength(1); + expect(listed[0]).toMatchObject({ + projectId: 2, + label: "Production", + pageOrigin: "https://us.posthog.com", + dataProjectId: 2, + }); + }); + + it("scopes list to the requested project", () => { + service.save(input({ projectId: 2 })); + service.save(input({ projectId: 3, label: "Other" })); + + expect(service.list(2)).toHaveLength(1); + expect(service.list(3).map((e) => e.label)).toEqual(["Other"]); + }); + + it("normalizes pageOrigin to a bare origin", () => { + const saved = service.save( + input({ pageOrigin: "https://us.posthog.com/some/path?q=1" }), + ); + expect(saved.pageOrigin).toBe("https://us.posthog.com"); + }); + + it("rejects a non-http(s) pageOrigin", () => { + expect(() => + service.save(input({ pageOrigin: "file:///etc/passwd" })), + ).toThrow(/http/i); + expect(() => service.save(input({ pageOrigin: "not a url" }))).toThrow(); + }); + + it("updates in place when saving with an existing id", () => { + const saved = service.save(input()); + const updated = service.save({ + ...input({ label: "Prod (US)" }), + id: saved.id, + }); + + expect(updated.id).toBe(saved.id); + const listed = service.list(2); + expect(listed).toHaveLength(1); + expect(listed[0].label).toBe("Prod (US)"); + }); + + it("touch records the current URL and bumps lastActiveAt", () => { + const saved = service.save(input()); + + const touched = service.touch(saved.id, "https://us.posthog.com/project/2"); + expect(touched?.currentUrl).toBe("https://us.posthog.com/project/2"); + expect(touched?.lastActiveAt).toBeGreaterThanOrEqual(saved.lastActiveAt); + + expect(service.list(2)[0].currentUrl).toBe( + "https://us.posthog.com/project/2", + ); + }); + + it("touch ignores an unknown id", () => { + expect(service.touch("nope", "https://x.example")).toBeNull(); + }); + + it("remove deletes the environment", () => { + const saved = service.save(input()); + service.remove(saved.id); + expect(service.list(2)).toHaveLength(0); + }); + + it("lists most recently active first", () => { + const a = service.save(input({ label: "A" })); + const b = service.save(input({ label: "B" })); + service.touch(a.id, "https://us.posthog.com/a", 1000); + service.touch(b.id, "https://us.posthog.com/b", 2000); + + expect(service.list(2).map((e) => e.label)).toEqual(["B", "A"]); + }); +}); diff --git a/packages/workspace-server/src/services/product-view/service.ts b/packages/workspace-server/src/services/product-view/service.ts new file mode 100644 index 0000000000..2f56371bbd --- /dev/null +++ b/packages/workspace-server/src/services/product-view/service.ts @@ -0,0 +1,86 @@ +import { inject, injectable } from "inversify"; +import { PRODUCT_ENVIRONMENTS_REPOSITORY } from "../../db/identifiers"; +import type { IProductEnvironmentsRepository } from "../../db/repositories/product-environments-repository"; +import type { + ProductEnvironment, + SaveProductEnvironmentInput, +} from "./schemas"; + +export interface IProductEnvironmentsService { + list(projectId: number): ProductEnvironment[]; + save(input: SaveProductEnvironmentInput): ProductEnvironment; + remove(id: string): void; + touch( + id: string, + currentUrl: string, + now?: number, + ): ProductEnvironment | null; +} + +/** Reject anything but plain web origins — the embedded browser must never be + * pointed at file:, chrome:, or custom schemes by persisted config. */ +function normalizeOrigin(pageOrigin: string): string { + let url: URL; + try { + url = new URL(pageOrigin); + } catch { + throw new Error(`Invalid page origin: ${pageOrigin}`); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("Page origin must be http(s)"); + } + return url.origin; +} + +/** + * Durable per-project registry of Product View environments (which sites the + * Product tab can browse, and whose analytics overlay onto them). + */ +@injectable() +export class ProductEnvironmentsService implements IProductEnvironmentsService { + constructor( + @inject(PRODUCT_ENVIRONMENTS_REPOSITORY) + private readonly repo: IProductEnvironmentsRepository, + ) {} + + list(projectId: number): ProductEnvironment[] { + return this.repo.listByProject(projectId); + } + + save(input: SaveProductEnvironmentInput): ProductEnvironment { + const now = Date.now(); + const existing = input.id ? this.repo.findById(input.id) : null; + const record: ProductEnvironment = { + id: existing?.id ?? input.id ?? crypto.randomUUID(), + projectId: input.projectId, + label: input.label, + pageOrigin: normalizeOrigin(input.pageOrigin), + dataProjectId: input.dataProjectId, + currentUrl: existing?.currentUrl ?? null, + createdAt: existing?.createdAt ?? now, + lastActiveAt: existing?.lastActiveAt ?? now, + }; + this.repo.upsert(record); + return record; + } + + remove(id: string): void { + this.repo.remove(id); + } + + touch( + id: string, + currentUrl: string, + now: number = Date.now(), + ): ProductEnvironment | null { + const existing = this.repo.findById(id); + if (!existing) return null; + const next: ProductEnvironment = { + ...existing, + currentUrl, + lastActiveAt: now, + }; + this.repo.upsert(next); + return next; + } +} From 726f4c789934aea9600133d13e7a6c47dcc397f3 Mon Sep 17 00:00:00 2001 From: Daniel Visca Date: Fri, 31 Jul 2026 11:30:39 -0400 Subject: [PATCH 02/10] feat(product-view): in-page inspector preload + ambient health overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2: the analytics overlay, driven end to end by real PostHog data. - product-view preload (isolated world, closed shadow root): enumerates interactive elements, reports capped descriptors to the host, paints green/amber/red halos + inspect-mode hover/select — no credentials or page access ever cross the boundary - core elementMatching: parses /elements/stats/ rows (fixture captured from the live posthog.com project) and attributes autocapture/rage/dead click counts to live DOM descriptors, keyed data-attr > id > href > tag+text with ambiguity dropping - core healthScore: frustration-share health (rage+dead clicks) with a min-sample guard, and a clutter budget (all unhealthy + top-usage healthy) - ProductViewService overlay pipeline: inspector reports → per-page cached elements/stats fetch against the environment's data project → matched halos pushed back into the page - UI: Inspect toggle; view opens with the environment's dataProjectId Generated-By: PostHog Code Task-Id: 1120fcea-fc07-4c54-8dbb-50b7b712f9cb --- apps/code/electron.vite.config.ts | 13 +- apps/code/src/preload/product-view-preload.ts | 374 ++++++++++++++++++ .../src/product-view/elementMatching.test.ts | 207 ++++++++++ .../core/src/product-view/elementMatching.ts | 178 +++++++++ .../core/src/product-view/healthScore.test.ts | 49 +++ packages/core/src/product-view/healthScore.ts | 61 +++ .../src/product-view/productInsights.test.ts | 34 ++ .../core/src/product-view/productInsights.ts | 33 ++ .../core/src/product-view/productView.test.ts | 96 ++++- packages/core/src/product-view/productView.ts | 136 ++++++- packages/core/src/product-view/schemas.ts | 18 + .../src/features/product-view/ProductView.tsx | 25 +- .../features/product-view/useProductView.ts | 19 +- 13 files changed, 1228 insertions(+), 15 deletions(-) create mode 100644 apps/code/src/preload/product-view-preload.ts create mode 100644 packages/core/src/product-view/elementMatching.test.ts create mode 100644 packages/core/src/product-view/elementMatching.ts create mode 100644 packages/core/src/product-view/healthScore.test.ts create mode 100644 packages/core/src/product-view/healthScore.ts create mode 100644 packages/core/src/product-view/productInsights.test.ts create mode 100644 packages/core/src/product-view/productInsights.ts diff --git a/apps/code/electron.vite.config.ts b/apps/code/electron.vite.config.ts index 16bce4a5c2..70c86f7990 100644 --- a/apps/code/electron.vite.config.ts +++ b/apps/code/electron.vite.config.ts @@ -180,11 +180,18 @@ export default defineConfig(({ mode }) => { emptyOutDir: false, sourcemap: true, rollupOptions: { - input: { preload: path.resolve(__dirname, "src/main/preload.ts") }, + input: { + preload: path.resolve(__dirname, "src/main/preload.ts"), + // Injected into the Product View's embedded WebContentsView (the + // user's own site) — isolated-world inspector + overlay renderer. + productViewPreload: path.resolve( + __dirname, + "src/preload/product-view-preload.ts", + ), + }, output: { format: "cjs", - inlineDynamicImports: true, - entryFileNames: "preload.js", + entryFileNames: "[name].js", chunkFileNames: "[name].js", assetFileNames: "[name].[ext]", }, diff --git a/apps/code/src/preload/product-view-preload.ts b/apps/code/src/preload/product-view-preload.ts new file mode 100644 index 0000000000..5df884f3d1 --- /dev/null +++ b/apps/code/src/preload/product-view-preload.ts @@ -0,0 +1,374 @@ +/** + * Product View in-page inspector + overlay. Injected as the preload of the + * embedded WebContentsView showing the USER'S OWN product, running in the + * isolated world: the page cannot see, call, or spoof anything here (nothing + * is exposed via contextBridge), and nothing secret ever enters this file — + * the host pushes only display-ready overlay items over IPC. + * + * Responsibilities: + * - enumerate interactive elements and report compact descriptors to the host + * (which matches them against PostHog autocapture data) + * - paint element-anchored halos/badges inside a closed shadow root, so the + * overlay tracks scroll/zoom for free and never collides with page CSS + * - inspect mode: hover highlight + click-to-select (reported to the host) + */ +import { ipcRenderer } from "electron"; + +const MAX_ELEMENTS = 300; +const TEXT_CAP = 64; +const ATTR_CAP = 200; +const REPORT_DEBOUNCE_MS = 600; + +const CANDIDATE_SELECTOR = [ + "a[href]", + "button", + "input", + "select", + "textarea", + "[role='button']", + "[data-attr]", +].join(", "); + +interface ElementDescriptor { + selectorHash: string; + tag: string; + dataAttr: string | null; + id: string | null; + classes: string[]; + href: string | null; + text: string | null; + nthChildPath: string; +} + +interface OverlayItem { + selectorHash: string; + halo: "green" | "amber" | "red"; + label: string | null; +} + +const cap = (value: string | null | undefined, max: number): string | null => { + if (!value) return null; + const trimmed = value.replace(/\s+/g, " ").trim(); + if (!trimmed) return null; + return trimmed.length > max ? trimmed.slice(0, max) : trimmed; +}; + +function djb2(input: string): string { + let hash = 5381; + for (let i = 0; i < input.length; i++) { + hash = ((hash << 5) + hash + input.charCodeAt(i)) | 0; + } + return (hash >>> 0).toString(36); +} + +function nthChildPath(element: Element): string { + const parts: string[] = []; + let node: Element | null = element; + for (let depth = 0; node && node !== document.body && depth < 6; depth++) { + const parent: Element | null = node.parentElement; + const index = parent + ? Array.prototype.indexOf.call(parent.children, node) + 1 + : 1; + parts.unshift(`${node.tagName.toLowerCase()}:${index}`); + node = parent; + } + return parts.join(">"); +} + +function describe(element: Element): ElementDescriptor { + const tag = element.tagName.toLowerCase(); + const dataAttr = cap(element.getAttribute("data-attr"), ATTR_CAP); + const id = cap(element.getAttribute("id"), ATTR_CAP); + const classes = Array.from(element.classList).slice(0, 5); + const href = cap(element.getAttribute("href"), ATTR_CAP); + const text = cap(element.textContent, TEXT_CAP); + const path = nthChildPath(element); + const selectorHash = djb2( + [tag, dataAttr ?? "", id ?? "", href ?? "", text ?? "", path].join("|"), + ); + return { + selectorHash, + tag, + dataAttr, + id, + classes, + href, + text, + nthChildPath: path, + }; +} + +// hash → live element; rebuilt on every enumeration so navigations and +// re-renders can't leave the overlay pointing at detached nodes. +let elementsByHash = new Map(); +let overlayItems = new Map(); +let inspectMode = false; + +function isVisible(element: Element): boolean { + const rect = element.getBoundingClientRect(); + return rect.width >= 8 && rect.height >= 8; +} + +function enumerate(): ElementDescriptor[] { + const found = document.querySelectorAll(CANDIDATE_SELECTOR); + const next = new Map(); + const descriptors: ElementDescriptor[] = []; + for (const element of Array.from(found)) { + if (descriptors.length >= MAX_ELEMENTS) break; + if (overlayHost?.contains(element)) continue; + if (!isVisible(element)) continue; + const descriptor = describe(element); + if (next.has(descriptor.selectorHash)) continue; + next.set(descriptor.selectorHash, element); + descriptors.push(descriptor); + } + elementsByHash = next; + return descriptors; +} + +let reportTimer: ReturnType | null = null; +function scheduleReport(): void { + if (reportTimer) clearTimeout(reportTimer); + reportTimer = setTimeout(() => { + reportTimer = null; + const elements = enumerate(); + ipcRenderer.send("product-view:elements-reported", { elements }); + renderOverlay(); + }, REPORT_DEBOUNCE_MS); +} + +// ── Overlay rendering (closed shadow root; page CSS cannot reach in) ── + +let overlayHost: HTMLDivElement | null = null; +let overlayRoot: ShadowRoot | null = null; +let ringsLayer: HTMLDivElement | null = null; +let hoverRing: HTMLDivElement | null = null; + +const OVERLAY_CSS = ` + :host { all: initial; } + .layer { position: fixed; inset: 0; pointer-events: none; z-index: 2147483646; } + .ring { + position: fixed; box-sizing: border-box; pointer-events: none; + border-radius: 6px; border: 2px solid transparent; + } + .ring.green { border-color: rgba(54, 196, 111, 0.75); } + .ring.amber { border-color: rgba(247, 165, 1, 0.9); box-shadow: 0 0 8px rgba(247, 165, 1, 0.35); } + .ring.red { border-color: rgba(245, 78, 0, 0.9); box-shadow: 0 0 10px rgba(245, 78, 0, 0.4); } + .badge { + position: absolute; top: -20px; left: 0; white-space: nowrap; + font: 600 10px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #fff; background: rgba(13, 15, 18, 0.85); border-radius: 4px; + padding: 0 6px; letter-spacing: 0.2px; + } + .hover { + position: fixed; box-sizing: border-box; pointer-events: none; + border: 2px dashed rgba(45, 135, 255, 0.9); border-radius: 6px; + background: rgba(45, 135, 255, 0.08); z-index: 2147483647; display: none; + } +`; + +function ensureOverlay(): void { + if (overlayHost?.isConnected) return; + overlayHost = document.createElement("div"); + overlayHost.setAttribute("data-posthog-product-view", ""); + overlayRoot = overlayHost.attachShadow({ mode: "closed" }); + const sheet = new CSSStyleSheet(); + sheet.replaceSync(OVERLAY_CSS); + overlayRoot.adoptedStyleSheets = [sheet]; + ringsLayer = document.createElement("div"); + ringsLayer.className = "layer"; + hoverRing = document.createElement("div"); + hoverRing.className = "hover"; + overlayRoot.append(ringsLayer, hoverRing); + document.documentElement.appendChild(overlayHost); +} + +let layoutFrame: number | null = null; +function scheduleLayout(): void { + if (layoutFrame != null) return; + layoutFrame = requestAnimationFrame(() => { + layoutFrame = null; + layoutRings(); + }); +} + +const ringNodes = new Map(); + +function renderOverlay(): void { + ensureOverlay(); + if (!ringsLayer) return; + const wanted = new Set(); + for (const [hash, item] of overlayItems) { + const element = elementsByHash.get(hash); + if (!element || !element.isConnected) continue; + wanted.add(hash); + let ring = ringNodes.get(hash); + if (!ring) { + ring = document.createElement("div"); + ringNodes.set(hash, ring); + ringsLayer.appendChild(ring); + } + ring.className = `ring ${item.halo}`; + const label = inspectMode && item.label ? item.label : null; + let badge = ring.firstElementChild as HTMLDivElement | null; + if (label) { + if (!badge) { + badge = document.createElement("div"); + badge.className = "badge"; + ring.appendChild(badge); + } + badge.textContent = label; + } else if (badge) { + badge.remove(); + } + } + for (const [hash, ring] of ringNodes) { + if (!wanted.has(hash)) { + ring.remove(); + ringNodes.delete(hash); + } + } + layoutRings(); +} + +function layoutRings(): void { + for (const [hash, ring] of ringNodes) { + const element = elementsByHash.get(hash); + if (!element || !element.isConnected) { + ring.style.display = "none"; + continue; + } + const rect = element.getBoundingClientRect(); + const visible = + rect.width > 0 && + rect.height > 0 && + rect.bottom > 0 && + rect.top < window.innerHeight; + ring.style.display = visible ? "block" : "none"; + if (!visible) continue; + ring.style.left = `${rect.left - 2}px`; + ring.style.top = `${rect.top - 2}px`; + ring.style.width = `${rect.width + 4}px`; + ring.style.height = `${rect.height + 4}px`; + } +} + +// ── Inspect mode: hover highlight + click-to-select ── + +function candidateFor(target: EventTarget | null): Element | null { + if (!(target instanceof Element)) return null; + if (overlayHost?.contains(target)) return null; + return target.closest(CANDIDATE_SELECTOR); +} + +function onMouseMove(event: MouseEvent): void { + if (!hoverRing) return; + const candidate = candidateFor(event.target); + if (!candidate) { + hoverRing.style.display = "none"; + return; + } + const rect = candidate.getBoundingClientRect(); + hoverRing.style.display = "block"; + hoverRing.style.left = `${rect.left - 2}px`; + hoverRing.style.top = `${rect.top - 2}px`; + hoverRing.style.width = `${rect.width + 4}px`; + hoverRing.style.height = `${rect.height + 4}px`; +} + +function onClick(event: MouseEvent): void { + const candidate = candidateFor(event.target); + if (!candidate) return; + // Inspect-mode clicks select, never activate — ambient mode never touches + // page behaviour (these listeners are only attached while inspecting). + event.preventDefault(); + event.stopImmediatePropagation(); + const descriptor = describe(candidate); + elementsByHash.set(descriptor.selectorHash, candidate); + ipcRenderer.send("product-view:element-selected", { element: descriptor }); +} + +function setInspectMode(enabled: boolean): void { + if (inspectMode === enabled) return; + inspectMode = enabled; + ensureOverlay(); + if (enabled) { + document.addEventListener("mousemove", onMouseMove, true); + document.addEventListener("click", onClick, true); + } else { + document.removeEventListener("mousemove", onMouseMove, true); + document.removeEventListener("click", onClick, true); + if (hoverRing) hoverRing.style.display = "none"; + } + renderOverlay(); +} + +// ── Wiring ── + +ipcRenderer.on("product-view:overlay-data", (_event, payload) => { + const items = Array.isArray(payload?.items) ? payload.items : []; + overlayItems = new Map( + items + .filter( + (item: OverlayItem) => + item && + typeof item.selectorHash === "string" && + (item.halo === "green" || + item.halo === "amber" || + item.halo === "red"), + ) + .map((item: OverlayItem) => [item.selectorHash, item]), + ); + renderOverlay(); +}); + +ipcRenderer.on("product-view:set-inspect-mode", (_event, payload) => { + setInspectMode(Boolean(payload?.enabled)); +}); + +window.addEventListener("scroll", scheduleLayout, { + passive: true, + capture: true, +}); +window.addEventListener("resize", scheduleLayout, { passive: true }); +// Animations/layout changes without scroll events (accordions, carousels). +setInterval(scheduleLayout, 500); + +const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if ( + mutation.target instanceof Element && + overlayHost?.contains(mutation.target) + ) { + continue; + } + scheduleReport(); + return; + } +}); + +function start(): void { + ensureOverlay(); + scheduleReport(); + observer.observe(document.documentElement, { + childList: true, + subtree: true, + }); + // SPA navigations don't fire load events; history changes re-enumerate. + const wrap = (name: "pushState" | "replaceState") => { + const original = history[name].bind(history); + history[name] = ((...args: Parameters) => { + original(...args); + scheduleReport(); + }) as History["pushState"]; + }; + wrap("pushState"); + wrap("replaceState"); + window.addEventListener("popstate", scheduleReport); +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", start, { once: true }); +} else { + start(); +} diff --git a/packages/core/src/product-view/elementMatching.test.ts b/packages/core/src/product-view/elementMatching.test.ts new file mode 100644 index 0000000000..777c198d36 --- /dev/null +++ b/packages/core/src/product-view/elementMatching.test.ts @@ -0,0 +1,207 @@ +import type { EmbeddedBrowserElement } from "@posthog/platform/embedded-browser"; +import { describe, expect, it } from "vitest"; +import { + matchElementStats, + shapeElementStatsResponse, +} from "./elementMatching"; + +// Trimmed from a real GET /api/projects/2/elements/stats/ response for +// posthog.com "/" (chains walk innermost → body; clicks often land on an +// inner span while the meaningful anchor is the next link in the chain). +const realStatsResponse = { + results: [ + { + count: 32413, + hash: "10b33e411ad844f8", + type: "$autocapture", + elements: [ + { + text: "Open PostHog", + tag_name: "span", + attr_class: ["bg-orange", "flex"], + href: "https://us.posthog.com", + attr_id: null, + nth_child: 1, + nth_of_type: 1, + attributes: { attr__class: "flex bg-orange" }, + order: 0, + }, + { + text: "Open PostHog", + tag_name: "a", + attr_class: ["group"], + href: null, + attr_id: null, + nth_child: null, + nth_of_type: 1, + attributes: { attr__href: "https://us.posthog.com" }, + order: 1, + }, + { + text: null, + tag_name: "body", + attr_class: ["light"], + href: null, + attr_id: null, + nth_child: 2, + nth_of_type: 1, + attributes: {}, + order: 11, + }, + ], + }, + { + count: 9020, + hash: "641f46283dc56fb0", + type: "$autocapture", + elements: [ + { + text: "Get started – free", + tag_name: "span", + attr_class: [], + href: "https://app.posthog.com/signup", + attr_id: null, + nth_child: 1, + nth_of_type: 1, + attributes: {}, + order: 0, + }, + ], + }, + { + count: 300, + hash: "ragerow", + type: "$rageclick", + elements: [ + { + text: "Get started – free", + tag_name: "span", + attr_class: [], + href: "https://app.posthog.com/signup", + attr_id: null, + nth_child: 1, + nth_of_type: 1, + attributes: {}, + order: 0, + }, + ], + }, + { + count: 50, + hash: "dataattrrow", + type: "$autocapture", + elements: [ + { + text: "Save", + tag_name: "button", + attr_class: ["primary"], + href: null, + attr_id: null, + nth_child: 3, + nth_of_type: 1, + attributes: { "attr__data-attr": "save-button" }, + order: 0, + }, + ], + }, + ], +}; + +const descriptor = ( + overrides: Partial, +): EmbeddedBrowserElement => ({ + selectorHash: "h1", + tag: "a", + dataAttr: null, + id: null, + classes: [], + href: null, + text: null, + nthChildPath: "", + ...overrides, +}); + +describe("shapeElementStatsResponse", () => { + it("parses rows with count, type, and a normalized chain", () => { + const rows = shapeElementStatsResponse(realStatsResponse); + expect(rows).toHaveLength(4); + expect(rows[0].count).toBe(32413); + expect(rows[0].type).toBe("$autocapture"); + expect(rows[0].chain[0]).toMatchObject({ + tagName: "span", + text: "Open PostHog", + href: "https://us.posthog.com", + }); + // attr__href on a chain link without a top-level href still surfaces. + expect(rows[0].chain[1].href).toBe("https://us.posthog.com"); + // data-attr surfaces from the attributes map. + expect(rows[3].chain[0].dataAttr).toBe("save-button"); + }); + + it("returns empty for malformed payloads", () => { + expect(shapeElementStatsResponse(null)).toEqual([]); + expect(shapeElementStatsResponse({ results: "nope" })).toEqual([]); + expect(shapeElementStatsResponse({ results: [{ count: "x" }] })).toEqual( + [], + ); + }); +}); + +describe("matchElementStats", () => { + const rows = shapeElementStatsResponse(realStatsResponse); + + it("matches an anchor by href even when the click landed on an inner span", () => { + const anchor = descriptor({ + selectorHash: "anchor-us", + tag: "a", + href: "https://us.posthog.com", + text: "Open PostHog", + }); + const stats = matchElementStats([anchor], rows); + expect(stats.get("anchor-us")).toMatchObject({ + clicks: 32413, + rageclicks: 0, + deadclicks: 0, + }); + }); + + it("prefers data-attr over weaker keys and aggregates rage/dead clicks separately", () => { + const save = descriptor({ + selectorHash: "save", + tag: "button", + dataAttr: "save-button", + text: "Different label now", + }); + const signup = descriptor({ + selectorHash: "signup", + tag: "a", + href: "https://app.posthog.com/signup", + }); + const stats = matchElementStats([save, signup], rows); + expect(stats.get("save")?.clicks).toBe(50); + expect(stats.get("signup")).toMatchObject({ + clicks: 9020, + rageclicks: 300, + }); + }); + + it("falls back to tag+text when nothing stronger matches", () => { + const textOnly = descriptor({ + selectorHash: "text-only", + tag: "span", + text: "Get started – free", + }); + const stats = matchElementStats([textOnly], rows); + expect(stats.get("text-only")?.clicks).toBe(9020); + }); + + it("leaves unmatched descriptors absent (no false signal)", () => { + const stranger = descriptor({ + selectorHash: "stranger", + tag: "button", + text: "Never clicked", + }); + const stats = matchElementStats([stranger], rows); + expect(stats.has("stranger")).toBe(false); + }); +}); diff --git a/packages/core/src/product-view/elementMatching.ts b/packages/core/src/product-view/elementMatching.ts new file mode 100644 index 0000000000..ca753b04b5 --- /dev/null +++ b/packages/core/src/product-view/elementMatching.ts @@ -0,0 +1,178 @@ +import type { EmbeddedBrowserElement } from "@posthog/platform/embedded-browser"; + +/** + * Matching live DOM elements (descriptors reported by the in-page inspector) + * against PostHog `elements/stats` rows (parsed autocapture chains). This is + * the overlay's core correctness problem, so it's pure and heavily tested. + * + * A chain walks innermost → root, and clicks often land on decorative inner + * nodes (a span inside the anchor) — so each row is matched against its first + * few chain links, with key strength ordered data-attr > id > href > tag+text. + */ + +export interface ElementStatsChainLink { + tagName: string; + text: string | null; + href: string | null; + attrId: string | null; + dataAttr: string | null; +} + +export type ElementStatsRowType = "$autocapture" | "$rageclick" | "$dead_click"; + +export interface ElementStatsRow { + count: number; + type: ElementStatsRowType; + /** Innermost link first. */ + chain: ElementStatsChainLink[]; +} + +export interface ElementUsageStats { + clicks: number; + rageclicks: number; + deadclicks: number; +} + +/** How deep into a chain a click is still attributed to a live element — + * covers icon/span/inner-div indirection without matching page scaffolding. */ +const CHAIN_MATCH_DEPTH = 3; + +const asString = (value: unknown): string | null => + typeof value === "string" && value.length > 0 ? value : null; + +const normalizeText = (value: string | null): string | null => + value ? value.replace(/\s+/g, " ").trim() || null : null; + +/** Parse the raw `/elements/stats/` JSON into typed rows. Malformed rows are + * dropped wholesale — a partially-parsed chain would mis-attribute counts. */ +export function shapeElementStatsResponse(json: unknown): ElementStatsRow[] { + if (typeof json !== "object" || json === null) return []; + const results = (json as { results?: unknown }).results; + if (!Array.isArray(results)) return []; + + const rows: ElementStatsRow[] = []; + for (const raw of results) { + if (typeof raw !== "object" || raw === null) continue; + const { count, type, elements } = raw as { + count?: unknown; + type?: unknown; + elements?: unknown; + }; + if (typeof count !== "number" || !Array.isArray(elements)) continue; + if ( + type !== "$autocapture" && + type !== "$rageclick" && + type !== "$dead_click" + ) { + continue; + } + const chain: ElementStatsChainLink[] = []; + for (const link of elements) { + if (typeof link !== "object" || link === null) continue; + const l = link as Record; + const attributes = + typeof l.attributes === "object" && l.attributes !== null + ? (l.attributes as Record) + : {}; + const tagName = asString(l.tag_name); + if (!tagName) continue; + chain.push({ + tagName, + text: normalizeText(asString(l.text)), + href: asString(l.href) ?? asString(attributes.attr__href), + attrId: asString(l.attr_id) ?? asString(attributes.attr__id), + dataAttr: asString(attributes["attr__data-attr"]), + }); + } + if (chain.length === 0) continue; + rows.push({ count, type, chain }); + } + return rows; +} + +interface DescriptorIndex { + byDataAttr: Map; + byId: Map; + byHref: Map; + byTagText: Map; +} + +/** Index keys that resolve to exactly one descriptor; ambiguous keys are + * dropped so a shared label can't attribute one row to several elements. */ +function indexDescriptors( + descriptors: EmbeddedBrowserElement[], +): DescriptorIndex { + const build = (key: (d: EmbeddedBrowserElement) => string | null) => { + const map = new Map(); + const ambiguous = new Set(); + for (const d of descriptors) { + const k = key(d); + if (!k) continue; + if (map.has(k) && map.get(k) !== d.selectorHash) ambiguous.add(k); + else map.set(k, d.selectorHash); + } + for (const k of ambiguous) map.delete(k); + return map; + }; + return { + byDataAttr: build((d) => d.dataAttr), + byId: build((d) => d.id), + byHref: build((d) => d.href), + byTagText: build((d) => + d.text ? `${d.tag}|${normalizeText(d.text)}` : null, + ), + }; +} + +function resolveRow( + index: DescriptorIndex, + row: ElementStatsRow, +): string | null { + const depth = Math.min(CHAIN_MATCH_DEPTH, row.chain.length); + // Strongest key across the whole window beats a weaker key found earlier — + // the innermost link is often a decorative span whose text also appears on + // an unrelated element. + const lookups: Array< + [keyof DescriptorIndex, (l: ElementStatsChainLink) => string | null] + > = [ + ["byDataAttr", (l) => l.dataAttr], + ["byId", (l) => l.attrId], + ["byHref", (l) => l.href], + ["byTagText", (l) => (l.text ? `${l.tagName}|${l.text}` : null)], + ]; + for (const [mapName, key] of lookups) { + for (let i = 0; i < depth; i++) { + const k = key(row.chain[i]); + if (!k) continue; + const hit = index[mapName].get(k); + if (hit) return hit; + } + } + return null; +} + +/** + * Attribute stats rows to live elements. Returns only elements with at least + * one matched row — an unmatched element gets NO entry (never a false zero). + */ +export function matchElementStats( + descriptors: EmbeddedBrowserElement[], + rows: ElementStatsRow[], +): Map { + const index = indexDescriptors(descriptors); + const stats = new Map(); + for (const row of rows) { + const selectorHash = resolveRow(index, row); + if (!selectorHash) continue; + const entry = stats.get(selectorHash) ?? { + clicks: 0, + rageclicks: 0, + deadclicks: 0, + }; + if (row.type === "$autocapture") entry.clicks += row.count; + else if (row.type === "$rageclick") entry.rageclicks += row.count; + else entry.deadclicks += row.count; + stats.set(selectorHash, entry); + } + return stats; +} diff --git a/packages/core/src/product-view/healthScore.test.ts b/packages/core/src/product-view/healthScore.test.ts new file mode 100644 index 0000000000..d82fdaa452 --- /dev/null +++ b/packages/core/src/product-view/healthScore.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { buildOverlayItems, healthFor } from "./healthScore"; + +describe("healthFor", () => { + it.each([ + // clicks, rageclicks, deadclicks → health + { clicks: 1000, rageclicks: 0, deadclicks: 0, expected: "green" }, + { clicks: 1000, rageclicks: 15, deadclicks: 0, expected: "amber" }, // 1.5% + { clicks: 1000, rageclicks: 40, deadclicks: 20, expected: "red" }, // 6% + { clicks: 1000, rageclicks: 0, deadclicks: 60, expected: "red" }, + // Tiny samples never alarm: 1 rage click out of 10 is noise, not signal. + { clicks: 10, rageclicks: 1, deadclicks: 0, expected: "green" }, + { clicks: 0, rageclicks: 0, deadclicks: 0, expected: "green" }, + ])( + "clicks=$clicks rage=$rageclicks dead=$deadclicks → $expected", + ({ clicks, rageclicks, deadclicks, expected }) => { + expect(healthFor({ clicks, rageclicks, deadclicks })).toBe(expected); + }, + ); +}); + +describe("buildOverlayItems", () => { + const stats = new Map([ + ["a", { clicks: 50000, rageclicks: 0, deadclicks: 0 }], + ["b", { clicks: 900, rageclicks: 90, deadclicks: 0 }], // red + ["c", { clicks: 30, rageclicks: 0, deadclicks: 0 }], + ["d", { clicks: 20000, rageclicks: 250, deadclicks: 50 }], // amber + ]); + + it("always includes unhealthy elements and ranks the rest by usage", () => { + const items = buildOverlayItems(stats, { maxItems: 3 }); + const byHash = new Map(items.map((i) => [i.selectorHash, i])); + // Unhealthy first regardless of volume. + expect(byHash.get("b")?.halo).toBe("red"); + expect(byHash.get("d")?.halo).toBe("amber"); + // Remaining slot goes to the top-usage healthy element. + expect(byHash.get("a")?.halo).toBe("green"); + expect(byHash.has("c")).toBe(false); + expect(items).toHaveLength(3); + }); + + it("labels items with compact usage and frustration share", () => { + const items = buildOverlayItems(stats, { maxItems: 4 }); + const byHash = new Map(items.map((i) => [i.selectorHash, i])); + expect(byHash.get("a")?.label).toBe("50K clicks"); + // 90 rage of 990 total interactions → 9%. + expect(byHash.get("b")?.label).toBe("900 clicks · 9% frustrated"); + }); +}); diff --git a/packages/core/src/product-view/healthScore.ts b/packages/core/src/product-view/healthScore.ts new file mode 100644 index 0000000000..ef98511499 --- /dev/null +++ b/packages/core/src/product-view/healthScore.ts @@ -0,0 +1,61 @@ +import type { EmbeddedBrowserOverlayItem } from "@posthog/platform/embedded-browser"; +import type { ElementUsageStats } from "./elementMatching"; + +/** + * Element health from real interaction quality: rage clicks and dead clicks + * as a share of all clicks. Small samples never alarm — one angry click on a + * ten-click button is noise. + */ + +const MIN_CLICKS_FOR_ALARM = 50; +const AMBER_FRUSTRATION = 0.01; +const RED_FRUSTRATION = 0.05; + +export type ElementHealth = "green" | "amber" | "red"; + +export function healthFor(stats: ElementUsageStats): ElementHealth { + const total = stats.clicks + stats.rageclicks + stats.deadclicks; + if (total < MIN_CLICKS_FOR_ALARM) return "green"; + const frustration = (stats.rageclicks + stats.deadclicks) / total; + if (frustration >= RED_FRUSTRATION) return "red"; + if (frustration >= AMBER_FRUSTRATION) return "amber"; + return "green"; +} + +const compact = (value: number): string => + Intl.NumberFormat("en", { notation: "compact" }).format(value); + +function labelFor(stats: ElementUsageStats): string { + const total = stats.clicks + stats.rageclicks + stats.deadclicks; + const frustration = + total > 0 ? (stats.rageclicks + stats.deadclicks) / total : 0; + const base = `${compact(stats.clicks)} clicks`; + if (frustration >= AMBER_FRUSTRATION && total >= MIN_CLICKS_FOR_ALARM) { + return `${base} · ${Math.round(frustration * 100)}% frustrated`; + } + return base; +} + +/** + * The ambient overlay's clutter budget: every unhealthy element is always + * shown; remaining slots go to the highest-usage healthy elements. + */ +export function buildOverlayItems( + stats: Map, + opts: { maxItems: number }, +): EmbeddedBrowserOverlayItem[] { + const entries = [...stats.entries()].map(([selectorHash, s]) => ({ + selectorHash, + stats: s, + halo: healthFor(s), + label: labelFor(s), + })); + const unhealthy = entries.filter((e) => e.halo !== "green"); + const healthy = entries + .filter((e) => e.halo === "green") + .sort((a, b) => b.stats.clicks - a.stats.clicks); + const budgetForHealthy = Math.max(0, opts.maxItems - unhealthy.length); + return [...unhealthy, ...healthy.slice(0, budgetForHealthy)].map( + ({ selectorHash, halo, label }) => ({ selectorHash, halo, label }), + ); +} diff --git a/packages/core/src/product-view/productInsights.test.ts b/packages/core/src/product-view/productInsights.test.ts new file mode 100644 index 0000000000..1f55635d99 --- /dev/null +++ b/packages/core/src/product-view/productInsights.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { elementStatsQuery } from "./productInsights"; + +describe("elementStatsQuery", () => { + it("scopes to the page path, all click types, last 7 days, bounded rows", () => { + const qs = elementStatsQuery("/pricing"); + const params = new URLSearchParams(qs); + expect(params.get("date_from")).toBe("-7d"); + expect(params.get("limit")).toBe("200"); + expect(JSON.parse(params.get("include") ?? "[]")).toEqual([ + "$autocapture", + "$rageclick", + "$dead_click", + ]); + expect(JSON.parse(params.get("properties") ?? "[]")).toEqual([ + { key: "$pathname", value: "/pricing", operator: "exact", type: "event" }, + ]); + }); + + it("safely encodes hostile pathnames (no filter injection)", () => { + const qs = elementStatsQuery('/x"},{"key":"email"'); + const params = new URLSearchParams(qs); + const properties = JSON.parse(params.get("properties") ?? "[]"); + expect(properties).toHaveLength(1); + expect(properties[0].value).toBe('/x"},{"key":"email"'); + }); + + it("caps pathological pathname lengths", () => { + const qs = elementStatsQuery(`/${"a".repeat(5000)}`); + const params = new URLSearchParams(qs); + const properties = JSON.parse(params.get("properties") ?? "[]"); + expect((properties[0].value as string).length).toBeLessThanOrEqual(1000); + }); +}); diff --git a/packages/core/src/product-view/productInsights.ts b/packages/core/src/product-view/productInsights.ts new file mode 100644 index 0000000000..7befc1df4e --- /dev/null +++ b/packages/core/src/product-view/productInsights.ts @@ -0,0 +1,33 @@ +/** + * Query builders for the Product View's PostHog data fetches. Pure string + * building only — execution lives in ProductViewService, which injects auth. + * Everything page-derived is treated as untrusted input: JSON-encoded (never + * string-interpolated into filters) and length-capped. + */ + +const PATHNAME_CAP = 1000; +const STATS_ROW_LIMIT = 200; + +/** Query string for GET /api/projects/{id}/elements/stats/ scoped to a page: + * autocapture + rage + dead clicks over the last 7 days. */ +export function elementStatsQuery(pathname: string): string { + const params = new URLSearchParams(); + params.set("date_from", "-7d"); + params.set("limit", String(STATS_ROW_LIMIT)); + params.set( + "include", + JSON.stringify(["$autocapture", "$rageclick", "$dead_click"]), + ); + params.set( + "properties", + JSON.stringify([ + { + key: "$pathname", + value: pathname.slice(0, PATHNAME_CAP), + operator: "exact", + type: "event", + }, + ]), + ); + return params.toString(); +} diff --git a/packages/core/src/product-view/productView.test.ts b/packages/core/src/product-view/productView.test.ts index bdd6cf461b..532d6bcecb 100644 --- a/packages/core/src/product-view/productView.test.ts +++ b/packages/core/src/product-view/productView.test.ts @@ -31,12 +31,10 @@ function makeBrowser() { function makeAuth(overrides: Partial> = {}) { return { - getValidAccessToken: vi - .fn() - .mockResolvedValue({ - accessToken: "t", - apiHost: "https://us.posthog.com", - }), + getValidAccessToken: vi.fn().mockResolvedValue({ + accessToken: "t", + apiHost: "https://us.posthog.com", + }), getState: vi.fn().mockReturnValue({ currentProjectId: 2 }), authenticatedFetch: vi.fn(), ...overrides, @@ -79,6 +77,92 @@ describe("ProductViewService navigation guards", () => { }); }); +describe("ProductViewService overlay pipeline", () => { + it("matches reported elements against elements/stats for the data project and pushes halos", async () => { + vi.useFakeTimers(); + const statsResponse = { + results: [ + { + count: 32413, + hash: "h", + type: "$autocapture", + elements: [ + { + text: "Open PostHog", + tag_name: "a", + attr_class: [], + href: "https://us.posthog.com", + attr_id: null, + nth_child: 1, + nth_of_type: 1, + attributes: {}, + order: 0, + }, + ], + }, + ], + }; + const authenticatedFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => statsResponse, + }); + const browser = makeBrowser(); + const reported = { + type: "elements-reported" as const, + viewId: "v1", + pageUrl: "https://posthog.com/", + elements: [ + { + selectorHash: "anchor-us", + tag: "a", + dataAttr: null, + id: null, + classes: [], + href: "https://us.posthog.com", + text: "Open PostHog", + nthChildPath: "a:1", + }, + ], + }; + browser.events.mockImplementation(async function* () { + yield reported; + await new Promise(() => {}); + }); + + const service = new ProductViewService( + browser, + makeAuth({ authenticatedFetch }), + fakeLogger, + ); + await service.open({ + viewId: "v1", + url: "https://posthog.com", + bounds: { x: 0, y: 0, width: 800, height: 600 }, + dataProjectId: 2, + }); + + await vi.advanceTimersByTimeAsync(500); + vi.useRealTimers(); + // Give the fetch → match → push chain a real tick to settle. + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(authenticatedFetch).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining("/api/projects/2/elements/stats/"), + ); + expect(browser.pushOverlayData).toHaveBeenCalledWith({ + viewId: "v1", + items: [ + { + selectorHash: "anchor-us", + halo: "green", + label: "32K clicks", + }, + ], + }); + }); +}); + describe("ProductViewService.suggestProductUrls", () => { it("merges app_urls with pageview hosts and survives either source failing", async () => { const browser = makeBrowser(); diff --git a/packages/core/src/product-view/productView.ts b/packages/core/src/product-view/productView.ts index 2f9c1d60f9..33380500bd 100644 --- a/packages/core/src/product-view/productView.ts +++ b/packages/core/src/product-view/productView.ts @@ -14,17 +14,26 @@ import { } from "@posthog/platform/embedded-browser"; import { inject, injectable } from "inversify"; import { runHogQLQuery } from "../canvas/posthogApi"; +import { + type ElementUsageStats, + matchElementStats, + shapeElementStatsResponse, +} from "./elementMatching"; +import { buildOverlayItems } from "./healthScore"; +import { elementStatsQuery } from "./productInsights"; import { buildHostsQuery, type ProductUrlSuggestion, shapeUrlSuggestions, } from "./productSuggestions"; +import { reportedElementsSchema } from "./schemas"; export interface IProductViewService { open(input: { viewId: string; url: string; bounds: EmbeddedBrowserBounds; + dataProjectId?: number; }): Promise; navigate(viewId: string, url: string): Promise; goBack(viewId: string): void; @@ -55,9 +64,29 @@ function assertHttpUrl(raw: string): string { * container (like CanvasDataService) — the renderer reaches it over tRPC and * PostHog credentials never leave the host side. */ +const OVERLAY_MAX_ITEMS = 30; +const OVERLAY_DEBOUNCE_MS = 300; +const STATS_CACHE_TTL_MS = 5 * 60 * 1000; + @injectable() export class ProductViewService implements IProductViewService { private readonly log: ScopedLogger; + /** Which PostHog project each view's overlay reads from. */ + private readonly dataProjectByView = new Map(); + /** Latest matched per-element stats per view (feeds the details panel). */ + private readonly statsByView = new Map< + string, + Map + >(); + private readonly statsCache = new Map< + string, + { at: number; rows: ReturnType } + >(); + private readonly overlayTimers = new Map< + string, + ReturnType + >(); + private overlayLoopStarted = false; constructor( @inject(EMBEDDED_BROWSER) @@ -74,9 +103,114 @@ export class ProductViewService implements IProductViewService { viewId: string; url: string; bounds: EmbeddedBrowserBounds; + dataProjectId?: number; }): Promise { const url = assertHttpUrl(input.url); - await this.embeddedBrowser.create({ ...input, url }); + if (input.dataProjectId !== undefined) { + this.dataProjectByView.set(input.viewId, input.dataProjectId); + } + this.ensureOverlayLoop(); + await this.embeddedBrowser.create({ + viewId: input.viewId, + url, + bounds: input.bounds, + }); + } + + /** + * The overlay pipeline: inspector reports elements → match against the data + * project's autocapture stats → push display-ready halos back into the page. + * One loop for all views, started lazily with the first open. + */ + private ensureOverlayLoop(): void { + if (this.overlayLoopStarted) return; + this.overlayLoopStarted = true; + void (async () => { + for await (const event of this.embeddedBrowser.events()) { + if (event.type === "elements-reported") { + this.scheduleOverlayRefresh( + event.viewId, + event.pageUrl, + event.elements, + ); + } else if (event.type === "view-destroyed") { + this.dataProjectByView.delete(event.viewId); + this.statsByView.delete(event.viewId); + } + } + })().catch((error) => { + this.overlayLoopStarted = false; + this.log.warn("overlay loop stopped", { error }); + }); + } + + private scheduleOverlayRefresh( + viewId: string, + pageUrl: string, + elements: unknown, + ): void { + const pending = this.overlayTimers.get(viewId); + if (pending) clearTimeout(pending); + this.overlayTimers.set( + viewId, + setTimeout(() => { + this.overlayTimers.delete(viewId); + void this.refreshOverlay(viewId, pageUrl, elements).catch((error) => { + this.log.warn("overlay refresh failed", { viewId, error }); + }); + }, OVERLAY_DEBOUNCE_MS), + ); + } + + private async refreshOverlay( + viewId: string, + pageUrl: string, + rawElements: unknown, + ): Promise { + const parsed = reportedElementsSchema.safeParse(rawElements); + if (!parsed.success || parsed.data.length === 0) return; + + let pathname: string; + try { + pathname = new URL(pageUrl).pathname; + } catch { + return; + } + + const projectId = + this.dataProjectByView.get(viewId) ?? + this.authService.getState().currentProjectId; + if (projectId == null) return; + + const rows = await this.fetchElementStats(projectId, pathname); + const matched = matchElementStats(parsed.data, rows); + this.statsByView.set(viewId, matched); + this.embeddedBrowser.pushOverlayData({ + viewId, + items: buildOverlayItems(matched, { maxItems: OVERLAY_MAX_ITEMS }), + }); + } + + private async fetchElementStats( + projectId: number, + pathname: string, + ): Promise> { + const cacheKey = `${projectId}|${pathname}`; + const cached = this.statsCache.get(cacheKey); + if (cached && Date.now() - cached.at < STATS_CACHE_TTL_MS) { + return cached.rows; + } + const { apiHost } = await this.authService.getValidAccessToken(); + const response = await this.authService.authenticatedFetch( + fetch, + `${apiHost}/api/projects/${projectId}/elements/stats/?${elementStatsQuery(pathname)}`, + ); + if (!response.ok) { + throw new Error(`elements/stats failed (${response.status})`); + } + const rows = shapeElementStatsResponse(await response.json()); + this.statsCache.set(cacheKey, { at: Date.now(), rows }); + return rows; } async navigate(viewId: string, url: string): Promise { diff --git a/packages/core/src/product-view/schemas.ts b/packages/core/src/product-view/schemas.ts index 61e9717c01..69e3c68842 100644 --- a/packages/core/src/product-view/schemas.ts +++ b/packages/core/src/product-view/schemas.ts @@ -24,8 +24,26 @@ export const openProductViewInput = z.object({ viewId: z.string(), url: z.string(), bounds: embeddedBrowserBoundsSchema, + /** PostHog project whose analytics overlay onto this view's pages. + * Defaults to the signed-in user's current project. */ + dataProjectId: z.number().optional(), }); +/** A reported in-page element descriptor. Everything here comes from the + * user's product page and is untrusted: capped and validated before use. */ +export const reportedElementSchema = z.object({ + selectorHash: z.string().min(1).max(64), + tag: z.string().min(1).max(64), + dataAttr: z.string().max(300).nullable(), + id: z.string().max(300).nullable(), + classes: z.array(z.string().max(200)).max(10), + href: z.string().max(2000).nullable(), + text: z.string().max(200).nullable(), + nthChildPath: z.string().max(500), +}); + +export const reportedElementsSchema = z.array(reportedElementSchema).max(400); + export const navigateProductViewInput = z.object({ viewId: z.string(), url: z.string(), diff --git a/packages/ui/src/features/product-view/ProductView.tsx b/packages/ui/src/features/product-view/ProductView.tsx index 3cf30f5291..b97f79a4c8 100644 --- a/packages/ui/src/features/product-view/ProductView.tsx +++ b/packages/ui/src/features/product-view/ProductView.tsx @@ -3,6 +3,7 @@ import { ArrowLeftIcon, ArrowRightIcon, CaretDownIcon, + CursorClickIcon, GlobeIcon, MonitorIcon, PlusIcon, @@ -121,7 +122,11 @@ function ProductBrowser(props: { const viewId = `product-${environment.id}`; const initialUrl = environment.currentUrl ?? environment.pageOrigin; - const slotRef = useProductViewSlot({ viewId, url: initialUrl }); + const slotRef = useProductViewSlot({ + viewId, + url: initialUrl, + dataProjectId: environment.dataProjectId, + }); const pageState = useProductViewPageState(viewId); const navigate = useMutation(trpc.productView.navigate.mutationOptions()); @@ -131,8 +136,12 @@ function ProductBrowser(props: { const touch = useMutation( trpc.productView.touchEnvironment.mutationOptions(), ); + const setInspectMode = useMutation( + trpc.productView.setInspectMode.mutationOptions(), + ); const removeEnvironment = useRemoveProductEnvironment(); + const [inspecting, setInspecting] = useState(false); const [draftUrl, setDraftUrl] = useState(null); const currentUrl = pageState?.url || initialUrl; @@ -194,6 +203,20 @@ function ProductBrowser(props: { aria-label="Page URL" spellCheck={false} /> + (null); const openedRef = useRef(false); @@ -83,7 +87,7 @@ export function useProductViewSlot(input: { viewId: string; url: string }) { lastRect = key; if (!openedRef.current) { openedRef.current = true; - void openMutate({ viewId, url, bounds }).catch(() => { + void openMutate({ viewId, url, bounds, dataProjectId }).catch(() => { openedRef.current = false; }); } else { @@ -111,7 +115,14 @@ export function useProductViewSlot(input: { viewId: string; url: string }) { setVisibleMutate({ viewId, visible: false }); openedRef.current = false; }; - }, [viewId, url, openMutate, setBoundsMutate, setVisibleMutate]); + }, [ + viewId, + url, + dataProjectId, + openMutate, + setBoundsMutate, + setVisibleMutate, + ]); // The command menu is a renderer overlay and cannot paint over the native // view — hide the view while the menu is open. From e66d1d20026c56c12ec4a700ae40715c9ef59bad Mon Sep 17 00:00:00 2001 From: Daniel Visca Date: Fri, 31 Jul 2026 11:42:55 -0400 Subject: [PATCH 03/10] feat(product-view): element details panel + live network/trace capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 3: select an element, see its full story. - core detail queries (all shapes validated against the live posthog.com project first): 30-day usage trend, exceptions correlated via shared sessions, recent replay sessions, page web-vitals p75 — built from the element's strongest chain key (data-attr > id > href > text) with HogQL string/LIKE escaping of all page-derived values - LatencySampleBuffer: bounded rolling p50/p95/p99 over live requests - adapter CDP capture: fetch/XHR timing + traceparent trace ids only (never cookies/auth headers); requests within 2s of a click are attributed to that element; degrades cleanly when a debugger is already attached - preload: ambient (non-blocking) interaction reporting for attribution - ElementDetailsPanel: usage sparkline, frustration, error issues, live latency percentiles, request waterfall with trace markers, replay links — deep links open in PostHog cloud scoped to the environment's data project Generated-By: PostHog Code Task-Id: 1120fcea-fc07-4c54-8dbb-50b7b712f9cb --- .../electron-embedded-browser.ts | 118 ++++++++ apps/code/src/preload/product-view-preload.ts | 15 ++ .../product-view/latencyAggregation.test.ts | 33 +++ .../src/product-view/latencyAggregation.ts | 52 ++++ .../src/product-view/productInsights.test.ts | 126 ++++++++- .../core/src/product-view/productInsights.ts | 188 +++++++++++++ packages/core/src/product-view/productView.ts | 167 +++++++++++- packages/core/src/product-view/schemas.ts | 53 ++++ .../src/routers/product-view.router.ts | 7 + .../product-view/ElementDetailsPanel.tsx | 253 ++++++++++++++++++ .../src/features/product-view/ProductView.tsx | 17 +- .../product-view/useSelectedElement.ts | 35 +++ packages/ui/src/utils/posthogLinks.ts | 10 + 13 files changed, 1069 insertions(+), 5 deletions(-) create mode 100644 packages/core/src/product-view/latencyAggregation.test.ts create mode 100644 packages/core/src/product-view/latencyAggregation.ts create mode 100644 packages/ui/src/features/product-view/ElementDetailsPanel.tsx create mode 100644 packages/ui/src/features/product-view/useSelectedElement.ts diff --git a/apps/code/src/main/platform-adapters/electron-embedded-browser.ts b/apps/code/src/main/platform-adapters/electron-embedded-browser.ts index 59b4b180bb..c3ab1c371c 100644 --- a/apps/code/src/main/platform-adapters/electron-embedded-browser.ts +++ b/apps/code/src/main/platform-adapters/electron-embedded-browser.ts @@ -236,5 +236,123 @@ export class ElectronEmbeddedBrowser element: payload.element, }); }); + + // Real interactions (ambient clicks, never blocked) — used to attribute + // the network requests that follow to the element that triggered them. + let lastInteraction: { selectorHash: string; at: number } | null = null; + wc.ipc.on("product-view:interaction", (_event, payload) => { + if (typeof payload?.selectorHash !== "string") return; + lastInteraction = { selectorHash: payload.selectorHash, at: Date.now() }; + }); + + this.attachNetworkCapture(viewId, view, () => { + if (!lastInteraction) return null; + return Date.now() - lastInteraction.at <= INTERACTION_ATTRIBUTION_MS + ? lastInteraction.selectorHash + : null; + }); + } + + /** + * Live network capture over CDP: request timing + the `traceparent` header + * (frontend → backend trace correlation). Only fetch/XHR traffic is sampled, + * and only these two fields ever leave the adapter — the page's cookies and + * auth headers do not. Degrades to no capture when another debugger is + * already attached (dev :9222, open devtools). + */ + private attachNetworkCapture( + viewId: string, + view: WebContentsView, + attributedSelectorHash: () => string | null, + ): void { + const wc = view.webContents; + try { + wc.debugger.attach("1.3"); + void wc.debugger.sendCommand("Network.enable"); + } catch (error) { + log.warn("network capture unavailable", { viewId, error }); + return; + } + + interface PendingRequest { + url: string; + method: string; + startedAt: number; + traceId: string | null; + interactionSelectorHash: string | null; + status: number | null; + } + const pending = new Map(); + + wc.debugger.on("message", (_event, method, params) => { + const p = params as { + requestId?: string; + type?: string; + timestamp?: number; + request?: { + url?: string; + method?: string; + headers?: Record; + }; + response?: { status?: number }; + }; + const requestId = p.requestId; + if (!requestId) return; + + if (method === "Network.requestWillBeSent") { + if (p.type !== "XHR" && p.type !== "Fetch") return; + if (pending.size > 500) pending.clear(); + const headers = p.request?.headers ?? {}; + const traceparent = + headers.traceparent ?? headers.Traceparent ?? headers.TRACEPARENT; + const traceId = + typeof traceparent === "string" + ? (traceparent.split("-")[1] ?? null) + : null; + pending.set(requestId, { + url: p.request?.url ?? "", + method: p.request?.method ?? "GET", + startedAt: (p.timestamp ?? 0) * 1000, + traceId, + interactionSelectorHash: attributedSelectorHash(), + status: null, + }); + } else if (method === "Network.responseReceived") { + const request = pending.get(requestId); + if (request) request.status = p.response?.status ?? null; + } else if ( + method === "Network.loadingFinished" || + method === "Network.loadingFailed" + ) { + const request = pending.get(requestId); + if (!request) return; + pending.delete(requestId); + const endedAt = (p.timestamp ?? 0) * 1000; + const duration = + request.startedAt > 0 && endedAt > request.startedAt + ? Math.round(endedAt - request.startedAt) + : null; + this.emit("event", { + type: "network-sample", + sample: { + viewId, + url: request.url, + method: request.method, + status: request.status, + durationMs: duration, + traceId: request.traceId, + interactionSelectorHash: request.interactionSelectorHash, + timestamp: Date.now(), + }, + }); + } + }); + + wc.debugger.on("detach", (_event, reason) => { + log.info("network capture detached", { viewId, reason }); + }); } } + +/** How long after a click a network request is still attributed to it. */ +const INTERACTION_ATTRIBUTION_MS = 2000; diff --git a/apps/code/src/preload/product-view-preload.ts b/apps/code/src/preload/product-view-preload.ts index 5df884f3d1..cc2d1c1ef8 100644 --- a/apps/code/src/preload/product-view-preload.ts +++ b/apps/code/src/preload/product-view-preload.ts @@ -326,6 +326,21 @@ ipcRenderer.on("product-view:set-inspect-mode", (_event, payload) => { setInspectMode(Boolean(payload?.enabled)); }); +// Ambient interaction reporting: never blocks or alters the page; the host +// uses it to attribute the network requests that follow to the element that +// triggered them (live latency + trace correlation). +document.addEventListener( + "pointerdown", + (event) => { + const candidate = candidateFor(event.target); + if (!candidate) return; + ipcRenderer.send("product-view:interaction", { + selectorHash: describe(candidate).selectorHash, + }); + }, + { capture: true, passive: true }, +); + window.addEventListener("scroll", scheduleLayout, { passive: true, capture: true, diff --git a/packages/core/src/product-view/latencyAggregation.test.ts b/packages/core/src/product-view/latencyAggregation.test.ts new file mode 100644 index 0000000000..eade31b882 --- /dev/null +++ b/packages/core/src/product-view/latencyAggregation.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { LatencySampleBuffer } from "./latencyAggregation"; + +describe("LatencySampleBuffer", () => { + it("returns null for keys with no samples", () => { + const buffer = new LatencySampleBuffer(); + expect(buffer.snapshot("nope")).toBeNull(); + }); + + it("computes count and percentiles", () => { + const buffer = new LatencySampleBuffer(); + for (let i = 1; i <= 100; i++) buffer.add("k", i); + const snap = buffer.snapshot("k"); + expect(snap).toMatchObject({ count: 100, p50: 50, p95: 95, p99: 99 }); + }); + + it("keeps only the most recent samples per key", () => { + const buffer = new LatencySampleBuffer(10); + for (let i = 1; i <= 100; i++) buffer.add("k", i); + const snap = buffer.snapshot("k"); + // Only 91..100 retained. + expect(snap?.count).toBe(10); + expect(snap?.p50).toBeGreaterThanOrEqual(91); + }); + + it("isolates keys", () => { + const buffer = new LatencySampleBuffer(); + buffer.add("a", 10); + buffer.add("b", 1000); + expect(buffer.snapshot("a")?.p50).toBe(10); + expect(buffer.snapshot("b")?.p50).toBe(1000); + }); +}); diff --git a/packages/core/src/product-view/latencyAggregation.ts b/packages/core/src/product-view/latencyAggregation.ts new file mode 100644 index 0000000000..1697f8209f --- /dev/null +++ b/packages/core/src/product-view/latencyAggregation.ts @@ -0,0 +1,52 @@ +/** + * Rolling latency aggregation over live network samples captured on the + * embedded page (CDP). Keys are caller-defined — a whole view or one + * element's attributed requests. Pure and bounded. + */ + +export interface LatencySnapshot { + count: number; + p50: number; + p95: number; + p99: number; +} + +const DEFAULT_MAX_SAMPLES = 500; + +export class LatencySampleBuffer { + private readonly samples = new Map(); + + constructor(private readonly maxSamplesPerKey = DEFAULT_MAX_SAMPLES) {} + + add(key: string, durationMs: number): void { + let list = this.samples.get(key); + if (!list) { + list = []; + this.samples.set(key, list); + } + list.push(durationMs); + if (list.length > this.maxSamplesPerKey) { + list.splice(0, list.length - this.maxSamplesPerKey); + } + } + + snapshot(key: string): LatencySnapshot | null { + const list = this.samples.get(key); + if (!list || list.length === 0) return null; + const sorted = [...list].sort((a, b) => a - b); + const at = (q: number) => + sorted[Math.min(sorted.length - 1, Math.ceil(q * sorted.length) - 1)]; + return { + count: sorted.length, + p50: at(0.5), + p95: at(0.95), + p99: at(0.99), + }; + } + + clear(prefix: string): void { + for (const key of this.samples.keys()) { + if (key.startsWith(prefix)) this.samples.delete(key); + } + } +} diff --git a/packages/core/src/product-view/productInsights.test.ts b/packages/core/src/product-view/productInsights.test.ts index 1f55635d99..54576a4080 100644 --- a/packages/core/src/product-view/productInsights.test.ts +++ b/packages/core/src/product-view/productInsights.test.ts @@ -1,5 +1,129 @@ import { describe, expect, it } from "vitest"; -import { elementStatsQuery } from "./productInsights"; +import { + buildElementChainFragment, + buildElementErrorsQuery, + buildElementSessionsQuery, + buildElementTrendQuery, + buildPageVitalsQuery, + elementStatsQuery, + shapeErrorRows, + shapeSessionRows, + shapeTrendRows, + shapeVitalsRow, +} from "./productInsights"; + +const element = { + selectorHash: "h", + tag: "a", + dataAttr: null, + id: null, + classes: [], + href: "https://us.posthog.com", + text: "Open PostHog", + nthChildPath: "a:1", +}; + +describe("buildElementChainFragment", () => { + it("prefers data-attr, then id, then href, then text", () => { + expect( + buildElementChainFragment({ ...element, dataAttr: "save-button" }), + ).toBe('%attr__data-attr="save-button"%'); + expect(buildElementChainFragment({ ...element, id: "cta" })).toBe( + '%attr__id="cta"%', + ); + expect(buildElementChainFragment(element)).toBe( + '%href="https://us.posthog.com"%', + ); + expect(buildElementChainFragment({ ...element, href: null })).toBe( + '%text="Open PostHog"%', + ); + }); + + it("returns null when the element has no usable key", () => { + expect( + buildElementChainFragment({ ...element, href: null, text: null }), + ).toBeNull(); + }); + + it("escapes quotes, backslashes, and LIKE wildcards from page-derived values", () => { + const fragment = buildElementChainFragment({ + ...element, + dataAttr: `x' OR 1=1 --100%_\\`, + }); + expect(fragment).toBe(`%attr__data-attr="x\\' OR 1=1 --100\\%\\_\\\\"%`); + }); +}); + +describe("detail query builders", () => { + const fragment = '%href="https://us.posthog.com"%'; + + it("trend query counts clicks and unique persons per day over 30 days", () => { + const sql = buildElementTrendQuery("/", fragment); + expect(sql).toContain("uniq(person_id)"); + expect(sql).toContain("INTERVAL 30 DAY"); + expect(sql).toContain("properties.$pathname = '/'"); + expect(sql).toContain(`elements_chain LIKE '${fragment}'`); + }); + + it("errors query correlates exceptions via shared sessions", () => { + const sql = buildElementErrorsQuery("/", fragment); + expect(sql).toContain("'$exception'"); + expect(sql).toContain("$exception_issue_id"); + expect(sql).toContain("`$session_id` IN ("); + }); + + it("sessions query lists recent interacting sessions", () => { + const sql = buildElementSessionsQuery("/", fragment); + expect(sql).toContain("max(timestamp)"); + expect(sql).toContain("LIMIT 5"); + }); + + it("vitals query reads page p75 INP and LCP", () => { + const sql = buildPageVitalsQuery("/"); + expect(sql).toContain("$web_vitals_INP_value"); + expect(sql).toContain("$web_vitals_LCP_value"); + }); + + it("escapes single quotes in pathnames", () => { + const sql = buildElementTrendQuery("/it's", fragment); + expect(sql).toContain("properties.$pathname = '/it\\'s'"); + }); +}); + +describe("shapers", () => { + it("shapes trend rows", () => { + expect( + shapeTrendRows([["2026-07-24T00:00:00-07:00", 4226, 2972], ["bad row"]]), + ).toEqual([ + { day: "2026-07-24T00:00:00-07:00", clicks: 4226, users: 2972 }, + ]); + }); + + it("shapes error rows", () => { + expect(shapeErrorRows([["019f6b23", '["Error"]', 13066, 970]])).toEqual([ + { + issueId: "019f6b23", + types: ["Error"], + occurrences: 13066, + affectedUsers: 970, + }, + ]); + }); + + it("shapes session rows and vitals", () => { + expect( + shapeSessionRows([["019fb8ce", "2026-07-31T08:32:29-07:00"]]), + ).toEqual([ + { sessionId: "019fb8ce", lastSeen: "2026-07-31T08:32:29-07:00" }, + ]); + expect(shapeVitalsRow([[176.0, 2408.0]])).toEqual({ + inpP75: 176, + lcpP75: 2408, + }); + expect(shapeVitalsRow([])).toBeNull(); + expect(shapeVitalsRow([[null, null]])).toBeNull(); + }); +}); describe("elementStatsQuery", () => { it("scopes to the page path, all click types, last 7 days, bounded rows", () => { diff --git a/packages/core/src/product-view/productInsights.ts b/packages/core/src/product-view/productInsights.ts index 7befc1df4e..f7e8cbc16d 100644 --- a/packages/core/src/product-view/productInsights.ts +++ b/packages/core/src/product-view/productInsights.ts @@ -5,9 +5,197 @@ * string-interpolated into filters) and length-capped. */ +import type { EmbeddedBrowserElement } from "@posthog/platform/embedded-browser"; + const PATHNAME_CAP = 1000; const STATS_ROW_LIMIT = 200; +/** Escape a page-derived value for a single-quoted HogQL string literal. */ +function escapeString(value: string): string { + return value.replaceAll("\\", "\\\\").replaceAll("'", "\\'"); +} + +/** Additionally neutralize LIKE wildcards for `elements_chain LIKE` patterns. */ +function escapeLike(value: string): string { + return value + .replaceAll("\\", "\\\\") + .replaceAll("'", "\\'") + .replaceAll("%", "\\%") + .replaceAll("_", "\\_"); +} + +const quotedPath = (pathname: string): string => + `'${escapeString(pathname.slice(0, PATHNAME_CAP))}'`; + +/** + * The `elements_chain LIKE` fragment identifying one element in historical + * autocapture data, from its strongest stable key. Null when the element has + * nothing worth matching on (then the details panel shows page context only). + */ +export function buildElementChainFragment( + element: EmbeddedBrowserElement, +): string | null { + if (element.dataAttr) + return `%attr__data-attr="${escapeLike(element.dataAttr)}"%`; + if (element.id) return `%attr__id="${escapeLike(element.id)}"%`; + if (element.href) return `%href="${escapeLike(element.href)}"%`; + if (element.text) return `%text="${escapeLike(element.text)}"%`; + return null; +} + +/** Daily clicks + unique persons on the element over the last 30 days. + * (Query shape validated against the live posthog.com project.) */ +export function buildElementTrendQuery( + pathname: string, + chainFragment: string, +): string { + return [ + "SELECT toStartOfDay(timestamp) AS day, count() AS clicks, uniq(person_id) AS users", + "FROM events", + "WHERE event = '$autocapture'", + ` AND properties.$pathname = ${quotedPath(pathname)}`, + ` AND elements_chain LIKE '${chainFragment}'`, + " AND timestamp > now() - INTERVAL 30 DAY", + "GROUP BY day ORDER BY day", + ].join("\n"); +} + +/** Exceptions raised in the same sessions that interacted with the element — + * the "what breaks for people who use this" correlation. */ +export function buildElementErrorsQuery( + pathname: string, + chainFragment: string, +): string { + return [ + "SELECT properties.$exception_issue_id AS issue_id, any(properties.$exception_types) AS types,", + " count() AS occurrences, uniq(person_id) AS affected_users", + "FROM events", + "WHERE event = '$exception' AND timestamp > now() - INTERVAL 7 DAY", + " AND `$session_id` IN (", + " SELECT `$session_id` FROM events", + " WHERE event = '$autocapture'", + ` AND properties.$pathname = ${quotedPath(pathname)}`, + ` AND elements_chain LIKE '${chainFragment}'`, + " AND timestamp > now() - INTERVAL 7 DAY AND `$session_id` != ''", + " )", + "GROUP BY issue_id ORDER BY occurrences DESC LIMIT 10", + ].join("\n"); +} + +/** Most recent sessions that interacted with the element (replay links). */ +export function buildElementSessionsQuery( + pathname: string, + chainFragment: string, +): string { + return [ + "SELECT `$session_id` AS session_id, max(timestamp) AS last_seen", + "FROM events", + "WHERE event = '$autocapture'", + ` AND properties.$pathname = ${quotedPath(pathname)}`, + ` AND elements_chain LIKE '${chainFragment}'`, + " AND `$session_id` != '' AND timestamp > now() - INTERVAL 7 DAY", + "GROUP BY session_id ORDER BY last_seen DESC LIMIT 5", + ].join("\n"); +} + +/** Page-level web-vitals context (p75 INP + LCP over 7 days). */ +export function buildPageVitalsQuery(pathname: string): string { + return [ + "SELECT round(quantile(0.75)(toFloat(properties.$web_vitals_INP_value))) AS inp_p75,", + " round(quantile(0.75)(toFloat(properties.$web_vitals_LCP_value))) AS lcp_p75", + "FROM events", + `WHERE event = '$web_vitals' AND properties.$pathname = ${quotedPath(pathname)}`, + " AND timestamp > now() - INTERVAL 7 DAY", + ].join("\n"); +} + +// ── Row shapers (raw /query/ grids → view models) ── + +export interface ElementTrendPoint { + day: string; + clicks: number; + users: number; +} + +export function shapeTrendRows(results: unknown[]): ElementTrendPoint[] { + const points: ElementTrendPoint[] = []; + for (const row of results) { + if (!Array.isArray(row)) continue; + const [day, clicks, users] = row; + if (typeof day !== "string" || typeof clicks !== "number") continue; + points.push({ day, clicks, users: typeof users === "number" ? users : 0 }); + } + return points; +} + +export interface ElementErrorIssue { + issueId: string; + types: string[]; + occurrences: number; + affectedUsers: number; +} + +export function shapeErrorRows(results: unknown[]): ElementErrorIssue[] { + const issues: ElementErrorIssue[] = []; + for (const row of results) { + if (!Array.isArray(row)) continue; + const [issueId, types, occurrences, affectedUsers] = row; + if (typeof issueId !== "string" || typeof occurrences !== "number") { + continue; + } + let parsedTypes: string[] = []; + if (typeof types === "string") { + try { + const value = JSON.parse(types); + if (Array.isArray(value)) parsedTypes = value.map(String); + } catch { + parsedTypes = [types]; + } + } else if (Array.isArray(types)) { + parsedTypes = types.map(String); + } + issues.push({ + issueId, + types: parsedTypes, + occurrences, + affectedUsers: typeof affectedUsers === "number" ? affectedUsers : 0, + }); + } + return issues; +} + +export interface ElementSessionRef { + sessionId: string; + lastSeen: string; +} + +export function shapeSessionRows(results: unknown[]): ElementSessionRef[] { + const sessions: ElementSessionRef[] = []; + for (const row of results) { + if (!Array.isArray(row)) continue; + const [sessionId, lastSeen] = row; + if (typeof sessionId !== "string" || typeof lastSeen !== "string") continue; + sessions.push({ sessionId, lastSeen }); + } + return sessions; +} + +export interface PageVitals { + inpP75: number; + lcpP75: number; +} + +export function shapeVitalsRow(results: unknown[]): PageVitals | null { + const row = results[0]; + if (!Array.isArray(row)) return null; + const [inp, lcp] = row; + if (typeof inp !== "number" && typeof lcp !== "number") return null; + return { + inpP75: typeof inp === "number" ? inp : 0, + lcpP75: typeof lcp === "number" ? lcp : 0, + }; +} + /** Query string for GET /api/projects/{id}/elements/stats/ scoped to a page: * autocapture + rage + dead clicks over the last 7 days. */ export function elementStatsQuery(pathname: string): string { diff --git a/packages/core/src/product-view/productView.ts b/packages/core/src/product-view/productView.ts index 33380500bd..fcbb67d11a 100644 --- a/packages/core/src/product-view/productView.ts +++ b/packages/core/src/product-view/productView.ts @@ -13,6 +13,7 @@ import { type IEmbeddedBrowser, } from "@posthog/platform/embedded-browser"; import { inject, injectable } from "inversify"; +import type { z } from "zod"; import { runHogQLQuery } from "../canvas/posthogApi"; import { type ElementUsageStats, @@ -20,13 +21,29 @@ import { shapeElementStatsResponse, } from "./elementMatching"; import { buildOverlayItems } from "./healthScore"; -import { elementStatsQuery } from "./productInsights"; +import { LatencySampleBuffer } from "./latencyAggregation"; +import { + buildElementChainFragment, + buildElementErrorsQuery, + buildElementSessionsQuery, + buildElementTrendQuery, + buildPageVitalsQuery, + elementStatsQuery, + shapeErrorRows, + shapeSessionRows, + shapeTrendRows, + shapeVitalsRow, +} from "./productInsights"; import { buildHostsQuery, type ProductUrlSuggestion, shapeUrlSuggestions, } from "./productSuggestions"; -import { reportedElementsSchema } from "./schemas"; +import { + type ElementDetail, + reportedElementSchema, + reportedElementsSchema, +} from "./schemas"; export interface IProductViewService { open(input: { @@ -46,6 +63,11 @@ export interface IProductViewService { setInspectMode(viewId: string, enabled: boolean): void; events(signal?: AbortSignal): AsyncIterable; suggestProductUrls(): Promise; + getElementDetail(input: { + viewId: string; + pageUrl: string; + element: z.infer; + }): Promise; } /** The embedded browser must only ever load plain web pages. */ @@ -82,6 +104,13 @@ export class ProductViewService implements IProductViewService { string, { at: number; rows: ReturnType } >(); + /** Live network latency captured on the embedded pages (CDP). */ + private readonly latency = new LatencySampleBuffer(); + /** Recent request samples per view, newest last (details panel waterfall). */ + private readonly recentSamples = new Map< + string, + ElementDetail["recentRequests"] + >(); private readonly overlayTimers = new Map< string, ReturnType @@ -133,9 +162,13 @@ export class ProductViewService implements IProductViewService { event.pageUrl, event.elements, ); + } else if (event.type === "network-sample") { + this.recordNetworkSample(event.sample); } else if (event.type === "view-destroyed") { this.dataProjectByView.delete(event.viewId); this.statsByView.delete(event.viewId); + this.recentSamples.delete(event.viewId); + this.latency.clear(`${event.viewId}|`); } } })().catch((error) => { @@ -253,6 +286,136 @@ export class ProductViewService implements IProductViewService { return this.embeddedBrowser.events(signal); } + private recordNetworkSample(sample: { + viewId: string; + url: string; + method: string; + status: number | null; + durationMs: number | null; + traceId: string | null; + interactionSelectorHash: string | null; + timestamp: number; + }): void { + if (sample.durationMs != null) { + this.latency.add(`${sample.viewId}|page`, sample.durationMs); + if (sample.interactionSelectorHash) { + this.latency.add( + `${sample.viewId}|${sample.interactionSelectorHash}`, + sample.durationMs, + ); + } + } + const ring = this.recentSamples.get(sample.viewId) ?? []; + ring.push({ + url: sample.url, + method: sample.method, + status: sample.status, + durationMs: sample.durationMs, + traceId: sample.traceId, + interactionSelectorHash: sample.interactionSelectorHash, + timestamp: sample.timestamp, + }); + if (ring.length > 100) ring.splice(0, ring.length - 100); + this.recentSamples.set(sample.viewId, ring); + } + + /** + * The full story for one selected element: 30-day usage trend, exceptions + * correlated via shared sessions, example replay sessions, page web vitals, + * plus live latency/requests captured while browsing. Historical queries run + * against the view's data project; anything page-derived is escaped by the + * builders. + */ + async getElementDetail(input: { + viewId: string; + pageUrl: string; + element: z.infer; + }): Promise { + const element = reportedElementSchema.parse(input.element); + const projectId = + this.dataProjectByView.get(input.viewId) ?? + this.authService.getState().currentProjectId; + if (projectId == null) throw new Error("No PostHog project selected"); + + let pathname = "/"; + try { + pathname = new URL(input.pageUrl).pathname; + } catch { + // keep "/" — detail queries then show page-root context + } + + const fragment = buildElementChainFragment(element); + const run = async (sql: string): Promise => + (await this.queryHogQL(projectId, sql)).results; + + const [trend, errors, sessions, vitals] = await Promise.all([ + fragment + ? run(buildElementTrendQuery(pathname, fragment)).then(shapeTrendRows) + : Promise.resolve([]), + fragment + ? run(buildElementErrorsQuery(pathname, fragment)).then(shapeErrorRows) + : Promise.resolve([]), + fragment + ? run(buildElementSessionsQuery(pathname, fragment)).then( + shapeSessionRows, + ) + : Promise.resolve([]), + run(buildPageVitalsQuery(pathname)) + .then(shapeVitalsRow) + .catch(() => null), + ]); + + const allSamples = this.recentSamples.get(input.viewId) ?? []; + const attributed = allSamples.filter( + (s) => s.interactionSelectorHash === element.selectorHash, + ); + return { + selectorHash: element.selectorHash, + dataProjectId: projectId, + pathname, + totals: + this.statsByView.get(input.viewId)?.get(element.selectorHash) ?? null, + trend, + errors, + sessions, + vitals, + liveLatency: + this.latency.snapshot(`${input.viewId}|${element.selectorHash}`) ?? + this.latency.snapshot(`${input.viewId}|page`), + recentRequests: (attributed.length > 0 ? attributed : allSamples).slice( + -20, + ), + }; + } + + /** Raw HogQL against an explicit project (the environment's data project — + * not necessarily the signed-in default). */ + private async queryHogQL( + projectId: number, + sql: string, + ): Promise<{ results: unknown[] }> { + const { apiHost } = await this.authService.getValidAccessToken(); + const response = await this.authService.authenticatedFetch( + fetch, + `${apiHost}/api/projects/${projectId}/query/`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: { + kind: "HogQLQuery", + query: sql, + tags: { productKey: "max" }, + }, + refresh: "blocking", + }), + }, + ); + if (!response.ok) throw new Error(`Query failed (${response.status})`); + const body = (await response.json()) as { results?: unknown[] }; + return { results: Array.isArray(body.results) ? body.results : [] }; + } + /** * Candidate product origins for the current project: configured toolbar * `app_urls` first, then the hosts $pageview traffic actually reports. diff --git a/packages/core/src/product-view/schemas.ts b/packages/core/src/product-view/schemas.ts index 69e3c68842..ce27b324cf 100644 --- a/packages/core/src/product-view/schemas.ts +++ b/packages/core/src/product-view/schemas.ts @@ -66,3 +66,56 @@ export const productUrlSuggestionSchema = z.object({ source: z.enum(["app_urls", "pageview_hosts"]), eventCount: z.number().optional(), }); + +export const getElementDetailInput = z.object({ + viewId: z.string(), + pageUrl: z.string().max(4000), + element: reportedElementSchema, +}); + +const latencySnapshotSchema = z.object({ + count: z.number(), + p50: z.number(), + p95: z.number(), + p99: z.number(), +}); + +const networkRequestSampleSchema = z.object({ + url: z.string(), + method: z.string(), + status: z.number().nullable(), + durationMs: z.number().nullable(), + traceId: z.string().nullable(), + interactionSelectorHash: z.string().nullable(), + timestamp: z.number(), +}); + +/** Everything the details panel renders for one selected element. */ +export const elementDetailSchema = z.object({ + selectorHash: z.string(), + dataProjectId: z.number(), + pathname: z.string(), + totals: z + .object({ + clicks: z.number(), + rageclicks: z.number(), + deadclicks: z.number(), + }) + .nullable(), + trend: z.array( + z.object({ day: z.string(), clicks: z.number(), users: z.number() }), + ), + errors: z.array( + z.object({ + issueId: z.string(), + types: z.array(z.string()), + occurrences: z.number(), + affectedUsers: z.number(), + }), + ), + sessions: z.array(z.object({ sessionId: z.string(), lastSeen: z.string() })), + vitals: z.object({ inpP75: z.number(), lcpP75: z.number() }).nullable(), + liveLatency: latencySnapshotSchema.nullable(), + recentRequests: z.array(networkRequestSampleSchema), +}); +export type ElementDetail = z.infer; diff --git a/packages/host-router/src/routers/product-view.router.ts b/packages/host-router/src/routers/product-view.router.ts index ff5ce17f09..33a95bc346 100644 --- a/packages/host-router/src/routers/product-view.router.ts +++ b/packages/host-router/src/routers/product-view.router.ts @@ -1,6 +1,8 @@ import { PRODUCT_VIEW_SERVICE } from "@posthog/core/product-view/identifiers"; import type { IProductViewService } from "@posthog/core/product-view/productView"; import { + elementDetailSchema, + getElementDetailInput, navigateProductViewInput, openProductViewInput, productUrlSuggestionSchema, @@ -104,6 +106,11 @@ export const productViewRouter = router({ .output(z.array(productUrlSuggestionSchema)) .query(({ ctx }) => view(ctx.container).suggestProductUrls()), + getElementDetail: publicProcedure + .input(getElementDetailInput) + .output(elementDetailSchema) + .query(({ ctx, input }) => view(ctx.container).getElementDetail(input)), + onEvents: publicProcedure.subscription(async function* (opts) { for await (const event of view(opts.ctx.container).events(opts.signal)) { yield event; diff --git a/packages/ui/src/features/product-view/ElementDetailsPanel.tsx b/packages/ui/src/features/product-view/ElementDetailsPanel.tsx new file mode 100644 index 0000000000..76285b9e5f --- /dev/null +++ b/packages/ui/src/features/product-view/ElementDetailsPanel.tsx @@ -0,0 +1,253 @@ +import { + ArrowSquareOutIcon, + PulseIcon, + UsersIcon, + WarningIcon, + XIcon, +} from "@phosphor-icons/react"; +import { useHostTRPC } from "@posthog/host-router/react"; +import { Badge, Button, Spinner } from "@posthog/quill"; +import { openExternalUrl } from "@posthog/ui/shell/openExternal"; +import { + errorTrackingIssueUrl, + replayUrl, +} from "@posthog/ui/utils/posthogLinks"; +import { useQuery } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import type { SelectedElement } from "./useSelectedElement"; + +const compact = (value: number): string => + Intl.NumberFormat("en", { notation: "compact" }).format(value); + +function Section(props: { title: string; children: ReactNode }) { + return ( +
+ + {props.title} + + {props.children} +
+ ); +} + +function EmptyLine({ text }: { text: string }) { + return {text}; +} + +/** Tiny dependency-free daily-usage sparkline (30 bars max). */ +function TrendBars(props: { trend: { day: string; clicks: number }[] }) { + const max = Math.max(...props.trend.map((t) => t.clicks), 1); + return ( +
+ {props.trend.map((point) => ( +
+ ))} +
+ ); +} + +/** + * The full story for one selected element: usage, frustration, errors, + * latency, network trace, and example sessions — everything the host resolved + * from the environment's data project, rendered beside the live page. + */ +export function ElementDetailsPanel(props: { + viewId: string; + selected: SelectedElement; + dataProjectId: number; + onClose: () => void; + investigateSlot?: ReactNode; +}) { + const { viewId, selected, dataProjectId, onClose } = props; + const trpc = useHostTRPC(); + const { data: detail, isLoading } = useQuery( + trpc.productView.getElementDetail.queryOptions({ + viewId, + pageUrl: selected.pageUrl, + element: selected.element, + }), + ); + + const element = selected.element; + const title = + element.text || element.dataAttr || element.id || `<${element.tag}>`; + const linkOverrides = { projectId: dataProjectId }; + + return ( +
+
+
+ + {title} + +
+ {element.tag} + {element.dataAttr && ( + + {element.dataAttr} + + )} +
+
+ +
+ +
+ {isLoading && ( +
+ +
+ )} + {detail && ( + <> +
+ {detail.trend.length === 0 ? ( + + ) : ( + <> + +
+ + + {compact( + detail.trend.reduce((sum, t) => sum + t.clicks, 0), + )}{" "} + clicks + + + + {compact( + Math.max(...detail.trend.map((t) => t.users), 0), + )}{" "} + users/day peak + +
+ + )} + {detail.totals && + detail.totals.rageclicks + detail.totals.deadclicks > 0 && ( + + + {compact(detail.totals.rageclicks)} rage ·{" "} + {compact(detail.totals.deadclicks)} dead clicks (7d) + + )} +
+ +
+ {detail.errors.length === 0 ? ( + + ) : ( + detail.errors.map((issue) => { + const url = errorTrackingIssueUrl( + issue.issueId, + linkOverrides, + ); + return ( + + ); + }) + )} +
+ +
+ {detail.liveLatency ? ( +
+ p50 {Math.round(detail.liveLatency.p50)}ms + p95 {Math.round(detail.liveLatency.p95)}ms + p99 {Math.round(detail.liveLatency.p99)}ms + + ({detail.liveLatency.count} live reqs) + +
+ ) : ( + + )} + {detail.vitals && ( + + Page p75: INP {Math.round(detail.vitals.inpP75)}ms · LCP{" "} + {Math.round(detail.vitals.lcpP75)}ms + + )} +
+ +
+ {detail.recentRequests.length === 0 ? ( + + ) : ( + detail.recentRequests + .slice(-8) + .reverse() + .map((request) => ( +
+ + {request.method}{" "} + {request.url.replace(/^https?:\/\/[^/]+/, "")} + + + {request.status ?? "…"} + {request.durationMs != null && + ` · ${request.durationMs}ms`} + {request.traceId && " · trace"} + +
+ )) + )} +
+ +
+ {detail.sessions.length === 0 ? ( + + ) : ( + detail.sessions.map((session) => { + const url = replayUrl(session.sessionId, linkOverrides); + return ( + + ); + }) + )} +
+ + {props.investigateSlot} + + )} +
+
+ ); +} diff --git a/packages/ui/src/features/product-view/ProductView.tsx b/packages/ui/src/features/product-view/ProductView.tsx index b97f79a4c8..789e474ad8 100644 --- a/packages/ui/src/features/product-view/ProductView.tsx +++ b/packages/ui/src/features/product-view/ProductView.tsx @@ -30,12 +30,14 @@ import { useProjects } from "@posthog/ui/features/projects/useProjects"; import { useHostCapabilities } from "@posthog/ui/shell/useHostCapabilities"; import { useMutation } from "@tanstack/react-query"; import { useEffect, useMemo, useState } from "react"; +import { ElementDetailsPanel } from "./ElementDetailsPanel"; import { ProductEnvironmentPicker } from "./ProductEnvironmentPicker"; import { useProductEnvironments, useRemoveProductEnvironment, } from "./useProductEnvironments"; import { useProductViewPageState, useProductViewSlot } from "./useProductView"; +import { useSelectedElement } from "./useSelectedElement"; function isLocalOrigin(origin: string): boolean { return origin.includes("//localhost") || origin.includes("//127.0.0.1"); @@ -143,6 +145,7 @@ function ProductBrowser(props: { const [inspecting, setInspecting] = useState(false); const [draftUrl, setDraftUrl] = useState(null); + const { selected, clear: clearSelected } = useSelectedElement(viewId); const currentUrl = pageState?.url || initialUrl; // Remember where this environment is parked (debounced) so the tab restores @@ -269,8 +272,18 @@ function ProductBrowser(props: { Data: {dataProjectName}
- {/* The native browser view is glued to this slot's rect by the host. */} -
+
+ {/* The native browser view is glued to this slot's rect by the host. */} +
+ {selected && ( + + )} +
); } diff --git a/packages/ui/src/features/product-view/useSelectedElement.ts b/packages/ui/src/features/product-view/useSelectedElement.ts new file mode 100644 index 0000000000..ad241d7f64 --- /dev/null +++ b/packages/ui/src/features/product-view/useSelectedElement.ts @@ -0,0 +1,35 @@ +import { useHostTRPC } from "@posthog/host-router/react"; +import { useSubscription } from "@trpc/tanstack-react-query"; +import { useState } from "react"; + +export interface SelectedElement { + pageUrl: string; + element: { + selectorHash: string; + tag: string; + dataAttr: string | null; + id: string | null; + classes: string[]; + href: string | null; + text: string | null; + nthChildPath: string; + }; +} + +/** The element the user selected in inspect mode, from the host event stream. */ +export function useSelectedElement(viewId: string) { + const trpc = useHostTRPC(); + const [selected, setSelected] = useState(null); + + useSubscription( + trpc.productView.onEvents.subscriptionOptions(undefined, { + onData: (event) => { + if (event.type === "element-selected" && event.viewId === viewId) { + setSelected({ pageUrl: event.pageUrl, element: event.element }); + } + }, + }), + ); + + return { selected, clear: () => setSelected(null) }; +} diff --git a/packages/ui/src/utils/posthogLinks.ts b/packages/ui/src/utils/posthogLinks.ts index 1b2227fcdd..1ad640cbd3 100644 --- a/packages/ui/src/utils/posthogLinks.ts +++ b/packages/ui/src/utils/posthogLinks.ts @@ -222,6 +222,16 @@ export function parseShareLink(href: string): ShareLinkTarget | null { return null; } +export function replayUrl( + sessionId: string, + overrides?: LinkOverrides, +): string | null { + return withProjectId( + (pid) => `/project/${pid}/replay/${encodeURIComponent(sessionId)}`, + overrides, + ); +} + export function errorTrackingIssueUrl( issueId: string, overrides?: ErrorTrackingIssueLinkOverrides, From 7b4ff94e43f5284774b92183a38af73d1211068d Mon Sep 17 00:00:00 2001 From: Daniel Visca Date: Fri, 31 Jul 2026 11:50:35 -0400 Subject: [PATCH 04/10] feat(product-view): investigate task creation + deterministic PR impact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 4: act on what the overlay shows. - ProductCodeContextService (workspace-server): grep the local checkout for the element's stable keys (data-attr/id/text via git grep), surface recently-merged PRs from squash-commit subjects in `git log -- ` (pure parsing, tested) and open PRs whose diff touches those files via the gh CLI — degrading to git-only history when gh is unavailable - buildInvestigatePrompt (core, tested): verify-then-conclude prompt seeding page URL, element keys, usage/frustration totals, correlated error issue IDs, session IDs, live trace IDs, source files, and implicated PRs, with an ordered procedure over the posthog MCP tools and explicit drift caveats - InvestigateSection in the details panel: open/merged PR list + Investigate button that starts an auto-mode cloud task via the shared inbox task runner Generated-By: PostHog Code Task-Id: 1120fcea-fc07-4c54-8dbb-50b7b712f9cb --- .../product-view/investigatePrompt.test.ts | 85 +++++++ .../src/product-view/investigatePrompt.ts | 133 +++++++++++ .../src/routers/product-view.router.ts | 17 +- .../product-view/ElementDetailsPanel.tsx | 11 +- .../product-view/InvestigateSection.tsx | 218 ++++++++++++++++++ .../src/features/product-view/ProductView.tsx | 1 + packages/workspace-server/src/di/tokens.ts | 3 + .../src/services/product-view/code-context.ts | 168 ++++++++++++++ .../services/product-view/prImpact.test.ts | 83 +++++++ .../src/services/product-view/prImpact.ts | 71 ++++++ .../product-view/product-view.module.ts | 9 +- .../src/services/product-view/schemas.ts | 21 ++ 12 files changed, 815 insertions(+), 5 deletions(-) create mode 100644 packages/core/src/product-view/investigatePrompt.test.ts create mode 100644 packages/core/src/product-view/investigatePrompt.ts create mode 100644 packages/ui/src/features/product-view/InvestigateSection.tsx create mode 100644 packages/workspace-server/src/services/product-view/code-context.ts create mode 100644 packages/workspace-server/src/services/product-view/prImpact.test.ts create mode 100644 packages/workspace-server/src/services/product-view/prImpact.ts diff --git a/packages/core/src/product-view/investigatePrompt.test.ts b/packages/core/src/product-view/investigatePrompt.test.ts new file mode 100644 index 0000000000..774f875458 --- /dev/null +++ b/packages/core/src/product-view/investigatePrompt.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { buildInvestigatePrompt } from "./investigatePrompt"; + +const context = { + pageUrl: "https://posthog.com/", + environmentLabel: "Production", + dataProjectId: 2, + element: { + tag: "a", + dataAttr: null, + id: null, + href: "https://us.posthog.com", + text: "Open PostHog", + }, + totals: { clicks: 32413, rageclicks: 120, deadclicks: 3 }, + errors: [ + { + issueId: "019f6b23-95c1", + types: ["Error"], + occurrences: 13066, + affectedUsers: 970, + }, + ], + sessionIds: ["019fb8ce-328e"], + traceIds: ["21edb3a025a9ecd32adf3e5d7548a4f4"], + liveLatency: { count: 12, p50: 120, p95: 480, p99: 900 }, + sourceFiles: ["src/components/Nav.tsx"], + mergedPrs: [ + { number: 123, title: "feat: nav", url: "https://github.com/x/y/pull/123" }, + ], + openPrs: [ + { + number: 456, + title: "wip: nav v2", + url: "https://github.com/x/y/pull/456", + }, + ], +}; + +describe("buildInvestigatePrompt", () => { + const prompt = buildInvestigatePrompt(context); + + it("seeds every concrete identifier the agent needs", () => { + expect(prompt).toContain("https://posthog.com/"); + expect(prompt).toContain('href="https://us.posthog.com"'); + expect(prompt).toContain("Open PostHog"); + expect(prompt).toContain("019f6b23-95c1"); + expect(prompt).toContain("019fb8ce-328e"); + expect(prompt).toContain("21edb3a025a9ecd32adf3e5d7548a4f4"); + expect(prompt).toContain("src/components/Nav.tsx"); + expect(prompt).toContain("https://github.com/x/y/pull/123"); + expect(prompt).toContain("https://github.com/x/y/pull/456"); + expect(prompt).toContain("project 2"); + }); + + it("instructs verify-then-conclude with the posthog MCP tools", () => { + expect(prompt).toMatch(/do not trust the seeded numbers/i); + expect(prompt).toContain("execute-sql"); + expect(prompt).toContain("error-tracking"); + expect(prompt).toContain("apm"); + expect(prompt).toContain("session-recording"); + }); + + it("states the drift caveat and asks for a structured readout", () => { + expect(prompt).toMatch(/checkout may drift/i); + expect(prompt).toMatch(/root cause/i); + }); + + it("omits empty sections rather than rendering placeholders", () => { + const bare = buildInvestigatePrompt({ + ...context, + errors: [], + traceIds: [], + sourceFiles: [], + mergedPrs: [], + openPrs: [], + liveLatency: null, + totals: null, + }); + expect(bare).not.toContain("Correlated error issues"); + expect(bare).not.toContain("Trace IDs"); + expect(bare).not.toContain("undefined"); + expect(bare).not.toContain("null"); + }); +}); diff --git a/packages/core/src/product-view/investigatePrompt.ts b/packages/core/src/product-view/investigatePrompt.ts new file mode 100644 index 0000000000..b62490825d --- /dev/null +++ b/packages/core/src/product-view/investigatePrompt.ts @@ -0,0 +1,133 @@ +/** + * The Investigate task prompt: everything the Product View knows about a + * selected element, packed for an agent with the PostHog MCP tools. This is a + * product surface — treat it as tested code. + * + * Prompting principles (reliability over flair): + * - seed concrete, re-derivable identifiers, never summaries alone + * - verify-then-conclude: the agent must re-derive the numbers with MCP tools + * before drawing conclusions + * - an ordered, bounded procedure with an explicit report shape + * - state known caveats (checkout/deploy drift) in the prompt itself + */ + +export interface InvestigateContext { + pageUrl: string; + environmentLabel: string; + dataProjectId: number; + element: { + tag: string; + dataAttr: string | null; + id: string | null; + href: string | null; + text: string | null; + }; + totals: { clicks: number; rageclicks: number; deadclicks: number } | null; + errors: Array<{ + issueId: string; + types: string[]; + occurrences: number; + affectedUsers: number; + }>; + sessionIds: string[]; + traceIds: string[]; + liveLatency: { count: number; p50: number; p95: number; p99: number } | null; + sourceFiles: string[]; + mergedPrs: Array<{ number: number; title: string; url: string }>; + openPrs: Array<{ number: number; title: string; url: string }>; +} + +function describeElement(element: InvestigateContext["element"]): string { + const parts = [`<${element.tag}>`]; + if (element.dataAttr) parts.push(`data-attr="${element.dataAttr}"`); + if (element.id) parts.push(`id="${element.id}"`); + if (element.href) parts.push(`href="${element.href}"`); + if (element.text) parts.push(`text "${element.text}"`); + return parts.join(" "); +} + +export function buildInvestigatePrompt(ctx: InvestigateContext): string { + const sections: string[] = []; + + sections.push( + `Investigate the health of a UI element in our product and report what (if anything) needs fixing. + +Element: ${describeElement(ctx.element)} +Page: ${ctx.pageUrl} +Environment: ${ctx.environmentLabel} +PostHog data lives in project ${ctx.dataProjectId}. Time range of the seeded data: last 7 days (usage trend: 30 days).`, + ); + + const seeded: string[] = []; + if (ctx.totals) { + seeded.push( + `- Interactions (7d): ${ctx.totals.clicks} clicks, ${ctx.totals.rageclicks} rage clicks, ${ctx.totals.deadclicks} dead clicks`, + ); + } + if (ctx.liveLatency) { + seeded.push( + `- Live-sampled request latency: p50 ${Math.round(ctx.liveLatency.p50)}ms, p95 ${Math.round(ctx.liveLatency.p95)}ms, p99 ${Math.round(ctx.liveLatency.p99)}ms over ${ctx.liveLatency.count} requests`, + ); + } + if (ctx.errors.length > 0) { + seeded.push( + "- Correlated error issues (exceptions in sessions that used this element):", + ...ctx.errors.map( + (issue) => + ` - issue ${issue.issueId} (${issue.types.join(", ") || "Error"}): ${issue.occurrences} occurrences, ${issue.affectedUsers} affected users`, + ), + ); + } + if (ctx.sessionIds.length > 0) { + seeded.push(`- Example session IDs: ${ctx.sessionIds.join(", ")}`); + } + if (ctx.traceIds.length > 0) { + seeded.push( + `- Trace IDs captured from requests this element triggered: ${ctx.traceIds.join(", ")}`, + ); + } + if (seeded.length > 0) { + sections.push(`Seeded observations:\n${seeded.join("\n")}`); + } + + const code: string[] = []; + if (ctx.sourceFiles.length > 0) { + code.push( + `- Source files referencing this element's stable keys: ${ctx.sourceFiles.join(", ")}`, + ); + } + if (ctx.mergedPrs.length > 0) { + code.push( + "- Recently merged PRs touching those files:", + ...ctx.mergedPrs.map((pr) => ` - #${pr.number} ${pr.title} — ${pr.url}`), + ); + } + if (ctx.openPrs.length > 0) { + code.push( + "- Open PRs touching those files:", + ...ctx.openPrs.map((pr) => ` - #${pr.number} ${pr.title} — ${pr.url}`), + ); + } + if (code.length > 0) { + sections.push( + `Code context (from the local checkout):\n${code.join("\n")}`, + ); + } + + sections.push( + `Procedure — follow in order, verify before concluding. Do not trust the seeded numbers: re-derive them with the posthog MCP tools (execute-sql against project ${ctx.dataProjectId}; the error-tracking tools for the issue IDs; the apm tools — query-apm-spans / apm-trace-get — for the trace IDs; session-recording tools for the sessions) before drawing any conclusion. + +1. Read the listed source files first and confirm how this element and its handlers are wired. +2. Re-derive the element's usage and frustration numbers with execute-sql (event = '$autocapture', match the element via elements_chain). +3. Pull each correlated error issue and judge whether it is actually caused by this element or merely co-occurs in the same sessions. +4. Follow the trace IDs across services with the apm tools; identify which backend endpoints/services this element exercises and their error/latency profile. +5. Check the listed PRs (and any others touching the code paths you traced) for changes that could explain what you found. +6. Watch one example session recording if the errors need user-behaviour context. + +Caveats: the local checkout may drift from the deployed version — treat source locations as approximate and say so where it matters. The trace IDs came from live browsing just now; historical traces may differ. + +Report back with: the root cause (or a clean bill of health), affected-user impact, the candidate fix (file + approach), and which PRs are implicated. Keep it evidence-first — every claim tied to a query, trace, or file you actually inspected.`, + ); + + return sections.join("\n\n"); +} diff --git a/packages/host-router/src/routers/product-view.router.ts b/packages/host-router/src/routers/product-view.router.ts index 33a95bc346..b2ad6396b1 100644 --- a/packages/host-router/src/routers/product-view.router.ts +++ b/packages/host-router/src/routers/product-view.router.ts @@ -13,8 +13,14 @@ import { } from "@posthog/core/product-view/schemas"; import type { ServiceResolver } from "@posthog/host-trpc/context"; import { publicProcedure, router } from "@posthog/host-trpc/trpc"; -import { PRODUCT_ENVIRONMENTS_SERVICE } from "@posthog/workspace-server/di/tokens"; import { + PRODUCT_CODE_CONTEXT_SERVICE, + PRODUCT_ENVIRONMENTS_SERVICE, +} from "@posthog/workspace-server/di/tokens"; +import type { IProductCodeContextService } from "@posthog/workspace-server/services/product-view/code-context"; +import { + elementCodeContextInput, + elementCodeContextSchema, listProductEnvironmentsInput, productEnvironmentSchema, removeProductEnvironmentInput, @@ -28,6 +34,8 @@ const view = (container: ServiceResolver) => container.get(PRODUCT_VIEW_SERVICE); const envs = (container: ServiceResolver) => container.get(PRODUCT_ENVIRONMENTS_SERVICE); +const codeContext = (container: ServiceResolver) => + container.get(PRODUCT_CODE_CONTEXT_SERVICE); export const productViewRouter = router({ // ── Environments: which sites this project's Product tab can browse ── @@ -111,6 +119,13 @@ export const productViewRouter = router({ .output(elementDetailSchema) .query(({ ctx, input }) => view(ctx.container).getElementDetail(input)), + getElementCodeContext: publicProcedure + .input(elementCodeContextInput) + .output(elementCodeContextSchema) + .query(({ ctx, input }) => + codeContext(ctx.container).getElementCodeContext(input), + ), + onEvents: publicProcedure.subscription(async function* (opts) { for await (const event of view(opts.ctx.container).events(opts.signal)) { yield event; diff --git a/packages/ui/src/features/product-view/ElementDetailsPanel.tsx b/packages/ui/src/features/product-view/ElementDetailsPanel.tsx index 76285b9e5f..c664c708db 100644 --- a/packages/ui/src/features/product-view/ElementDetailsPanel.tsx +++ b/packages/ui/src/features/product-view/ElementDetailsPanel.tsx @@ -14,6 +14,7 @@ import { } from "@posthog/ui/utils/posthogLinks"; import { useQuery } from "@tanstack/react-query"; import type { ReactNode } from "react"; +import { InvestigateSection } from "./InvestigateSection"; import type { SelectedElement } from "./useSelectedElement"; const compact = (value: number): string => @@ -60,10 +61,10 @@ export function ElementDetailsPanel(props: { viewId: string; selected: SelectedElement; dataProjectId: number; + environmentLabel: string; onClose: () => void; - investigateSlot?: ReactNode; }) { - const { viewId, selected, dataProjectId, onClose } = props; + const { viewId, selected, dataProjectId, environmentLabel, onClose } = props; const trpc = useHostTRPC(); const { data: detail, isLoading } = useQuery( trpc.productView.getElementDetail.queryOptions({ @@ -244,7 +245,11 @@ export function ElementDetailsPanel(props: { )} - {props.investigateSlot} + )}
diff --git a/packages/ui/src/features/product-view/InvestigateSection.tsx b/packages/ui/src/features/product-view/InvestigateSection.tsx new file mode 100644 index 0000000000..34bbdc229a --- /dev/null +++ b/packages/ui/src/features/product-view/InvestigateSection.tsx @@ -0,0 +1,218 @@ +import { GitPullRequestIcon, MagnifyingGlassIcon } from "@phosphor-icons/react"; +import { + buildInvestigatePrompt, + type InvestigateContext, +} from "@posthog/core/product-view/investigatePrompt"; +import type { ElementDetail } from "@posthog/core/product-view/schemas"; +import type { TaskCreationInput } from "@posthog/core/task-detail/taskService"; +import { useHostTRPC } from "@posthog/host-router/react"; +import { Button, Spinner } from "@posthog/quill"; +import { useFolders } from "@posthog/ui/features/folders/useFolders"; +import { + type InboxCloudTaskInputContext, + useInboxCloudTaskRunner, +} from "@posthog/ui/features/inbox/hooks/useInboxCloudTaskRunner"; +import { useUserRepositoryIntegration } from "@posthog/ui/features/integrations/useIntegrations"; +import { + resolveDefaultCloudRepository, + useSettingsStore, +} from "@posthog/ui/features/settings/settingsStore"; +import { openExternalUrl } from "@posthog/ui/shell/openExternal"; +import { useQuery } from "@tanstack/react-query"; +import { useCallback, useMemo } from "react"; +import type { SelectedElement } from "./useSelectedElement"; + +/** Stable, grep-able keys identifying this element in source code. */ +function needlesFor(element: SelectedElement["element"]): string[] { + return [element.dataAttr, element.id, element.text].filter( + (needle): needle is string => !!needle && needle.length >= 3, + ); +} + +/** + * Deterministic PR impact (grep the local checkout → git history + open PRs + * touching those files) and the Investigate CTA — a cloud task seeded with + * everything the panel knows, instructed to verify it all with the posthog + * MCP tools before concluding. + */ +export function InvestigateSection(props: { + selected: SelectedElement; + detail: ElementDetail; + environmentLabel: string; +}) { + const { selected, detail, environmentLabel } = props; + const trpc = useHostTRPC(); + const { folders } = useFolders(); + const repoPath = folders[0]?.path ?? null; + const needles = useMemo(() => needlesFor(selected.element), [selected]); + + const { data: codeContext } = useQuery( + trpc.productView.getElementCodeContext.queryOptions( + { repoPath: repoPath ?? "", needles }, + { enabled: !!repoPath && needles.length > 0 }, + ), + ); + + const { repositories } = useUserRepositoryIntegration(); + const lastUsedCloudRepository = useSettingsStore( + (state) => state.lastUsedCloudRepository, + ); + const cloudRepository = useMemo( + () => resolveDefaultCloudRepository(repositories, lastUsedCloudRepository), + [repositories, lastUsedCloudRepository], + ); + + const prompt = useMemo(() => { + const context: InvestigateContext = { + pageUrl: selected.pageUrl, + environmentLabel, + dataProjectId: detail.dataProjectId, + element: selected.element, + totals: detail.totals, + errors: detail.errors, + sessionIds: detail.sessions.map((s) => s.sessionId), + traceIds: [ + ...new Set( + detail.recentRequests + .map((r) => r.traceId) + .filter((t): t is string => !!t), + ), + ].slice(0, 5), + liveLatency: detail.liveLatency, + sourceFiles: codeContext?.files ?? [], + mergedPrs: codeContext?.mergedPrs ?? [], + openPrs: codeContext?.openPrs ?? [], + }; + return buildInvestigatePrompt(context); + }, [selected, detail, codeContext, environmentLabel]); + + const buildInput = useCallback( + (ctx: InboxCloudTaskInputContext): TaskCreationInput => ({ + content: prompt, + taskDescription: `Investigate: ${ + selected.element.text ?? + selected.element.dataAttr ?? + `<${selected.element.tag}>` + } on ${selected.pageUrl}`, + repository: ctx.cloudRepository, + githubUserIntegrationId: ctx.githubUserIntegrationId ?? undefined, + workspaceMode: "cloud", + executionMode: "auto", + adapter: ctx.adapter, + model: ctx.model, + reasoningLevel: ctx.reasoningLevel, + }), + [prompt, selected], + ); + + const { run, isRunning } = useInboxCloudTaskRunner({ + cloudRepository, + // Investigation is PostHog-MCP-first; a missing repo shouldn't block it. + allowMissingRepository: true, + loggerScope: "product-view-investigate", + copy: { + loadingTitle: "Starting investigation…", + errorTitle: "Failed to start investigation", + missingRepository: "Connect a GitHub repository first", + missingIntegration: "Connect a GitHub integration first", + signedOut: "Sign in to start an investigation", + missingModel: + "Couldn't resolve a default model. Open the task page once and pick a model, then try again.", + }, + buildInput, + }); + + return ( +
+
+ + Pull requests + + {!repoPath && ( + + Add a local folder for this repo to map this element to code and + PRs. + + )} + {repoPath && !codeContext && needles.length > 0 && } + {repoPath && needles.length === 0 && ( + + This element has no stable key (data-attr, id, or text) to search + the code for. + + )} + {codeContext && ( + <> + {codeContext.openPrs.map((pr) => ( + + ))} + {codeContext.mergedPrs.map((pr) => ( + + ))} + {codeContext.openPrs.length === 0 && + codeContext.mergedPrs.length === 0 && ( + + {codeContext.files.length === 0 + ? "No source files reference this element's keys." + : "No recent PRs touched this element's source files."} + + )} + {!codeContext.openPrsAvailable && ( + + Open-PR lookup unavailable (gh CLI) — merged history shown from + git. + + )} + {codeContext.files.length > 0 && ( + + Source: {codeContext.files.slice(0, 3).join(", ")} + {codeContext.files.length > 3 && + ` +${codeContext.files.length - 3} more`} + + )} + + )} +
+ +
+ ); +} + +function PrRow(props: { label: string; title: string; url: string }) { + return ( + + ); +} diff --git a/packages/ui/src/features/product-view/ProductView.tsx b/packages/ui/src/features/product-view/ProductView.tsx index 789e474ad8..94f50d0533 100644 --- a/packages/ui/src/features/product-view/ProductView.tsx +++ b/packages/ui/src/features/product-view/ProductView.tsx @@ -280,6 +280,7 @@ function ProductBrowser(props: { viewId={viewId} selected={selected} dataProjectId={environment.dataProjectId} + environmentLabel={environment.label} onClose={clearSelected} /> )} diff --git a/packages/workspace-server/src/di/tokens.ts b/packages/workspace-server/src/di/tokens.ts index 06d42531af..eb34bf267e 100644 --- a/packages/workspace-server/src/di/tokens.ts +++ b/packages/workspace-server/src/di/tokens.ts @@ -20,3 +20,6 @@ export const BROWSER_TABS_SERVICE = Symbol.for( export const PRODUCT_ENVIRONMENTS_SERVICE = Symbol.for( "posthog.workspace.product-environments-service", ); +export const PRODUCT_CODE_CONTEXT_SERVICE = Symbol.for( + "posthog.workspace.product-code-context-service", +); diff --git a/packages/workspace-server/src/services/product-view/code-context.ts b/packages/workspace-server/src/services/product-view/code-context.ts new file mode 100644 index 0000000000..c02c1b2b6a --- /dev/null +++ b/packages/workspace-server/src/services/product-view/code-context.ts @@ -0,0 +1,168 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { getGitOperationManager } from "@posthog/git/operation-manager"; +import { getRemoteUrl, listFilesContainingText } from "@posthog/git/queries"; +import { injectable } from "inversify"; +import { + aggregateMergedPrs, + filterOpenPrsByFiles, + type MergedPrRef, + type OpenPrRef, + parseGithubRemote, +} from "./prImpact"; + +const execFileAsync = promisify(execFile); + +export interface ElementCodeContext { + /** Repo-relative files that reference the element's stable keys. */ + files: string[]; + mergedPrs: MergedPrRef[]; + openPrs: Array>; + /** False when the gh CLI wasn't available/authenticated — merged history + * (pure git) still works then. */ + openPrsAvailable: boolean; +} + +const MAX_FILES = 12; +const MAX_MERGED_PRS = 8; +const GH_TIMEOUT_MS = 10_000; + +export interface IProductCodeContextService { + getElementCodeContext(input: { + repoPath: string; + needles: string[]; + }): Promise; +} + +/** + * Deterministic element → code → PRs mapping for the details panel: grep the + * local checkout for the element's stable keys (data-attr value, event name), + * then surface recently-merged PRs (squash subjects in `git log -- `) + * and open PRs whose diff touches those files (gh CLI, degrades gracefully). + */ +@injectable() +export class ProductCodeContextService implements IProductCodeContextService { + async getElementCodeContext(input: { + repoPath: string; + needles: string[]; + }): Promise { + const files = await this.findFiles(input.repoPath, input.needles); + if (files.length === 0) { + return { files, mergedPrs: [], openPrs: [], openPrsAvailable: true }; + } + + const remoteUrl = await getRemoteUrl(input.repoPath).catch(() => null); + const remote = remoteUrl ? parseGithubRemote(remoteUrl) : null; + + const [mergedPrs, open] = await Promise.all([ + remote + ? this.mergedPrsTouching(input.repoPath, files, remote) + : Promise.resolve([]), + remote + ? this.openPrsTouching(input.repoPath, remote, files) + : Promise.resolve({ prs: [], available: false }), + ]); + + return { + files, + mergedPrs: mergedPrs.slice(0, MAX_MERGED_PRS), + openPrs: open.prs.map(({ files: _files, ...pr }) => pr), + openPrsAvailable: open.available, + }; + } + + private async findFiles( + repoPath: string, + needles: string[], + ): Promise { + const files = new Set(); + for (const needle of needles.slice(0, 3)) { + if (!needle || needle.length < 3) continue; + const hits = await listFilesContainingText(repoPath, needle).catch( + () => [] as string[], + ); + for (const hit of hits) { + files.add(hit); + if (files.size >= MAX_FILES) return [...files]; + } + } + return [...files]; + } + + private async mergedPrsTouching( + repoPath: string, + files: string[], + remote: { owner: string; repo: string }, + ): Promise { + const manager = getGitOperationManager(); + const output = await manager + .executeRead(repoPath, async (git) => + git.raw([ + "log", + "--since=90.days", + "--date=short", + "--pretty=format:%ad\t%s", + "--", + ...files, + ]), + ) + .catch(() => ""); + const commits = output + .split("\n") + .filter(Boolean) + .map((line) => { + const [date, ...rest] = line.split("\t"); + return { date, subject: rest.join("\t") }; + }); + return aggregateMergedPrs(commits, remote); + } + + private async openPrsTouching( + repoPath: string, + remote: { owner: string; repo: string }, + files: string[], + ): Promise<{ prs: OpenPrRef[]; available: boolean }> { + try { + const { stdout } = await execFileAsync( + "gh", + [ + "pr", + "list", + "--repo", + `${remote.owner}/${remote.repo}`, + "--state", + "open", + "--limit", + "30", + "--json", + "number,title,url,files", + ], + { cwd: repoPath, timeout: GH_TIMEOUT_MS }, + ); + const parsed = JSON.parse(stdout) as Array<{ + number?: number; + title?: string; + url?: string; + files?: Array<{ path?: string }>; + }>; + const prs: OpenPrRef[] = parsed + .filter( + (pr) => + typeof pr.number === "number" && + typeof pr.url === "string" && + Array.isArray(pr.files), + ) + .map((pr) => ({ + number: pr.number as number, + title: pr.title ?? `PR #${pr.number}`, + url: pr.url as string, + files: (pr.files ?? []) + .map((file) => file.path) + .filter((path): path is string => typeof path === "string"), + })); + return { prs: filterOpenPrsByFiles(prs, files), available: true }; + } catch { + return { prs: [], available: false }; + } + } +} diff --git a/packages/workspace-server/src/services/product-view/prImpact.test.ts b/packages/workspace-server/src/services/product-view/prImpact.test.ts new file mode 100644 index 0000000000..7181cf815c --- /dev/null +++ b/packages/workspace-server/src/services/product-view/prImpact.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { + aggregateMergedPrs, + extractPrNumber, + filterOpenPrsByFiles, + parseGithubRemote, +} from "./prImpact"; + +describe("extractPrNumber", () => { + it.each([ + ["feat(tabs): add product tab (#3944)", 3944], + ["fix: a thing (#12) (#13)", 13], // squash of a squash — last wins + ["Merge pull request #55 from x/y", 55], + ["plain subject with no PR", null], + ["not a ref #123 mid-sentence", null], + ])("%s → %s", (subject, expected) => { + expect(extractPrNumber(subject)).toBe(expected); + }); +}); + +describe("parseGithubRemote", () => { + it.each([ + [ + "https://github.com/PostHog/posthog.git", + { owner: "PostHog", repo: "posthog" }, + ], + [ + "git@github.com:PostHog/posthog.git", + { owner: "PostHog", repo: "posthog" }, + ], + [ + "https://github.com/PostHog/posthog", + { owner: "PostHog", repo: "posthog" }, + ], + ["https://gitlab.com/x/y.git", null], + ["nonsense", null], + ])("%s", (remote, expected) => { + expect(parseGithubRemote(remote)).toEqual(expected); + }); +}); + +describe("aggregateMergedPrs", () => { + it("dedupes by PR number keeping the newest commit date, newest first", () => { + const merged = aggregateMergedPrs( + [ + { subject: "feat: one (#10)", date: "2026-07-01" }, + { subject: "fix: follow-up (#10)", date: "2026-07-10" }, + { subject: "feat: two (#20)", date: "2026-07-05" }, + { subject: "chore: no pr", date: "2026-07-06" }, + ], + { owner: "PostHog", repo: "posthog" }, + ); + expect(merged).toEqual([ + { + number: 10, + title: "fix: follow-up", + url: "https://github.com/PostHog/posthog/pull/10", + lastCommitDate: "2026-07-10", + }, + { + number: 20, + title: "feat: two", + url: "https://github.com/PostHog/posthog/pull/20", + lastCommitDate: "2026-07-05", + }, + ]); + }); +}); + +describe("filterOpenPrsByFiles", () => { + const prs = [ + { number: 1, title: "a", url: "u1", files: ["src/a.ts", "src/b.ts"] }, + { number: 2, title: "b", url: "u2", files: ["docs/readme.md"] }, + ]; + it("keeps only PRs touching one of the given files", () => { + expect( + filterOpenPrsByFiles(prs, ["src/b.ts", "src/z.ts"]).map((p) => p.number), + ).toEqual([1]); + }); + it("returns empty when nothing overlaps", () => { + expect(filterOpenPrsByFiles(prs, ["nope.ts"])).toEqual([]); + }); +}); diff --git a/packages/workspace-server/src/services/product-view/prImpact.ts b/packages/workspace-server/src/services/product-view/prImpact.ts new file mode 100644 index 0000000000..eea314ead1 --- /dev/null +++ b/packages/workspace-server/src/services/product-view/prImpact.ts @@ -0,0 +1,71 @@ +/** + * Deterministic "PRs impacting this element" helpers: pure parsing over git + * history (squash-merge subjects carry `(#123)`) and gh CLI output. The + * service composes these; nothing here does I/O. + */ + +export interface MergedPrRef { + number: number; + title: string; + url: string; + lastCommitDate: string; +} + +export interface OpenPrRef { + number: number; + title: string; + url: string; + files: string[]; +} + +/** PR number from a merge/squash commit subject: a trailing `(#123)` (squash + * convention) or a `Merge pull request #123` prefix. */ +export function extractPrNumber(subject: string): number | null { + const squash = subject.match(/\(#(\d+)\)\s*$/); + if (squash) return Number(squash[1]); + const merge = subject.match(/^Merge pull request #(\d+)/); + if (merge) return Number(merge[1]); + return null; +} + +export function parseGithubRemote( + remoteUrl: string, +): { owner: string; repo: string } | null { + const match = remoteUrl.match( + /^(?:https:\/\/github\.com\/|git@github\.com:)([^/]+)\/([^/]+?)(?:\.git)?\/?$/, + ); + if (!match) return null; + return { owner: match[1], repo: match[2] }; +} + +/** Collapse per-file commit history into unique merged PRs, newest first. */ +export function aggregateMergedPrs( + commits: Array<{ subject: string; date: string }>, + remote: { owner: string; repo: string }, +): MergedPrRef[] { + const byNumber = new Map(); + for (const commit of commits) { + const number = extractPrNumber(commit.subject); + if (number == null) continue; + const title = commit.subject.replace(/\s*\(#\d+\)\s*$/, ""); + const existing = byNumber.get(number); + if (existing && existing.lastCommitDate >= commit.date) continue; + byNumber.set(number, { + number, + title, + url: `https://github.com/${remote.owner}/${remote.repo}/pull/${number}`, + lastCommitDate: commit.date, + }); + } + return [...byNumber.values()].sort((a, b) => + b.lastCommitDate.localeCompare(a.lastCommitDate), + ); +} + +export function filterOpenPrsByFiles( + prs: OpenPrRef[], + files: string[], +): OpenPrRef[] { + const wanted = new Set(files); + return prs.filter((pr) => pr.files.some((file) => wanted.has(file))); +} diff --git a/packages/workspace-server/src/services/product-view/product-view.module.ts b/packages/workspace-server/src/services/product-view/product-view.module.ts index dd64b96000..1c7ecf4a09 100644 --- a/packages/workspace-server/src/services/product-view/product-view.module.ts +++ b/packages/workspace-server/src/services/product-view/product-view.module.ts @@ -1,9 +1,16 @@ import { ContainerModule } from "inversify"; -import { PRODUCT_ENVIRONMENTS_SERVICE } from "../../di/tokens"; +import { + PRODUCT_CODE_CONTEXT_SERVICE, + PRODUCT_ENVIRONMENTS_SERVICE, +} from "../../di/tokens"; +import { ProductCodeContextService } from "./code-context"; import { ProductEnvironmentsService } from "./service"; export const productEnvironmentsModule = new ContainerModule(({ bind }) => { bind(PRODUCT_ENVIRONMENTS_SERVICE) .to(ProductEnvironmentsService) .inSingletonScope(); + bind(PRODUCT_CODE_CONTEXT_SERVICE) + .to(ProductCodeContextService) + .inSingletonScope(); }); diff --git a/packages/workspace-server/src/services/product-view/schemas.ts b/packages/workspace-server/src/services/product-view/schemas.ts index 623a424a23..728b4f5d78 100644 --- a/packages/workspace-server/src/services/product-view/schemas.ts +++ b/packages/workspace-server/src/services/product-view/schemas.ts @@ -28,3 +28,24 @@ export const touchProductEnvironmentInput = z.object({ id: z.string(), currentUrl: z.string().max(4000), }); + +export const elementCodeContextInput = z.object({ + repoPath: z.string().min(1), + needles: z.array(z.string().max(300)).max(5), +}); + +export const elementCodeContextSchema = z.object({ + files: z.array(z.string()), + mergedPrs: z.array( + z.object({ + number: z.number(), + title: z.string(), + url: z.string(), + lastCommitDate: z.string(), + }), + ), + openPrs: z.array( + z.object({ number: z.number(), title: z.string(), url: z.string() }), + ), + openPrsAvailable: z.boolean(), +}); From c7d5e9af86d04a09e5361352bf580e860c641f23 Mon Sep 17 00:00:00 2001 From: Daniel Visca Date: Fri, 31 Jul 2026 12:01:39 -0400 Subject: [PATCH 05/10] feat(product-view): signed-out empty state for the Product tab Generated-By: PostHog Code Task-Id: 1120fcea-fc07-4c54-8dbb-50b7b712f9cb --- .../src/features/product-view/ProductView.tsx | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/features/product-view/ProductView.tsx b/packages/ui/src/features/product-view/ProductView.tsx index 94f50d0533..6fdaca4b9a 100644 --- a/packages/ui/src/features/product-view/ProductView.tsx +++ b/packages/ui/src/features/product-view/ProductView.tsx @@ -80,7 +80,25 @@ export function ProductView() { ); } - if (projectId == null || isLoading) return null; + if (projectId == null) { + return ( + + + + + + Sign in to open your product + + The Product tab browses your live site (or a local checkout) with + your PostHog project's analytics overlaid on it. Connect your + PostHog account to pick a project first. + + + + ); + } + + if (isLoading) return null; if (!activeEnv || adding) { return ( From f14b627768b5f28eaaf2e6e8553a66afc3947390 Mon Sep 17 00:00:00 2001 From: Daniel Visca Date: Fri, 31 Jul 2026 12:44:53 -0400 Subject: [PATCH 06/10] =?UTF-8?q?fix(product-view):=20satisfy=20biome=20ci?= =?UTF-8?q?=20=E2=80=94=20import=20sorting,=20formatting,=20sparkline=20a1?= =?UTF-8?q?1y=20role?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quality check runs `biome ci` repo-wide, which is stricter than the scoped `biome check` used during development: import/export sorting in the DI wiring files and shared index, formatting in the host containers, and role="img" so aria-label is valid on the sparkline div. Generated-By: PostHog Code Task-Id: 1120fcea-fc07-4c54-8dbb-50b7b712f9cb --- apps/code/src/main/di/bindings.ts | 4 ++-- apps/code/src/main/di/container.ts | 8 ++++---- apps/code/src/renderer/desktop-services.ts | 10 ++++------ apps/web/src/web-container.ts | 10 ++++------ packages/shared/src/index.ts | 8 ++++---- .../src/features/product-view/ElementDetailsPanel.tsx | 6 +++++- 6 files changed, 23 insertions(+), 23 deletions(-) diff --git a/apps/code/src/main/di/bindings.ts b/apps/code/src/main/di/bindings.ts index 2e6b196a81..d171ab6e2b 100644 --- a/apps/code/src/main/di/bindings.ts +++ b/apps/code/src/main/di/bindings.ts @@ -115,11 +115,11 @@ import type { APP_METRICS_SERVICE } from "@posthog/platform/app-metrics"; import type { BUNDLED_RESOURCES_SERVICE } from "@posthog/platform/bundled-resources"; import type { CLIPBOARD_SERVICE } from "@posthog/platform/clipboard"; import type { CONTEXT_MENU_SERVICE } from "@posthog/platform/context-menu"; -import type { EMBEDDED_BROWSER } from "@posthog/platform/embedded-browser"; import type { CRYPTO_SERVICE } from "@posthog/platform/crypto"; import type { DEEP_LINK_SERVICE } from "@posthog/platform/deep-link"; import type { DEV_HOST_ACTIONS_SERVICE } from "@posthog/platform/dev-host-actions"; import type { DIALOG_SERVICE } from "@posthog/platform/dialog"; +import type { EMBEDDED_BROWSER } from "@posthog/platform/embedded-browser"; import type { FILE_ICON_SERVICE } from "@posthog/platform/file-icon"; import type { IMAGE_PROCESSOR_SERVICE } from "@posthog/platform/image-processor"; import type { MAIN_WINDOW_SERVICE } from "@posthog/platform/main-window"; @@ -234,10 +234,10 @@ import type { ElectronAppMetrics } from "../platform-adapters/electron-app-metri import type { ElectronBundledResources } from "../platform-adapters/electron-bundled-resources"; import type { ElectronClipboard } from "../platform-adapters/electron-clipboard"; import type { ElectronContextMenu } from "../platform-adapters/electron-context-menu"; -import type { ElectronEmbeddedBrowser } from "../platform-adapters/electron-embedded-browser"; import type { ElectronCrypto } from "../platform-adapters/electron-crypto"; import type { ElectronDevHostActions } from "../platform-adapters/electron-dev-host-actions"; import type { ElectronDialog } from "../platform-adapters/electron-dialog"; +import type { ElectronEmbeddedBrowser } from "../platform-adapters/electron-embedded-browser"; import type { ElectronFileIcon } from "../platform-adapters/electron-file-icon"; import type { ElectronImageProcessor } from "../platform-adapters/electron-image-processor"; import type { ElectronMainWindow } from "../platform-adapters/electron-main-window"; diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index 34af470a07..100defc581 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -25,7 +25,6 @@ import { MCP_RELAY_EXECUTOR, } from "@posthog/core/cloud-task/identifiers"; import { contextMenuCoreModule } from "@posthog/core/context-menu/context-menu.module"; -import { productViewCoreModule } from "@posthog/core/product-view/product-view.module"; import { CONTEXT_MENU_CONTROLLER, CONTEXT_MENU_EXTERNAL_APPS_SERVICE, @@ -80,6 +79,7 @@ import { type OAuthCallbackReceiver, } from "@posthog/core/oauth/identifiers"; import { oauthModule } from "@posthog/core/oauth/oauth.module"; +import { productViewCoreModule } from "@posthog/core/product-view/product-view.module"; import { PROVISIONING_SERVICE } from "@posthog/core/provisioning/identifiers"; import { ProvisioningService } from "@posthog/core/provisioning/provisioning"; import { SLEEP_SERVICE } from "@posthog/core/sleep/identifiers"; @@ -106,11 +106,11 @@ import { APP_METRICS_SERVICE } from "@posthog/platform/app-metrics"; import { BUNDLED_RESOURCES_SERVICE } from "@posthog/platform/bundled-resources"; import { CLIPBOARD_SERVICE } from "@posthog/platform/clipboard"; import { CONTEXT_MENU_SERVICE } from "@posthog/platform/context-menu"; -import { EMBEDDED_BROWSER } from "@posthog/platform/embedded-browser"; import { CRYPTO_SERVICE } from "@posthog/platform/crypto"; import { DEEP_LINK_SERVICE } from "@posthog/platform/deep-link"; import { DEV_HOST_ACTIONS_SERVICE } from "@posthog/platform/dev-host-actions"; import { DIALOG_SERVICE } from "@posthog/platform/dialog"; +import { EMBEDDED_BROWSER } from "@posthog/platform/embedded-browser"; import { FILE_ICON_SERVICE } from "@posthog/platform/file-icon"; import { IMAGE_PROCESSOR_SERVICE } from "@posthog/platform/image-processor"; import { MAIN_WINDOW_SERVICE } from "@posthog/platform/main-window"; @@ -156,7 +156,6 @@ import { import { authProxyModule } from "@posthog/workspace-server/services/auth-proxy/auth-proxy.module"; import { AUTH_PROXY_AUTH } from "@posthog/workspace-server/services/auth-proxy/identifiers"; import { browserTabsModule } from "@posthog/workspace-server/services/browser-tabs/browser-tabs.module"; -import { productEnvironmentsModule } from "@posthog/workspace-server/services/product-view/product-view.module"; import { claudeCliSessionsModule } from "@posthog/workspace-server/services/claude-cli-sessions/claude-cli-sessions.module"; import { enrichmentModule } from "@posthog/workspace-server/services/enrichment/enrichment.module"; import { @@ -200,6 +199,7 @@ import { POSTHOG_PLUGIN_SERVICE } from "@posthog/workspace-server/services/posth import { posthogPluginModule } from "@posthog/workspace-server/services/posthog-plugin/posthog-plugin.module"; import { PROCESS_TRACKING_SERVICE } from "@posthog/workspace-server/services/process-tracking/identifiers"; import { processTrackingModule } from "@posthog/workspace-server/services/process-tracking/process-tracking.module"; +import { productEnvironmentsModule } from "@posthog/workspace-server/services/product-view/product-view.module"; import { releaseFeedModule } from "@posthog/workspace-server/services/release-feed/release-feed.module"; import { SECURE_STORE_SERVICE } from "@posthog/workspace-server/services/secure-store/identifiers"; import { shellModule } from "@posthog/workspace-server/services/shell/shell.module"; @@ -241,10 +241,10 @@ import { ElectronAppMetrics } from "../platform-adapters/electron-app-metrics"; import { ElectronBundledResources } from "../platform-adapters/electron-bundled-resources"; import { ElectronClipboard } from "../platform-adapters/electron-clipboard"; import { ElectronContextMenu } from "../platform-adapters/electron-context-menu"; -import { ElectronEmbeddedBrowser } from "../platform-adapters/electron-embedded-browser"; import { ElectronCrypto } from "../platform-adapters/electron-crypto"; import { ElectronDevHostActions } from "../platform-adapters/electron-dev-host-actions"; import { ElectronDialog } from "../platform-adapters/electron-dialog"; +import { ElectronEmbeddedBrowser } from "../platform-adapters/electron-embedded-browser"; import { ElectronFileIcon } from "../platform-adapters/electron-file-icon"; import { ElectronImageProcessor } from "../platform-adapters/electron-image-processor"; import { ElectronMainWindow } from "../platform-adapters/electron-main-window"; diff --git a/apps/code/src/renderer/desktop-services.ts b/apps/code/src/renderer/desktop-services.ts index 667d969c13..d398750a7b 100644 --- a/apps/code/src/renderer/desktop-services.ts +++ b/apps/code/src/renderer/desktop-services.ts @@ -446,9 +446,7 @@ container container.bind(SETUP_STORE).toConstantValue(setupStore); -container - .bind(HOST_CAPABILITIES) - .toConstantValue({ - localWorkspaces: true, - embeddedBrowser: true, - } satisfies HostCapabilities); +container.bind(HOST_CAPABILITIES).toConstantValue({ + localWorkspaces: true, + embeddedBrowser: true, +} satisfies HostCapabilities); diff --git a/apps/web/src/web-container.ts b/apps/web/src/web-container.ts index 1e545364ba..40815f37dd 100644 --- a/apps/web/src/web-container.ts +++ b/apps/web/src/web-container.ts @@ -457,12 +457,10 @@ container.bind(POWER_MANAGER_SERVICE).toConstantValue(webPowerManager); // The web host is cloud-only: no local filesystem, so the UI must use remote // (connected-GitHub-org) repositories and cloud workspaces everywhere it would // otherwise reach for local folders/worktrees/terminal. -container - .bind(HOST_CAPABILITIES) - .toConstantValue({ - localWorkspaces: false, - embeddedBrowser: false, - } satisfies HostCapabilities); +container.bind(HOST_CAPABILITIES).toConstantValue({ + localWorkspaces: false, + embeddedBrowser: false, +} satisfies HostCapabilities); container.load(authUiModule); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e3b2eea69b..4d3d8e69be 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -65,10 +65,6 @@ export { type WindowBounds, windowBoundsSchema, } from "./browser-tabs-schemas"; -export { - type ProductEnvironment, - productEnvironmentSchema, -} from "./product-view-schemas"; export type { CloudRunSource, PrAuthorshipMode } from "./cloud"; export { CLOUD_PROMPT_PREFIX, @@ -270,6 +266,10 @@ export { isPrivateIpv4Octets, isPrivateIpv6Literal, } from "./private-network"; +export { + type ProductEnvironment, + productEnvironmentSchema, +} from "./product-view-schemas"; export { type CapabilityNotch, DEFAULT_REASONING_EFFORT, diff --git a/packages/ui/src/features/product-view/ElementDetailsPanel.tsx b/packages/ui/src/features/product-view/ElementDetailsPanel.tsx index c664c708db..eb60f158b4 100644 --- a/packages/ui/src/features/product-view/ElementDetailsPanel.tsx +++ b/packages/ui/src/features/product-view/ElementDetailsPanel.tsx @@ -39,7 +39,11 @@ function EmptyLine({ text }: { text: string }) { function TrendBars(props: { trend: { day: string; clicks: number }[] }) { const max = Math.max(...props.trend.map((t) => t.clicks), 1); return ( -
+
{props.trend.map((point) => (
Date: Fri, 31 Jul 2026 13:00:41 -0400 Subject: [PATCH 07/10] fix(product-view): allow SSO sign-in inside the embedded browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google (and other IdPs) reject OAuth from anything identifying as an embedded webview, and popup-based SSO can't complete when window.open is bounced to the system browser (no shared session). - strip Electron/app tokens from the view's user agent (plain Chrome UA), fixing Google's disallowed_useragent rejection - allow http(s) popups as sandboxed, preload-less child windows on the same persist:product-view partition so popup SSO completes; child windows get the same clean UA, still block non-web schemes, and can't nest popups Verified with an Electron harness: UA drops the Electron token, and window.open('https://accounts.google.com/…') creates a shared-partition child window with a clean UA. Generated-By: PostHog Code Task-Id: 1120fcea-fc07-4c54-8dbb-50b7b712f9cb --- .../electron-embedded-browser.ts | 57 +++++++++++++++++-- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/apps/code/src/main/platform-adapters/electron-embedded-browser.ts b/apps/code/src/main/platform-adapters/electron-embedded-browser.ts index c3ab1c371c..0c5968165b 100644 --- a/apps/code/src/main/platform-adapters/electron-embedded-browser.ts +++ b/apps/code/src/main/platform-adapters/electron-embedded-browser.ts @@ -30,6 +30,24 @@ function isWebUrl(raw: string): boolean { } } +/** + * A standard-Chrome user agent for the embedded product page. Identity + * providers (notably Google) reject OAuth from anything that identifies as an + * embedded webview — the default UA carries `Electron/…` and the app token, + * which triggers `disallowed_useragent`. Stripping those tokens leaves the + * plain Chrome UA this build actually is. + */ +function browserlikeUserAgent(defaultUserAgent: string): string { + return defaultUserAgent + .split(" ") + .filter( + (token) => + !token.startsWith("Electron/") && + !token.toLowerCase().includes("posthog"), + ) + .join(" "); +} + /** * Desktop implementation of the Product View browser surface: one * `WebContentsView` per view id, attached to the single main window. The view @@ -82,6 +100,9 @@ export class ElectronEmbeddedBrowser preload: path.join(__dirname, "productViewPreload.js"), }, }); + view.webContents.setUserAgent( + browserlikeUserAgent(view.webContents.getUserAgent()), + ); this.views.set(options.viewId, view); this.wireEvents(options.viewId, view); window.contentView.addChildView(view); @@ -207,14 +228,42 @@ export class ElectronEmbeddedBrowser wc.on("did-start-loading", push); wc.on("did-stop-loading", push); - // The guest stays a plain web page: block non-web schemes and keep - // popups/new windows in the system browser rather than spawning windows. + // The guest stays a plain web page: block non-web schemes. wc.on("will-navigate", (event, url) => { if (!isWebUrl(url)) event.preventDefault(); }); + // Allow http(s) popups as real (sandboxed, preload-less) child windows on + // the SAME cookie partition — popup-based SSO (Google sign-in) needs the + // popup and the page to share a session, so bouncing it to the system + // browser can never complete the login. Non-web schemes stay denied. wc.setWindowOpenHandler(({ url }) => { - if (isWebUrl(url)) void shell.openExternal(url); - return { action: "deny" }; + if (!isWebUrl(url)) return { action: "deny" }; + return { + action: "allow", + overrideBrowserWindowOptions: { + autoHideMenuBar: true, + webPreferences: { + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + partition: "persist:product-view", + }, + }, + }; + }); + wc.on("did-create-window", (child) => { + // Popups need the same identity-provider-friendly UA as the view. + child.webContents.setUserAgent( + browserlikeUserAgent(child.webContents.getUserAgent()), + ); + child.webContents.on("will-navigate", (event, url) => { + if (!isWebUrl(url)) event.preventDefault(); + }); + // No nested popups from a popup; open anything further externally. + child.webContents.setWindowOpenHandler(({ url }) => { + if (isWebUrl(url)) void shell.openExternal(url); + return { action: "deny" }; + }); }); // Preload → host messages (inspector reports). Validated downstream by From e440bc3198c62912a5c4787dac224bac0aba5e2f Mon Sep 17 00:00:00 2001 From: Daniel Visca Date: Fri, 31 Jul 2026 13:03:59 -0400 Subject: [PATCH 08/10] fix(product-view): inline environment pills + hide the page under toolbar menus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The environment dropdown opened underneath the embedded page — the native WebContentsView paints above every renderer popover, so any menu over the page area was invisible. - environments are now inline toolbar pills: one click switches instantly (each environment keeps its own live view, so page state is preserved), nothing opens over the page in the hot path; "+" adds an environment and right-click on a pill removes it - new productViewObscuredStore: renderer popovers that can overlap the page (the pill context menu, alongside the existing command-menu case) hide the native view while open, with an unmount guard so a leaked acquire can't keep the page hidden Generated-By: PostHog Code Task-Id: 1120fcea-fc07-4c54-8dbb-50b7b712f9cb --- .../src/features/product-view/ProductView.tsx | 132 +++++++++++------- .../product-view/productViewObscuredStore.ts | 21 +++ .../features/product-view/useProductView.ts | 13 +- 3 files changed, 111 insertions(+), 55 deletions(-) create mode 100644 packages/ui/src/features/product-view/productViewObscuredStore.ts diff --git a/packages/ui/src/features/product-view/ProductView.tsx b/packages/ui/src/features/product-view/ProductView.tsx index 6fdaca4b9a..6b6434779f 100644 --- a/packages/ui/src/features/product-view/ProductView.tsx +++ b/packages/ui/src/features/product-view/ProductView.tsx @@ -2,7 +2,6 @@ import { ArrowClockwiseIcon, ArrowLeftIcon, ArrowRightIcon, - CaretDownIcon, CursorClickIcon, GlobeIcon, MonitorIcon, @@ -13,11 +12,10 @@ import { useHostTRPC } from "@posthog/host-router/react"; import { Badge, Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, Empty, EmptyDescription, EmptyHeader, @@ -29,9 +27,10 @@ import type { ProductEnvironment } from "@posthog/shared"; import { useProjects } from "@posthog/ui/features/projects/useProjects"; import { useHostCapabilities } from "@posthog/ui/shell/useHostCapabilities"; import { useMutation } from "@tanstack/react-query"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { ElementDetailsPanel } from "./ElementDetailsPanel"; import { ProductEnvironmentPicker } from "./ProductEnvironmentPicker"; +import { useProductViewObscuredStore } from "./productViewObscuredStore"; import { useProductEnvironments, useRemoveProductEnvironment, @@ -238,51 +237,26 @@ function ProductBrowser(props: { Inspect - - - {isLocalOrigin(environment.pageOrigin) ? ( - - ) : ( - - )} - {environment.label} - - - } + {/* One pill per environment: a click switches instantly (each + environment keeps its own live view). Inline — a popover here + would be hidden under the native page view. */} + {environments.map((env) => ( + onSwitchEnvironment(env.id)} + onRemove={() => removeEnvironment.mutate({ id: env.id })} /> - - {environments.map((env) => ( - onSwitchEnvironment(env.id)} - > - {isLocalOrigin(env.pageOrigin) ? ( - - ) : ( - - )} - {env.label} - - {env.pageOrigin.replace(/^https?:\/\//, "")} - - - ))} - - - - Add environment… - - removeEnvironment.mutate({ id: environment.id })} - > - - Remove this environment - - - + ))} + ); } + +/** Toolbar pill for one environment. Right-click for management actions; + * the context menu hides the native view while open (z-order). */ +function EnvironmentPill(props: { + environment: ProductEnvironment; + active: boolean; + onSelect: () => void; + onRemove: () => void; +}) { + const { environment, active, onSelect, onRemove } = props; + const acquire = useProductViewObscuredStore((s) => s.acquire); + const release = useProductViewObscuredStore((s) => s.release); + // Balance the counter if the pill unmounts while its menu is open (e.g. + // "Remove environment" removes the pill itself) — a leaked acquire would + // keep the page hidden forever. + const holdingRef = useRef(false); + useEffect( + () => () => { + if (holdingRef.current) release(); + }, + [release], + ); + return ( + { + holdingRef.current = open; + if (open) acquire(); + else release(); + }} + > + + {isLocalOrigin(environment.pageOrigin) ? ( + + ) : ( + + )} + {environment.label} + + } + /> + + + + Remove environment + + + + ); +} diff --git a/packages/ui/src/features/product-view/productViewObscuredStore.ts b/packages/ui/src/features/product-view/productViewObscuredStore.ts new file mode 100644 index 0000000000..39b5ef31e1 --- /dev/null +++ b/packages/ui/src/features/product-view/productViewObscuredStore.ts @@ -0,0 +1,21 @@ +import { create } from "zustand"; + +/** + * The embedded product page is a NATIVE view that paints above the renderer, + * so any renderer popover that can overlap its rectangle (menus, pickers) + * must hide the view while open. This counter is that cooperation point: + * acquire() on open, release() on close; the slot hides the view while > 0. + */ +interface ProductViewObscuredState { + count: number; + acquire: () => void; + release: () => void; +} + +export const useProductViewObscuredStore = create( + (set) => ({ + count: 0, + acquire: () => set((state) => ({ count: state.count + 1 })), + release: () => set((state) => ({ count: Math.max(0, state.count - 1) })), + }), +); diff --git a/packages/ui/src/features/product-view/useProductView.ts b/packages/ui/src/features/product-view/useProductView.ts index dba6b764e4..b4f39d802d 100644 --- a/packages/ui/src/features/product-view/useProductView.ts +++ b/packages/ui/src/features/product-view/useProductView.ts @@ -3,6 +3,7 @@ import { useCommandMenuStore } from "@posthog/ui/shell/commandMenuStore"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useSubscription } from "@trpc/tanstack-react-query"; import { useEffect, useRef, useState } from "react"; +import { useProductViewObscuredStore } from "./productViewObscuredStore"; export interface ProductViewPageState { viewId: string; @@ -60,6 +61,7 @@ export function useProductViewSlot(input: { const setBounds = useMutation(trpc.productView.setBounds.mutationOptions()); const setVisible = useMutation(trpc.productView.setVisible.mutationOptions()); const commandMenuOpen = useCommandMenuStore((s) => s.isOpen); + const obscuredCount = useProductViewObscuredStore((s) => s.count); const openMutate = open.mutateAsync; const setBoundsMutate = setBounds.mutate; @@ -124,12 +126,15 @@ export function useProductViewSlot(input: { setVisibleMutate, ]); - // The command menu is a renderer overlay and cannot paint over the native - // view — hide the view while the menu is open. + // Renderer overlays (command menu, toolbar menus) cannot paint over the + // native view — hide the view while any of them is open. useEffect(() => { if (!openedRef.current) return; - setVisibleMutate({ viewId, visible: !commandMenuOpen }); - }, [commandMenuOpen, viewId, setVisibleMutate]); + setVisibleMutate({ + viewId, + visible: !commandMenuOpen && obscuredCount === 0, + }); + }, [commandMenuOpen, obscuredCount, viewId, setVisibleMutate]); return slotRef; } From e1d4e43d256c1880a7f4ff2fdff861181de5f317 Mon Sep 17 00:00:00 2001 From: Daniel Visca Date: Fri, 31 Jul 2026 16:51:03 -0400 Subject: [PATCH 09/10] fix(product-view): PR URLs, toolbar overflow, and mid-login view resets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - parseGithubRemote: trim the remote URL — `git remote get-url` output ends with a newline, which stopped the ".git" suffix from being stripped, so merged-PR links pointed at github.com//.git/pull/N (404) and the gh open-PR lookup got a garbage --repo value. Also accept ssh:// remotes. - toolbar: flex-wrap the row and truncate pill labels / the data badge so environments flow to a second line instead of crushing the URL bar. - stop resetting the embedded page mid-session: create() no longer navigates an existing view back to the environment's debounce-persisted URL, and the slot effect no longer tears down/reopens when the environments query refetches — either could yank a multi-step login back to a stale page. Generated-By: PostHog Code Task-Id: 1120fcea-fc07-4c54-8dbb-50b7b712f9cb --- .../electron-embedded-browser.ts | 11 ++++---- .../src/features/product-view/ProductView.tsx | 12 +++++---- .../features/product-view/useProductView.ts | 25 ++++++++++++------- .../services/product-view/prImpact.test.ts | 10 ++++++++ .../src/services/product-view/prImpact.ts | 10 +++++--- 5 files changed, 46 insertions(+), 22 deletions(-) diff --git a/apps/code/src/main/platform-adapters/electron-embedded-browser.ts b/apps/code/src/main/platform-adapters/electron-embedded-browser.ts index 0c5968165b..e6ed6e1904 100644 --- a/apps/code/src/main/platform-adapters/electron-embedded-browser.ts +++ b/apps/code/src/main/platform-adapters/electron-embedded-browser.ts @@ -77,13 +77,14 @@ export class ElectronEmbeddedBrowser async create(options: EmbeddedBrowserCreateOptions): Promise { const existing = this.views.get(options.viewId); if (existing) { - // Re-opening a kept-alive view (tab switch back): re-glue and re-show it; - // only navigate when the caller actually wants a different page. + // Re-opening a kept-alive view (tab switch back): re-glue and re-show + // it exactly where the user left it. Never navigate here — options.url + // is the environment's SAVED url, which lags the live page (it's + // debounce-persisted), so "restoring" it would yank an in-progress + // flow (a multi-step login, a checkout) back to a stale page. + // Explicit navigation goes through navigate(). this.setBounds(options.viewId, options.bounds); existing.setVisible(true); - if (existing.webContents.getURL() !== options.url) { - await this.navigate(options.viewId, options.url); - } this.emitPageState(options.viewId, existing); return; } diff --git a/packages/ui/src/features/product-view/ProductView.tsx b/packages/ui/src/features/product-view/ProductView.tsx index 6b6434779f..b48054b627 100644 --- a/packages/ui/src/features/product-view/ProductView.tsx +++ b/packages/ui/src/features/product-view/ProductView.tsx @@ -186,7 +186,9 @@ function ProductBrowser(props: { return (
-
+ {/* flex-wrap: with several environments (or a narrow window) the row + flows onto a second line instead of crushing the URL bar. */} +
setDraftUrl(e.target.value)} onFocus={(e) => e.target.select()} @@ -259,9 +261,9 @@ function ProductBrowser(props: { - Data: {dataProjectName} + Data: {dataProjectName}
@@ -323,7 +325,7 @@ function EnvironmentPill(props: { ) : ( )} - {environment.label} + {environment.label} } /> diff --git a/packages/ui/src/features/product-view/useProductView.ts b/packages/ui/src/features/product-view/useProductView.ts index b4f39d802d..1c33d17d95 100644 --- a/packages/ui/src/features/product-view/useProductView.ts +++ b/packages/ui/src/features/product-view/useProductView.ts @@ -67,6 +67,13 @@ export function useProductViewSlot(input: { const setBoundsMutate = setBounds.mutate; const setVisibleMutate = setVisible.mutate; + // url/dataProjectId are only consumed when the view is first opened; they + // must NOT be effect deps. The url is the environment's persisted currentUrl, + // which updates (via query refetch) as the user browses — re-running the + // effect on that would hide/reopen the live view mid-session. + const openParamsRef = useRef({ url, dataProjectId }); + openParamsRef.current = { url, dataProjectId }; + useEffect(() => { const slot = slotRef.current; if (!slot) return; @@ -89,7 +96,14 @@ export function useProductViewSlot(input: { lastRect = key; if (!openedRef.current) { openedRef.current = true; - void openMutate({ viewId, url, bounds, dataProjectId }).catch(() => { + const { url: openUrl, dataProjectId: openProjectId } = + openParamsRef.current; + void openMutate({ + viewId, + url: openUrl, + bounds, + dataProjectId: openProjectId, + }).catch(() => { openedRef.current = false; }); } else { @@ -117,14 +131,7 @@ export function useProductViewSlot(input: { setVisibleMutate({ viewId, visible: false }); openedRef.current = false; }; - }, [ - viewId, - url, - dataProjectId, - openMutate, - setBoundsMutate, - setVisibleMutate, - ]); + }, [viewId, openMutate, setBoundsMutate, setVisibleMutate]); // Renderer overlays (command menu, toolbar menus) cannot paint over the // native view — hide the view while any of them is open. diff --git a/packages/workspace-server/src/services/product-view/prImpact.test.ts b/packages/workspace-server/src/services/product-view/prImpact.test.ts index 7181cf815c..3c04eea64f 100644 --- a/packages/workspace-server/src/services/product-view/prImpact.test.ts +++ b/packages/workspace-server/src/services/product-view/prImpact.test.ts @@ -32,6 +32,16 @@ describe("parseGithubRemote", () => { "https://github.com/PostHog/posthog", { owner: "PostHog", repo: "posthog" }, ], + // `git remote get-url` output has a trailing newline — must not leak + // ".git" into the repo name (broken /pull/ URLs). + [ + "https://github.com/PostHog/posthog.git\n", + { owner: "PostHog", repo: "posthog" }, + ], + [ + "ssh://git@github.com/PostHog/posthog.git", + { owner: "PostHog", repo: "posthog" }, + ], ["https://gitlab.com/x/y.git", null], ["nonsense", null], ])("%s", (remote, expected) => { diff --git a/packages/workspace-server/src/services/product-view/prImpact.ts b/packages/workspace-server/src/services/product-view/prImpact.ts index eea314ead1..a34b7c3a86 100644 --- a/packages/workspace-server/src/services/product-view/prImpact.ts +++ b/packages/workspace-server/src/services/product-view/prImpact.ts @@ -31,9 +31,13 @@ export function extractPrNumber(subject: string): number | null { export function parseGithubRemote( remoteUrl: string, ): { owner: string; repo: string } | null { - const match = remoteUrl.match( - /^(?:https:\/\/github\.com\/|git@github\.com:)([^/]+)\/([^/]+?)(?:\.git)?\/?$/, - ); + // `git remote get-url` output arrives with a trailing newline; without the + // trim the repo group swallows ".git" and every PR URL 404s. + const match = remoteUrl + .trim() + .match( + /^(?:https:\/\/github\.com\/|(?:ssh:\/\/)?git@github\.com[:/])([^/]+)\/([^/]+?)(?:\.git)?\/?$/, + ); if (!match) return null; return { owner: match[1], repo: match[2] }; } From 15f5337b01879f94cea8c7689b87e9f580b99508 Mon Sep 17 00:00:00 2001 From: Daniel Visca Date: Fri, 31 Jul 2026 17:16:45 -0400 Subject: [PATCH 10/10] fix(product-view): dedupe environments by origin, add pill close button, log popup decisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - saveEnvironment now updates in place when the project already has an environment with the same normalized origin — running the picker twice created duplicate pills (no way to end up with three identical us.posthog.com entries anymore) - each environment pill gets a visible ✕ (right-click remove stays) — closing was undiscoverable - the embedded view's window-open handler logs each popup request and verdict so "the SSO popup never opened" is diagnosable from main logs Generated-By: PostHog Code Task-Id: 1120fcea-fc07-4c54-8dbb-50b7b712f9cb --- .../electron-embedded-browser.ts | 2 + .../src/features/product-view/ProductView.tsx | 40 +++++++++++++------ .../src/services/product-view/service.test.ts | 25 +++++++++++- .../src/services/product-view/service.ts | 12 +++++- 4 files changed, 63 insertions(+), 16 deletions(-) diff --git a/apps/code/src/main/platform-adapters/electron-embedded-browser.ts b/apps/code/src/main/platform-adapters/electron-embedded-browser.ts index e6ed6e1904..9e400200a2 100644 --- a/apps/code/src/main/platform-adapters/electron-embedded-browser.ts +++ b/apps/code/src/main/platform-adapters/electron-embedded-browser.ts @@ -238,6 +238,8 @@ export class ElectronEmbeddedBrowser // popup and the page to share a session, so bouncing it to the system // browser can never complete the login. Non-web schemes stay denied. wc.setWindowOpenHandler(({ url }) => { + // Kept at info so "the SSO popup never opened" is diagnosable from logs. + log.info("popup requested", { viewId, url, allowed: isWebUrl(url) }); if (!isWebUrl(url)) return { action: "deny" }; return { action: "allow", diff --git a/packages/ui/src/features/product-view/ProductView.tsx b/packages/ui/src/features/product-view/ProductView.tsx index b48054b627..43a4b6d448 100644 --- a/packages/ui/src/features/product-view/ProductView.tsx +++ b/packages/ui/src/features/product-view/ProductView.tsx @@ -7,6 +7,7 @@ import { MonitorIcon, PlusIcon, TrashIcon, + XIcon, } from "@phosphor-icons/react"; import { useHostTRPC } from "@posthog/host-router/react"; import { @@ -314,19 +315,32 @@ function EnvironmentPill(props: { > - {isLocalOrigin(environment.pageOrigin) ? ( - - ) : ( - - )} - {environment.label} - +
+ + +
} /> diff --git a/packages/workspace-server/src/services/product-view/service.test.ts b/packages/workspace-server/src/services/product-view/service.test.ts index f408a8cdaa..b5109a9190 100644 --- a/packages/workspace-server/src/services/product-view/service.test.ts +++ b/packages/workspace-server/src/services/product-view/service.test.ts @@ -46,6 +46,27 @@ describe("ProductEnvironmentsService", () => { }); }); + it("dedupes by origin within a project: re-adding updates in place", () => { + const first = service.save(input({ label: "Prod" })); + const second = service.save( + input({ label: "Renamed", pageOrigin: "https://us.posthog.com/login" }), + ); + + expect(second.id).toBe(first.id); + const listed = service.list(2); + expect(listed).toHaveLength(1); + expect(listed[0].label).toBe("Renamed"); + }); + + it("keeps distinct origins as distinct environments", () => { + service.save(input()); + service.save( + input({ label: "Local", pageOrigin: "http://localhost:8000" }), + ); + + expect(service.list(2)).toHaveLength(2); + }); + it("scopes list to the requested project", () => { service.save(input({ projectId: 2 })); service.save(input({ projectId: 3, label: "Other" })); @@ -105,7 +126,9 @@ describe("ProductEnvironmentsService", () => { it("lists most recently active first", () => { const a = service.save(input({ label: "A" })); - const b = service.save(input({ label: "B" })); + const b = service.save( + input({ label: "B", pageOrigin: "https://eu.posthog.com" }), + ); service.touch(a.id, "https://us.posthog.com/a", 1000); service.touch(b.id, "https://us.posthog.com/b", 2000); diff --git a/packages/workspace-server/src/services/product-view/service.ts b/packages/workspace-server/src/services/product-view/service.ts index 2f56371bbd..4f72a3d003 100644 --- a/packages/workspace-server/src/services/product-view/service.ts +++ b/packages/workspace-server/src/services/product-view/service.ts @@ -49,12 +49,20 @@ export class ProductEnvironmentsService implements IProductEnvironmentsService { save(input: SaveProductEnvironmentInput): ProductEnvironment { const now = Date.now(); - const existing = input.id ? this.repo.findById(input.id) : null; + const pageOrigin = normalizeOrigin(input.pageOrigin); + // Re-adding a site that's already registered updates it in place — + // running the picker twice must not grow a second identical pill. + const existing = + (input.id ? this.repo.findById(input.id) : null) ?? + this.repo + .listByProject(input.projectId) + .find((env) => env.pageOrigin === pageOrigin) ?? + null; const record: ProductEnvironment = { id: existing?.id ?? input.id ?? crypto.randomUUID(), projectId: input.projectId, label: input.label, - pageOrigin: normalizeOrigin(input.pageOrigin), + pageOrigin, dataProjectId: input.dataProjectId, currentUrl: existing?.currentUrl ?? null, createdAt: existing?.createdAt ?? now,