Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/code/src/main/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/*,
Expand Down
14 changes: 14 additions & 0 deletions packages/ui/src/features/notebooks/notebookRegistry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 `<Comment ref replies>` 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
Expand Down
150 changes: 150 additions & 0 deletions packages/ui/src/features/notebooks/widgets/QueryWidgetEdit.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | null {
if (!query || typeof query !== "object") {
return null;
}
const node = query as Record<string, unknown>;
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<string, unknown>) {
return (
!!stored &&
typeof stored === "object" &&
(stored as Record<string, unknown>).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<HTMLDivElement | null>(null);
const handleRef = useRef<QueryEditorWidgetHandle | null>(null);
const [error, setError] = useState<string | null>(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<string, unknown>)
: 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 (
<div className="flex min-h-24 w-full flex-col">
{error ? (
<div className="p-2 text-destructive text-sm">{error}</div>
) : null}
<div ref={containerRef} className="w-full" />
</div>
);
}
71 changes: 71 additions & 0 deletions packages/ui/src/features/notebooks/widgets/widgetLoader.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => void;
theme?: "light" | "dark";
}): void;
unmount(): void;
}

export interface PostHogWidgetsApi {
mountQueryEditor(
el: HTMLElement,
options: {
query: unknown;
onQueryChange?: (query: Record<string, unknown>) => void;
apiHost: string;
getAccessToken?: () => Promise<string | null>;
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<PostHogWidgetsApi> | null = null;

export function loadPostHogWidgets(url: string): Promise<PostHogWidgetsApi> {
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;
}
Loading