From f73cbc9ccf1984a7f2200ec54b8c579c960ca956 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Wed, 15 Jul 2026 14:29:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(notebooks):=20experiment=20=E2=80=94=20web?= =?UTF-8?q?app=20Query=20editor=20as=20same-document=20widget?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the PostHog webapp's REAL insight editor (built as a standalone widget bundle from the posthog repo's new frontend/src/widgets entry) in as the EditComponent for notebook Query nodes. Same document, no iframe: the bundle carries its own React 18 + kea and renders into a shadow root, the toolbar pattern applied to the query editor. - widgets/widgetLoader.ts: loads window.PostHogWidgets once via dynamic import; experiment toggle via localStorage key posthog.notebooks.widgetsUrl (e.g. http://localhost:8124/widgets.js). - widgets/QueryWidgetEdit.tsx: EditComponent bridging node.props.query <-> mountQueryEditor. Auth: apiHost from the auth store's cloud region, bearer tokens streamed through hostClient.auth.getValidAccessToken (also used as the widget's 401-refresh callback). Theme follows themeStore. - notebookRegistry.tsx: Query nodes get the widget edit panel (exclusiveEditPanel) when the toggle is set; JSON editor otherwise. - bootstrap.ts: POSTHOG_CODE_USERDATA_DIR override so a second dev instance can run side-by-side (single-instance lock lives in userData). Co-Authored-By: Claude Fable 5 --- apps/code/src/main/bootstrap.ts | 7 +- .../features/notebooks/notebookRegistry.tsx | 14 ++ .../notebooks/widgets/QueryWidgetEdit.tsx | 150 ++++++++++++++++++ .../notebooks/widgets/widgetLoader.ts | 71 +++++++++ 4 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/features/notebooks/widgets/QueryWidgetEdit.tsx create mode 100644 packages/ui/src/features/notebooks/widgets/widgetLoader.ts diff --git a/apps/code/src/main/bootstrap.ts b/apps/code/src/main/bootstrap.ts index 87f850cb82..3b9b4e8fad 100644 --- a/apps/code/src/main/bootstrap.ts +++ b/apps/code/src/main/bootstrap.ts @@ -41,8 +41,13 @@ const appName = isDev ? "posthog-code-dev" : "posthog-code"; app.setName(isDev ? "PostHog Code (Development)" : "PostHog Code"); // Set userData path for @posthog/code +// EXPERIMENT (shared-widgets): POSTHOG_CODE_USERDATA_DIR lets a second dev +// instance run side-by-side with a separate profile (the single-instance lock +// lives in userData). const appDataPath = app.getPath("appData"); -const userDataPath = path.join(appDataPath, "@posthog", appName); +const userDataPath = + process.env.POSTHOG_CODE_USERDATA_DIR ?? + path.join(appDataPath, "@posthog", appName); app.setPath("userData", userDataPath); // Export the electron-derived state to env so utility singletons (utils/*, diff --git a/packages/ui/src/features/notebooks/notebookRegistry.tsx b/packages/ui/src/features/notebooks/notebookRegistry.tsx index 475f5f6f8c..9b266ff1e1 100644 --- a/packages/ui/src/features/notebooks/notebookRegistry.tsx +++ b/packages/ui/src/features/notebooks/notebookRegistry.tsx @@ -28,6 +28,8 @@ import type { NotebookComponentRegistry, NotebookComponentRenderProps, } from "./markdown-notebook/types"; +import { QueryWidgetEdit } from "./widgets/QueryWidgetEdit"; +import { getNotebookWidgetsUrl } from "./widgets/widgetLoader"; // The default (vendored) registry renders PostHog entity blocks as JSON // previews. This registry swaps in live views backed by the real PostHog @@ -70,6 +72,18 @@ export function getNotebooksAppRegistry(): NotebookComponentRegistry { EditComponent: fallbackEditComponent, }; } + // EXPERIMENT (shared-widgets): when a widgets bundle URL is configured, the + // Query node's edit panel becomes the real webapp insight editor (same + // document, shadow root, own React copy). The widget renders its own viz, so + // the edit panel is exclusive. Toggle via localStorage key + // "posthog.notebooks.widgetsUrl". + if (getNotebookWidgetsUrl() && components.Query) { + components.Query = { + ...components.Query, + EditComponent: QueryWidgetEdit, + exclusiveEditPanel: true, + }; + } // Discussion-flavor `` threads render (and reply) // through the same component in both modes — the generic props edit panel // makes no sense for a comment thread. Authorial `` comments render diff --git a/packages/ui/src/features/notebooks/widgets/QueryWidgetEdit.tsx b/packages/ui/src/features/notebooks/widgets/QueryWidgetEdit.tsx new file mode 100644 index 0000000000..a70c0a6ae0 --- /dev/null +++ b/packages/ui/src/features/notebooks/widgets/QueryWidgetEdit.tsx @@ -0,0 +1,150 @@ +// EXPERIMENT (shared-widgets): renders the REAL PostHog webapp query editor +// (InsightViz editor filters: series, taxonomic pickers, breakdown, date range, +// chart type) as the edit UI for notebook Query nodes. The editor runs in the +// same document inside a shadow root with its own React copy — no iframe. +import { useHostTRPCClient } from "@posthog/host-router/react"; +import { getCloudUrlFromRegion } from "@posthog/shared"; +import type { JSX } from "react"; +import { useEffect, useRef, useState } from "react"; +import { useAuthStateValue } from "../../auth/store"; +import { useThemeStore } from "../../../shell/themeStore"; +import type { + NotebookComponentRenderProps, + NotebookPropValue, +} from "../markdown-notebook/types"; +import { + getNotebookWidgetsUrl, + loadPostHogWidgets, + type QueryEditorWidgetHandle, +} from "./widgetLoader"; + +// Bare insight query kinds that the InsightViz editor edits — wrap them so the +// widget always receives a renderable top-level node. +const INSIGHT_SOURCE_KINDS = new Set([ + "TrendsQuery", + "FunnelsQuery", + "RetentionQuery", + "PathsQuery", + "StickinessQuery", + "LifecycleQuery", + "CalendarHeatmapQuery", +]); + +function toEditorQuery(query: unknown): Record | null { + if (!query || typeof query !== "object") { + return null; + } + const node = query as Record; + if (typeof node.kind !== "string") { + return null; + } + if (INSIGHT_SOURCE_KINDS.has(node.kind)) { + return { kind: "InsightVizNode", source: node }; + } + return node; +} + +/** True when the widget wrapped the stored query in an InsightVizNode. */ +function wasWrapped(stored: unknown, editorQuery: Record) { + return ( + !!stored && + typeof stored === "object" && + (stored as Record).kind !== "InsightVizNode" && + editorQuery.kind === "InsightVizNode" + ); +} + +export function QueryWidgetEdit({ + node, + updateProps, +}: NotebookComponentRenderProps): JSX.Element { + const hostClient = useHostTRPCClient(); + const authState = useAuthStateValue((state) => state); + const isDarkMode = useThemeStore((state) => state.isDarkMode); + const containerRef = useRef(null); + const handleRef = useRef(null); + const [error, setError] = useState(null); + + // Keep the latest updateProps reachable from the long-lived widget callback. + const updatePropsRef = useRef(updateProps); + updatePropsRef.current = updateProps; + + const cloudRegion = + authState.status === "authenticated" ? authState.cloudRegion : null; + + // Mount once per node/auth change; interim query edits flow back out through + // onQueryChange (re-mounting on every props.query echo would churn the widget). + // biome-ignore lint/correctness/useExhaustiveDependencies: intentional mount-once semantics + useEffect(() => { + const el = containerRef.current; + const widgetsUrl = getNotebookWidgetsUrl(); + if (!el || !widgetsUrl) { + return; + } + if (!cloudRegion) { + setError("Sign in to PostHog to edit queries."); + return; + } + + const storedQuery = node.props.query; + const editorQuery = toEditorQuery(storedQuery); + if (!editorQuery) { + setError("This node has no editable query."); + return; + } + const unwrap = wasWrapped(storedQuery, editorQuery); + + let cancelled = false; + loadPostHogWidgets(widgetsUrl) + .then((api) => { + if (cancelled || !containerRef.current) { + return; + } + handleRef.current = api.mountQueryEditor(el, { + query: editorQuery, + onQueryChange: (nextQuery) => { + // Store back in the shape the node originally used. + const next = + unwrap && nextQuery.kind === "InsightVizNode" + ? (nextQuery.source as Record) + : nextQuery; + updatePropsRef.current({ + query: next as unknown as NotebookPropValue, + }); + }, + apiHost: getCloudUrlFromRegion(cloudRegion), + getAccessToken: () => + hostClient.auth.getValidAccessToken + .query() + .then((result) => result.accessToken), + theme: isDarkMode ? "dark" : "light", + }); + }) + .catch((loadError: unknown) => { + if (!cancelled) { + setError( + `Failed to load PostHog widgets bundle: ${String(loadError)}`, + ); + } + }); + + return () => { + cancelled = true; + handleRef.current?.unmount(); + handleRef.current = null; + }; + }, [cloudRegion, node.id]); + + useEffect(() => { + handleRef.current?.update({ theme: isDarkMode ? "dark" : "light" }); + }, [isDarkMode]); + + return ( +
+ {error ? ( +
{error}
+ ) : null} +
+
+ ); +} diff --git a/packages/ui/src/features/notebooks/widgets/widgetLoader.ts b/packages/ui/src/features/notebooks/widgets/widgetLoader.ts new file mode 100644 index 0000000000..be3d84819c --- /dev/null +++ b/packages/ui/src/features/notebooks/widgets/widgetLoader.ts @@ -0,0 +1,71 @@ +// EXPERIMENT (shared-widgets): loads the PostHog webapp's embeddable widget +// bundle (built from posthog/frontend/src/widgets) into this document exactly +// once. The bundle carries its own React 18 + kea and renders into a shadow +// root — same-document, no iframe. See the experiment notes in the PR. + +export interface QueryEditorWidgetHandle { + update(props: { + query?: unknown; + onQueryChange?: (query: Record) => void; + theme?: "light" | "dark"; + }): void; + unmount(): void; +} + +export interface PostHogWidgetsApi { + mountQueryEditor( + el: HTMLElement, + options: { + query: unknown; + onQueryChange?: (query: Record) => void; + apiHost: string; + getAccessToken?: () => Promise; + personalApiKey?: string; + theme?: "light" | "dark"; + onClose?: () => void; + }, + ): QueryEditorWidgetHandle; +} + +declare global { + interface Window { + PostHogWidgets?: PostHogWidgetsApi; + } +} + +const WIDGETS_URL_STORAGE_KEY = "posthog.notebooks.widgetsUrl"; + +/** + * Experiment toggle: the widget editor activates only when a bundle URL is set, + * e.g. localStorage.setItem("posthog.notebooks.widgetsUrl", + * "http://localhost:8124/widgets.js"). Remove the key to fall back to the + * generic JSON props editor. + */ +export function getNotebookWidgetsUrl(): string | null { + try { + return window.localStorage.getItem(WIDGETS_URL_STORAGE_KEY); + } catch { + return null; + } +} + +let widgetsPromise: Promise | null = null; + +export function loadPostHogWidgets(url: string): Promise { + if (!widgetsPromise) { + widgetsPromise = import(/* @vite-ignore */ url).then(() => { + const api = window.PostHogWidgets; + if (!api) { + throw new Error( + "Widget bundle loaded but window.PostHogWidgets is missing", + ); + } + return api; + }); + widgetsPromise.catch(() => { + // Allow a retry after a failed load (dev server not running yet, etc.). + widgetsPromise = null; + }); + } + return widgetsPromise; +}