From 5d9cfa5b6be4e5dc977cd49920e1204d40256b74 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Tue, 14 Jul 2026 15:15:24 +0200 Subject: [PATCH 1/5] feat(notebooks): add PostHog markdown notebooks as an editable surface Vendors the webapp's MarkdownNotebook editor library (kea-free, self-contained contenteditable editor) into packages/ui with quill-based shims for lemon-ui, a token-bridge stylesheet, and the pure-logic test suites. Adds a core NotebooksService over the notebooks REST API with versioned collab markdown_save (409 replay + 3-way merge, 410 reload), a framework-free NotebookSyncEngine replacing notebookLogic's markdown autosave slice, list/editor views, a sidebar entry, /notebooks routes, and live rendering of embedded blocks via the query endpoint. Co-Authored-By: Claude Fable 5 --- .../src/renderer/desktop-contributions.ts | 2 + .../api-client/src/posthog-client.test.ts | 94 + packages/api-client/src/posthog-client.ts | 218 + packages/api-client/src/types.ts | 3 + packages/core/src/notebooks/identifiers.ts | 4 + .../core/src/notebooks/notebookContent.ts | 95 + .../core/src/notebooks/notebooks.module.ts | 11 + packages/core/src/notebooks/notebooks.test.ts | 276 + .../core/src/notebooks/notebooksService.ts | 129 + packages/core/src/notebooks/notebooksStore.ts | 41 + packages/core/src/notebooks/schemas.ts | 31 + packages/ui/package.json | 1 + .../src/features/notebooks/NotebookView.tsx | 232 + .../src/features/notebooks/NotebooksView.tsx | 131 + .../notebooks/markdown-notebook/COMPONENTS.md | 205 + .../markdown-notebook/CommentBlock.tsx | 155 + .../markdown-notebook/DividerBlock.tsx | 87 + .../markdown-notebook/EditableCodeBlock.tsx | 409 + .../markdown-notebook/EditableListBlock.tsx | 486 ++ .../EditablePromptComponent.tsx | 276 + .../markdown-notebook/EditableTableBlock.tsx | 866 ++ .../markdown-notebook/EditableTextBlock.tsx | 926 ++ .../markdown-notebook/FormattingToolbar.tsx | 544 ++ .../InsertBoundaryButton.tsx | 157 + .../markdown-notebook/InsertMenu.tsx | 770 ++ .../markdown-notebook/MarkdownNotebook.tsx | 7666 +++++++++++++++++ .../markdown-notebook/MarkdownTextDiff.tsx | 67 + .../NotebookComponentShell.tsx | 722 ++ .../notebooks/markdown-notebook/README.md | 75 + .../markdown-notebook/collaboration.test.ts | 430 + .../markdown-notebook/collaboration.ts | 735 ++ .../componentPanelContext.ts | 17 + .../markdown-notebook/componentPanels.ts | 115 + .../markdown-notebook/documentModel.ts | 1112 +++ .../markdown-notebook/domSelection.ts | 818 ++ .../markdown-notebook/editorTypes.ts | 171 + .../markdown-notebook/freshlyInserted.ts | 32 + .../notebooks/markdown-notebook/index.ts | 54 + .../markdown-notebook/inlineContent.ts | 401 + .../markdown-notebook/listModel.test.ts | 174 + .../notebooks/markdown-notebook/listModel.ts | 315 + .../markdown-notebook/markdown-notebook.css | 2372 +++++ .../notebooks/markdown-notebook/markdown.ts | 1992 +++++ .../markdownRoundTrip.test.ts | 957 ++ .../notebooks/markdown-notebook/notebookAI.ts | 703 ++ .../markdown-notebook/operations.test.ts | 313 + .../notebooks/markdown-notebook/operations.ts | 500 ++ .../markdown-notebook/reconcile.test.ts | 138 + .../notebooks/markdown-notebook/reconcile.ts | 338 + .../notebooks/markdown-notebook/registry.tsx | 641 ++ .../markdown-notebook/remoteCarets.tsx | 373 + .../markdown-notebook/renderNode.tsx | 312 + .../shims/LazyCodeEditor.tsx | 34 + .../markdown-notebook/shims/LemonDropdown.tsx | 53 + .../shims/PostHogErrorBoundary.tsx | 38 + .../markdown-notebook/shims/Spinner.tsx | 4 + .../shims/copyToClipboard.ts | 18 + .../notebooks/markdown-notebook/shims/dom.ts | 21 + .../markdown-notebook/shims/icons.tsx | 20 + .../markdown-notebook/shims/lemon-ui.tsx | 191 + .../markdown-notebook/shims/retryImport.ts | 13 + .../markdown-notebook/shims/stubs.ts | 12 + .../notebooks/markdown-notebook/tableModel.ts | 103 + .../markdown-notebook/textChanges.test.ts | 212 + .../markdown-notebook/textChanges.ts | 329 + .../notebooks/markdown-notebook/types.ts | 190 + .../notebooks/markdown-notebook/utils.ts | 395 + .../features/notebooks/notebookRegistry.tsx | 274 + .../features/notebooks/notebookSync.test.ts | 239 + .../ui/src/features/notebooks/notebookSync.ts | 277 + .../ui/src/features/notebooks/useNotebook.ts | 23 + .../ui/src/features/notebooks/useNotebooks.ts | 20 + .../sidebar/components/SidebarNavSection.tsx | 10 + .../components/items/NotebooksItem.tsx | 19 + packages/ui/src/router/navigationBridge.ts | 11 + packages/ui/src/router/routeTree.gen.ts | 42 + .../src/router/routes/notebooks/$shortId.tsx | 13 + .../ui/src/router/routes/notebooks/index.tsx | 6 + packages/ui/src/router/useAppView.ts | 4 + pnpm-lock.yaml | 101 +- 80 files changed, 30340 insertions(+), 24 deletions(-) create mode 100644 packages/core/src/notebooks/identifiers.ts create mode 100644 packages/core/src/notebooks/notebookContent.ts create mode 100644 packages/core/src/notebooks/notebooks.module.ts create mode 100644 packages/core/src/notebooks/notebooks.test.ts create mode 100644 packages/core/src/notebooks/notebooksService.ts create mode 100644 packages/core/src/notebooks/notebooksStore.ts create mode 100644 packages/core/src/notebooks/schemas.ts create mode 100644 packages/ui/src/features/notebooks/NotebookView.tsx create mode 100644 packages/ui/src/features/notebooks/NotebooksView.tsx create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/COMPONENTS.md create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/CommentBlock.tsx create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/DividerBlock.tsx create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/EditableCodeBlock.tsx create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/EditableListBlock.tsx create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/EditablePromptComponent.tsx create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/EditableTableBlock.tsx create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/EditableTextBlock.tsx create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/FormattingToolbar.tsx create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/InsertBoundaryButton.tsx create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/InsertMenu.tsx create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/MarkdownNotebook.tsx create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/MarkdownTextDiff.tsx create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/NotebookComponentShell.tsx create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/README.md create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/collaboration.test.ts create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/collaboration.ts create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/componentPanelContext.ts create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/componentPanels.ts create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/documentModel.ts create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/domSelection.ts create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/editorTypes.ts create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/freshlyInserted.ts create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/index.ts create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/inlineContent.ts create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/listModel.test.ts create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/listModel.ts create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/markdown-notebook.css create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/markdown.ts create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/markdownRoundTrip.test.ts create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/notebookAI.ts create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/operations.test.ts create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/operations.ts create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/reconcile.test.ts create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/reconcile.ts create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/registry.tsx create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/remoteCarets.tsx create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/renderNode.tsx create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/shims/LazyCodeEditor.tsx create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/shims/LemonDropdown.tsx create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/shims/PostHogErrorBoundary.tsx create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/shims/Spinner.tsx create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/shims/copyToClipboard.ts create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/shims/dom.ts create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/shims/icons.tsx create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/shims/lemon-ui.tsx create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/shims/retryImport.ts create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/shims/stubs.ts create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/tableModel.ts create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/textChanges.test.ts create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/textChanges.ts create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/types.ts create mode 100644 packages/ui/src/features/notebooks/markdown-notebook/utils.ts create mode 100644 packages/ui/src/features/notebooks/notebookRegistry.tsx create mode 100644 packages/ui/src/features/notebooks/notebookSync.test.ts create mode 100644 packages/ui/src/features/notebooks/notebookSync.ts create mode 100644 packages/ui/src/features/notebooks/useNotebook.ts create mode 100644 packages/ui/src/features/notebooks/useNotebooks.ts create mode 100644 packages/ui/src/features/sidebar/components/items/NotebooksItem.tsx create mode 100644 packages/ui/src/router/routes/notebooks/$shortId.tsx create mode 100644 packages/ui/src/router/routes/notebooks/index.tsx diff --git a/apps/code/src/renderer/desktop-contributions.ts b/apps/code/src/renderer/desktop-contributions.ts index a8aa79788b..e93a2d80ed 100644 --- a/apps/code/src/renderer/desktop-contributions.ts +++ b/apps/code/src/renderer/desktop-contributions.ts @@ -3,6 +3,7 @@ import { autoresearchCoreModule } from "@posthog/core/autoresearch/autoresearch. import { billingCoreModule } from "@posthog/core/billing/billing.module"; import { inboxCoreModule } from "@posthog/core/inbox/inbox.module"; import { githubConnectModule } from "@posthog/core/integrations/githubConnect.module"; +import { notebooksCoreModule } from "@posthog/core/notebooks/notebooks.module"; import { onboardingModule } from "@posthog/core/onboarding/onboarding.module"; import { setupCoreModule } from "@posthog/core/setup/setup.module"; import { skillsCoreModule } from "@posthog/core/skills/skills.module"; @@ -44,6 +45,7 @@ export function registerDesktopContributions(): void { focusUiModule, githubConnectModule, inboxCoreModule, + notebooksCoreModule, notificationsUiModule, onboardingModule, provisioningUiModule, diff --git a/packages/api-client/src/posthog-client.test.ts b/packages/api-client/src/posthog-client.test.ts index 8c29ded301..3289f3972f 100644 --- a/packages/api-client/src/posthog-client.test.ts +++ b/packages/api-client/src/posthog-client.test.ts @@ -1696,3 +1696,97 @@ describe("PostHogAPIClient", () => { }); }); }); + +describe("PostHogAPIClient.notebookMarkdownSave", () => { + function makeClient(fetch: ReturnType) { + const client = new PostHogAPIClient( + "http://localhost:8000", + async () => "token", + async () => "token", + 123, + ); + ( + client as unknown as { + api: { baseUrl: string; fetcher: { fetch: typeof fetch } }; + } + ).api = { baseUrl: "http://localhost:8000", fetcher: { fetch } }; + return client; + } + + const saveBody = { + client_id: "client-1", + version: 3, + content: { type: "doc", content: [] }, + text_content: "# Hi", + }; + + it("maps 200 to saved with the updated notebook", async () => { + const updated = { short_id: "abc123", version: 4 }; + const fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => updated, + }); + const client = makeClient(fetch); + + const result = await client.notebookMarkdownSave("abc123", saveBody); + + expect(result).toEqual({ status: "saved", notebook: updated }); + const call = fetch.mock.calls[0][0]; + expect(call.method).toBe("post"); + expect(call.path).toBe( + "/api/projects/123/notebooks/abc123/collab/markdown_save/", + ); + expect(JSON.parse(call.overrides.body)).toEqual(saveBody); + }); + + it("maps 409 to conflict with the server version and missed updates", async () => { + const conflictBody = { + version: 5, + updates: [ + { version: 4, diff: [{ start: 0, end: 1, text: "x" }], base_crc: null }, + ], + }; + const fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 409, + json: async () => conflictBody, + }); + const client = makeClient(fetch); + + const result = await client.notebookMarkdownSave("abc123", saveBody); + + expect(result).toEqual({ + status: "conflict", + serverVersion: 5, + updates: conflictBody.updates, + }); + }); + + it("maps 410 to gone", async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 410, + json: async () => ({}), + }); + const client = makeClient(fetch); + + await expect( + client.notebookMarkdownSave("abc123", saveBody), + ).resolves.toEqual({ status: "gone" }); + }); + + it("throws on any other failure status", async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + json: async () => ({}), + }); + const client = makeClient(fetch); + + await expect( + client.notebookMarkdownSave("abc123", saveBody), + ).rejects.toThrow("Failed to save notebook"); + }); +}); diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 18a2aa6da6..7ccf9ffdd0 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -138,6 +138,33 @@ export class SandboxCustomImagesDisabledError extends Error { } } +/** + * A text splice on a notebook's markdown string, as replayed by collab saves. + * Offsets are UTF-16 code units; spans are ascending and non-overlapping + * (matches the backend's MarkdownDiff and the markdown editor's TextChange). + */ +export interface NotebookTextChange { + start: number; + end: number; + text: string; +} + +export interface NotebookMarkdownUpdate { + version: number; + diff: NotebookTextChange[]; + base_crc?: number | null; +} + +/** Outcome of `notebookMarkdownSave`: 200 → saved, 409 → conflict, 410 → gone. */ +export type NotebookMarkdownSaveResponse = + | { status: "saved"; notebook: Schemas.Notebook } + | { + status: "conflict"; + serverVersion: number; + updates: NotebookMarkdownUpdate[]; + } + | { status: "gone" }; + export type UsageLimitType = "burst" | "sustained" | null; // Stable message so callers recognize this after a saga reduces the error to a string. @@ -1895,6 +1922,197 @@ export class PostHogAPIClient { return response.json(); } + // Notebooks — the PostHog notebooks REST resource. Retrieve/patch go through + // the generated client; list needs `order` (not in the OpenAPI query params), + // create needs a partial body (the generated body type requires read-only + // fields), and collab markdown_save isn't in the spec at all, so those use + // the raw fetcher. + async listNotebooks(): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/notebooks/?limit=100&order=-last_modified_at`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (!response.ok) { + throw new Error(`Failed to fetch notebooks: ${response.statusText}`); + } + const page = + (await response.json()) as Schemas.PaginatedNotebookMinimalList; + return page.results; + } + + async getNotebook(shortId: string): Promise { + const teamId = await this.getTeamId(); + return await this.api.get( + "/api/projects/{project_id}/notebooks/{short_id}/", + { + path: { project_id: teamId.toString(), short_id: shortId }, + }, + ); + } + + async createNotebook(body: { + title?: string; + content?: unknown; + text_content?: string; + }): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/notebooks/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: urlPath, + overrides: { + body: JSON.stringify(body), + }, + }); + if (!response.ok) { + throw new Error(`Failed to create notebook: ${response.statusText}`); + } + return (await response.json()) as Schemas.Notebook; + } + + async patchNotebook( + shortId: string, + body: { title?: string }, + ): Promise { + const teamId = await this.getTeamId(); + return await this.api.patch( + "/api/projects/{project_id}/notebooks/{short_id}/", + { + path: { project_id: teamId.toString(), short_id: shortId }, + body, + }, + ); + } + + // Notebooks soft-delete: the API's DELETE method is a 405, so deletion is a + // PATCH of `deleted: true`, same as the PostHog web app. + async deleteNotebook(shortId: string): Promise { + const teamId = await this.getTeamId(); + await this.api.patch("/api/projects/{project_id}/notebooks/{short_id}/", { + path: { project_id: teamId.toString(), short_id: shortId }, + body: { deleted: true }, + }); + } + + // Save a markdown-notebook edit through the collab endpoint. `version` is + // the baseline notebook version the edit was derived from; the server + // rebases concurrent saves onto it. 200 → saved (bumped version), 409 → + // conflict (body carries the missed remote updates to replay), 410 → the + // missed range is unrecoverable and the caller must reload the notebook. + async notebookMarkdownSave( + shortId: string, + body: { + client_id: string; + version: number; + content: unknown; + text_content?: string; + title?: string; + }, + ): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/notebooks/${encodeURIComponent(shortId)}/collab/markdown_save/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: urlPath, + overrides: { + body: JSON.stringify(body), + }, + }); + if (response.ok) { + return { + status: "saved", + notebook: (await response.json()) as Schemas.Notebook, + }; + } + if (response.status === 409) { + const conflict = (await response.json()) as { + updates: NotebookMarkdownUpdate[]; + version: number; + }; + return { + status: "conflict", + serverVersion: conflict.version, + updates: conflict.updates, + }; + } + if (response.status === 410) { + return { status: "gone" }; + } + throw new Error(`Failed to save notebook: ${response.statusText}`); + } + + // Run a typed query node ({ kind: "TrendsQuery" | "HogQLQuery" | … }) + // against the project's query endpoint. `refresh: "blocking"` serves a + // fresh cached result or computes one — the same numbers the PostHog UI + // shows. The response shape depends on the query kind, so callers coerce. + async runQueryNode( + query: Record, + opts?: { refresh?: string }, + ): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/query/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: urlPath, + overrides: { + body: JSON.stringify({ + query, + refresh: opts?.refresh ?? "blocking", + }), + }, + }); + if (!response.ok) { + throw new Error(`Query failed (${response.status})`); + } + return await response.json(); + } + + // Fetch a saved insight by short id, returning its stored query and cached + // result from the insights list endpoint (the same cache the PostHog UI + // reads). Throws on an unknown short id. + async getInsightByShortId( + shortId: string, + ): Promise<{ name?: string | null; query?: unknown; result?: unknown }> { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/insights/?short_id=${encodeURIComponent(shortId)}&refresh=blocking`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (!response.ok) { + throw new Error(`Failed to fetch insight (${response.status})`); + } + const body = (await response.json()) as { + results?: { + name?: string | null; + derived_name?: string | null; + query?: unknown; + result?: unknown; + }[]; + }; + const insight = body.results?.[0]; + if (!insight) { + throw new Error(`Insight ${shortId} not found`); + } + return { + name: insight.name ?? insight.derived_name, + query: insight.query, + result: insight.result, + }; + } + async listSignalSourceConfigs( projectId: number, ): Promise { diff --git a/packages/api-client/src/types.ts b/packages/api-client/src/types.ts index a17f035ad1..20fd866b4f 100644 --- a/packages/api-client/src/types.ts +++ b/packages/api-client/src/types.ts @@ -8,3 +8,6 @@ export type McpAuthType = Schemas.MCPAuthTypeEnum; export type McpRecommendedServer = Schemas.MCPServerTemplate; export type McpServerInstallation = Schemas.MCPServerInstallation; export type McpInstallationTool = Schemas.MCPServerInstallationTool; + +export type Notebook = Schemas.Notebook; +export type NotebookMinimal = Schemas.NotebookMinimal; diff --git a/packages/core/src/notebooks/identifiers.ts b/packages/core/src/notebooks/identifiers.ts new file mode 100644 index 0000000000..4a88d6fd7d --- /dev/null +++ b/packages/core/src/notebooks/identifiers.ts @@ -0,0 +1,4 @@ +// DI token for the notebooks service. Lives beside the interface in +// @posthog/core so host routers and containers can reference it without +// importing the concrete class's module. +export const NOTEBOOKS_SERVICE = Symbol.for("posthog.notebooks.service"); diff --git a/packages/core/src/notebooks/notebookContent.ts b/packages/core/src/notebooks/notebookContent.ts new file mode 100644 index 0000000000..aca1036f34 --- /dev/null +++ b/packages/core/src/notebooks/notebookContent.ts @@ -0,0 +1,95 @@ +// Pure helpers for the "markdown notebook" content envelope. A markdown +// notebook is a notebook whose `content` JSON is exactly one +// `ph-markdown-notebook` node holding the full markdown string in its attrs. +// Semantics mirror the upstream PostHog frontend helpers +// (scenes/notebooks/Notebook/markdownNotebookV2.ts) so we detect the same +// notebooks the product does and never corrupt old-format (TipTap) content. + +export const MARKDOWN_NOTEBOOK_NODE_TYPE = "ph-markdown-notebook"; + +// Upstream's default nodeId for the single markdown node; used as the fallback +// when no crypto.randomUUID is available. +const DEFAULT_MARKDOWN_NOTEBOOK_NODE_ID = "markdown-notebook-v2"; + +interface MarkdownNotebookNode { + type: string; + attrs?: { + nodeId?: unknown; + markdown?: unknown; + }; +} + +// The envelope check mirrors upstream: an object (not array) whose `content` +// is exactly one node of type `ph-markdown-notebook`. Anything else is an +// old-format notebook and must be left alone. +function getMarkdownNotebookNode( + content: unknown, +): MarkdownNotebookNode | null { + if (!content || typeof content !== "object" || Array.isArray(content)) { + return null; + } + const nodes = (content as { content?: unknown }).content; + if (!Array.isArray(nodes) || nodes.length !== 1) { + return null; + } + const node = nodes[0] as MarkdownNotebookNode | null; + if ( + !node || + typeof node !== "object" || + node.type !== MARKDOWN_NOTEBOOK_NODE_TYPE + ) { + return null; + } + return node; +} + +export function isMarkdownNotebookContent(content: unknown): boolean { + return !!getMarkdownNotebookNode(content); +} + +export function getMarkdownNotebookMarkdown(content: unknown): string | null { + const node = getMarkdownNotebookNode(content); + if (!node) { + return null; + } + const markdown = node.attrs?.markdown; + return typeof markdown === "string" ? markdown : ""; +} + +export function getMarkdownNotebookNodeId(content: unknown): string | null { + const node = getMarkdownNotebookNode(content); + if (!node) { + return null; + } + const nodeId = node.attrs?.nodeId; + return typeof nodeId === "string" && nodeId + ? nodeId + : DEFAULT_MARKDOWN_NOTEBOOK_NODE_ID; +} + +export function buildMarkdownNotebookContent( + markdown: string, + nodeId?: string, +): { + type: "doc"; + content: [{ type: string; attrs: { nodeId: string; markdown: string } }]; +} { + return { + type: "doc", + content: [ + { + type: MARKDOWN_NOTEBOOK_NODE_TYPE, + attrs: { + nodeId: nodeId ?? generateNodeId(), + markdown, + }, + }, + ], + }; +} + +function generateNodeId(): string { + // Both browsers and Node expose crypto.randomUUID; the constant fallback + // matches upstream's default nodeId and keeps this helper host-agnostic. + return globalThis.crypto?.randomUUID?.() ?? DEFAULT_MARKDOWN_NOTEBOOK_NODE_ID; +} diff --git a/packages/core/src/notebooks/notebooks.module.ts b/packages/core/src/notebooks/notebooks.module.ts new file mode 100644 index 0000000000..a7a738d10e --- /dev/null +++ b/packages/core/src/notebooks/notebooks.module.ts @@ -0,0 +1,11 @@ +import { ContainerModule } from "inversify"; +import { NOTEBOOKS_SERVICE } from "./identifiers"; +import { NotebooksService } from "./notebooksService"; + +// Host-agnostic notebooks service (PostHog cloud Notebooks REST API). It only +// needs AuthService + fetch, so it lives in @posthog/core and any host +// (desktop, web, mobile) can bind it by loading this module. +export const notebooksCoreModule = new ContainerModule(({ bind }) => { + bind(NotebooksService).toSelf().inSingletonScope(); + bind(NOTEBOOKS_SERVICE).toService(NotebooksService); +}); diff --git a/packages/core/src/notebooks/notebooks.test.ts b/packages/core/src/notebooks/notebooks.test.ts new file mode 100644 index 0000000000..4f9c0f182f --- /dev/null +++ b/packages/core/src/notebooks/notebooks.test.ts @@ -0,0 +1,276 @@ +import type { PostHogAPIClient } from "@posthog/api-client/posthog-client"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + buildMarkdownNotebookContent, + getMarkdownNotebookMarkdown, + getMarkdownNotebookNodeId, + isMarkdownNotebookContent, + MARKDOWN_NOTEBOOK_NODE_TYPE, +} from "./notebookContent"; +import { NotebooksService } from "./notebooksService"; +import { notebooksStore } from "./notebooksStore"; +import type { NotebookListItem, NotebookRecord } from "./schemas"; + +function notebook(overrides: Partial = {}): NotebookRecord { + return { + id: "nb-uuid", + short_id: "abc123", + title: "My notebook", + content: buildMarkdownNotebookContent("# Hi", "node-1"), + version: 3, + created_at: "2026-07-01T00:00:00Z", + last_modified_at: "2026-07-02T00:00:00Z", + user_access_level: "editor", + ...overrides, + } as NotebookRecord; +} + +function listItem(overrides: Partial = {}): NotebookListItem { + return { + id: "nb-uuid", + short_id: "abc123", + title: "My notebook", + deleted: false, + created_at: "2026-07-01T00:00:00Z", + last_modified_at: "2026-07-02T00:00:00Z", + user_access_level: "editor", + ...overrides, + } as NotebookListItem; +} + +function fakeClient(overrides: Partial): PostHogAPIClient { + return overrides as PostHogAPIClient; +} + +beforeEach(() => { + notebooksStore.setState({ + notebooks: [], + notebooksLoading: false, + notebooksError: null, + }); +}); + +describe("notebookContent envelope helpers", () => { + it("round-trips markdown through the envelope", () => { + const content = buildMarkdownNotebookContent("# Title\n\nBody", "node-42"); + + expect(isMarkdownNotebookContent(content)).toBe(true); + expect(getMarkdownNotebookMarkdown(content)).toBe("# Title\n\nBody"); + expect(getMarkdownNotebookNodeId(content)).toBe("node-42"); + }); + + it("generates a nodeId when none is given", () => { + const content = buildMarkdownNotebookContent("hello"); + + const nodeId = getMarkdownNotebookNodeId(content); + expect(nodeId).toBeTruthy(); + expect(getMarkdownNotebookMarkdown(content)).toBe("hello"); + }); + + it.each([ + ["null", null], + ["a string", "# raw markdown"], + ["an array", [{ type: MARKDOWN_NOTEBOOK_NODE_TYPE }]], + ["an empty doc", { type: "doc", content: [] }], + [ + "an old-format TipTap doc", + { + type: "doc", + content: [ + { type: "paragraph", content: [{ type: "text", text: "hi" }] }, + ], + }, + ], + [ + "a doc with extra nodes beside the markdown node", + { + type: "doc", + content: [ + { type: MARKDOWN_NOTEBOOK_NODE_TYPE, attrs: { markdown: "hi" } }, + { type: "paragraph" }, + ], + }, + ], + ])("does not treat %s as a markdown notebook", (_label, content) => { + expect(isMarkdownNotebookContent(content)).toBe(false); + expect(getMarkdownNotebookMarkdown(content)).toBeNull(); + expect(getMarkdownNotebookNodeId(content)).toBeNull(); + }); + + it("falls back to empty markdown and the default nodeId when attrs are missing", () => { + const content = { + type: "doc", + content: [{ type: MARKDOWN_NOTEBOOK_NODE_TYPE }], + }; + + expect(isMarkdownNotebookContent(content)).toBe(true); + expect(getMarkdownNotebookMarkdown(content)).toBe(""); + expect(getMarkdownNotebookNodeId(content)).toBe("markdown-notebook-v2"); + }); +}); + +describe("NotebooksService.listNotebooks", () => { + it("populates the store and clears loading/error", async () => { + const rows = [listItem(), listItem({ short_id: "def456", title: null })]; + const client = fakeClient({ listNotebooks: vi.fn(async () => rows) }); + const service = new NotebooksService(); + + const result = await service.listNotebooks(client); + + expect(result).toEqual(rows); + expect(notebooksStore.getState().notebooks).toEqual(rows); + expect(notebooksStore.getState().notebooksLoading).toBe(false); + expect(notebooksStore.getState().notebooksError).toBeNull(); + }); + + it("records the error and rethrows when validation fails", async () => { + const client = fakeClient({ + listNotebooks: vi.fn( + async () => [{ title: "missing short_id" }] as NotebookListItem[], + ), + }); + const service = new NotebooksService(); + + await expect(service.listNotebooks(client)).rejects.toThrow(); + expect(notebooksStore.getState().notebooksError).not.toBeNull(); + expect(notebooksStore.getState().notebooksLoading).toBe(false); + expect(notebooksStore.getState().notebooks).toEqual([]); + }); +}); + +describe("NotebooksService.createNotebook", () => { + it("sends a markdown envelope with text_content and upserts the store", async () => { + const created = notebook({ short_id: "new123", title: "Fresh" }); + const createNotebook = vi.fn( + async (_body: { + title?: string; + content?: unknown; + text_content?: string; + }) => created, + ); + const service = new NotebooksService(); + + const result = await service.createNotebook( + fakeClient({ createNotebook }), + { title: "Fresh", markdown: "# Fresh" }, + ); + + expect(result).toBe(created); + const [body] = createNotebook.mock.calls[0]; + expect(body.title).toBe("Fresh"); + expect(body.text_content).toBe("# Fresh"); + expect(isMarkdownNotebookContent(body.content)).toBe(true); + expect(getMarkdownNotebookMarkdown(body.content)).toBe("# Fresh"); + expect(notebooksStore.getState().notebooks[0]?.short_id).toBe("new123"); + }); +}); + +describe("NotebooksService.updateTitle", () => { + it("patches the title only and updates the store row", async () => { + notebooksStore.getState().setNotebooks([listItem()]); + const patched = notebook({ title: "Renamed" }); + const patchNotebook = vi.fn(async () => patched); + const service = new NotebooksService(); + + await service.updateTitle( + fakeClient({ patchNotebook }), + "abc123", + "Renamed", + ); + + expect(patchNotebook).toHaveBeenCalledWith("abc123", { title: "Renamed" }); + expect(notebooksStore.getState().notebooks).toHaveLength(1); + expect(notebooksStore.getState().notebooks[0]?.title).toBe("Renamed"); + }); +}); + +describe("NotebooksService.deleteNotebook", () => { + it("removes the notebook from the store", async () => { + notebooksStore + .getState() + .setNotebooks([listItem(), listItem({ short_id: "keep1" })]); + const deleteNotebook = vi.fn(async () => {}); + const service = new NotebooksService(); + + await service.deleteNotebook(fakeClient({ deleteNotebook }), "abc123"); + + expect(deleteNotebook).toHaveBeenCalledWith("abc123"); + expect(notebooksStore.getState().notebooks.map((n) => n.short_id)).toEqual([ + "keep1", + ]); + }); +}); + +describe("NotebooksService.markdownSave", () => { + const input = { + clientId: "client-1", + version: 3, + markdown: "# Edited", + nodeId: "node-1", + }; + + it("sends the envelope and upserts the store on saved", async () => { + const saved = notebook({ version: 4, title: "My notebook" }); + const notebookMarkdownSave = vi.fn( + async ( + _shortId: string, + _body: { + client_id: string; + version: number; + content: unknown; + text_content?: string; + }, + ) => ({ status: "saved" as const, notebook: saved }), + ); + const service = new NotebooksService(); + + const result = await service.markdownSave( + fakeClient({ notebookMarkdownSave }), + "abc123", + input, + ); + + expect(result).toEqual({ status: "saved", notebook: saved }); + const [shortId, body] = notebookMarkdownSave.mock.calls[0]; + expect(shortId).toBe("abc123"); + expect(body.client_id).toBe("client-1"); + expect(body.version).toBe(3); + expect(body.text_content).toBe("# Edited"); + expect(getMarkdownNotebookMarkdown(body.content)).toBe("# Edited"); + expect(getMarkdownNotebookNodeId(body.content)).toBe("node-1"); + expect(notebooksStore.getState().notebooks[0]?.short_id).toBe("abc123"); + }); + + it.each([ + [ + "conflict", + { + status: "conflict" as const, + serverVersion: 5, + updates: [ + { + version: 4, + diff: [{ start: 0, end: 1, text: "x" }], + base_crc: null, + }, + ], + }, + ], + ["gone", { status: "gone" as const }], + ])( + "passes through a %s result without touching the store", + async (_label, response) => { + const notebookMarkdownSave = vi.fn(async () => response); + const service = new NotebooksService(); + + const result = await service.markdownSave( + fakeClient({ notebookMarkdownSave }), + "abc123", + input, + ); + + expect(result).toEqual(response); + expect(notebooksStore.getState().notebooks).toEqual([]); + }, + ); +}); diff --git a/packages/core/src/notebooks/notebooksService.ts b/packages/core/src/notebooks/notebooksService.ts new file mode 100644 index 0000000000..6ba56e31f7 --- /dev/null +++ b/packages/core/src/notebooks/notebooksService.ts @@ -0,0 +1,129 @@ +import type { + NotebookMarkdownSaveResponse, + PostHogAPIClient, +} from "@posthog/api-client/posthog-client"; +import { injectable } from "inversify"; +import { buildMarkdownNotebookContent } from "./notebookContent"; +import { notebooksStore } from "./notebooksStore"; +import { + type NotebookListItem, + type NotebookRecord, + notebookListItemSchema, +} from "./schemas"; + +export type MarkdownSaveResult = NotebookMarkdownSaveResponse; + +/** + * Portable notebooks domain service over the PostHog cloud Notebooks REST + * API. Callers pass the authenticated PostHogAPIClient (the UI supplies it + * from useAuthenticatedClient()); the service keeps the notebooks domain + * store in sync. Merge/replay of markdown-save conflicts lives next to the + * editor (it needs the editor's document model); markdownSave just returns + * the structured saved/conflict/gone outcome. + */ +@injectable() +export class NotebooksService { + async listNotebooks(client: PostHogAPIClient): Promise { + notebooksStore.getState().setNotebooksLoading(true); + try { + const notebooks = await client.listNotebooks(); + notebookListItemSchema.parse(notebooks); + notebooksStore.getState().setNotebooks(notebooks); + notebooksStore.getState().setNotebooksError(null); + return notebooks; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + notebooksStore.getState().setNotebooksError(message); + throw error; + } finally { + notebooksStore.getState().setNotebooksLoading(false); + } + } + + async getNotebook( + client: PostHogAPIClient, + shortId: string, + ): Promise { + return await client.getNotebook(shortId); + } + + async createNotebook( + client: PostHogAPIClient, + input: { title?: string; markdown?: string }, + ): Promise { + const markdown = input.markdown ?? ""; + const notebook = await client.createNotebook({ + ...(input.title !== undefined ? { title: input.title } : {}), + content: buildMarkdownNotebookContent(markdown), + text_content: markdown, + }); + notebooksStore.getState().upsertNotebook(toListItem(notebook)); + return notebook; + } + + // Title-only update — content never travels through PATCH for markdown + // notebooks, so the envelope can't be clobbered outside the collab save. + async updateTitle( + client: PostHogAPIClient, + shortId: string, + title: string, + ): Promise { + const notebook = await client.patchNotebook(shortId, { title }); + notebooksStore.getState().upsertNotebook(toListItem(notebook)); + return notebook; + } + + async deleteNotebook( + client: PostHogAPIClient, + shortId: string, + ): Promise { + await client.deleteNotebook(shortId); + notebooksStore.getState().removeNotebook(shortId); + } + + /** + * Save a markdown edit through the collab endpoint. `version` is the + * baseline notebook version the edit was derived from. Returns: + * - `saved` with the updated notebook (bumped version), + * - `conflict` with the missed remote updates to replay/merge (409), + * - `gone` when the missed range is unrecoverable (410) — reload the + * notebook. + */ + async markdownSave( + client: PostHogAPIClient, + shortId: string, + input: { + clientId: string; + version: number; + markdown: string; + nodeId: string; + title?: string; + }, + ): Promise { + const result = await client.notebookMarkdownSave(shortId, { + client_id: input.clientId, + version: input.version, + content: buildMarkdownNotebookContent(input.markdown, input.nodeId), + text_content: input.markdown, + ...(input.title !== undefined ? { title: input.title } : {}), + }); + if (result.status === "saved") { + notebooksStore.getState().upsertNotebook(toListItem(result.notebook)); + } + return result; + } +} + +function toListItem(notebook: NotebookRecord): NotebookListItem { + return { + id: notebook.id, + short_id: notebook.short_id, + title: notebook.title ?? null, + deleted: notebook.deleted ?? false, + created_at: notebook.created_at, + created_by: notebook.created_by, + last_modified_at: notebook.last_modified_at, + last_modified_by: notebook.last_modified_by, + user_access_level: notebook.user_access_level, + }; +} diff --git a/packages/core/src/notebooks/notebooksStore.ts b/packages/core/src/notebooks/notebooksStore.ts new file mode 100644 index 0000000000..58f691aefc --- /dev/null +++ b/packages/core/src/notebooks/notebooksStore.ts @@ -0,0 +1,41 @@ +import { createStore } from "zustand/vanilla"; +import type { NotebookListItem } from "./schemas"; + +// Domain facts about the project's notebooks list. State only — fetching, +// validation, and error mapping live in NotebooksService. +interface NotebooksState { + notebooks: NotebookListItem[]; + notebooksLoading: boolean; + notebooksError: string | null; + setNotebooks: (notebooks: NotebookListItem[]) => void; + setNotebooksLoading: (notebooksLoading: boolean) => void; + setNotebooksError: (notebooksError: string | null) => void; + upsertNotebook: (notebook: NotebookListItem) => void; + removeNotebook: (shortId: string) => void; +} + +export const notebooksStore = createStore((set) => ({ + notebooks: [], + notebooksLoading: false, + notebooksError: null, + setNotebooks: (notebooks) => set({ notebooks }), + setNotebooksLoading: (notebooksLoading) => set({ notebooksLoading }), + setNotebooksError: (notebooksError) => set({ notebooksError }), + upsertNotebook: (notebook) => + set((state) => { + const exists = state.notebooks.some( + (n) => n.short_id === notebook.short_id, + ); + return { + notebooks: exists + ? state.notebooks.map((n) => + n.short_id === notebook.short_id ? notebook : n, + ) + : [notebook, ...state.notebooks], + }; + }), + removeNotebook: (shortId) => + set((state) => ({ + notebooks: state.notebooks.filter((n) => n.short_id !== shortId), + })), +})); diff --git a/packages/core/src/notebooks/schemas.ts b/packages/core/src/notebooks/schemas.ts new file mode 100644 index 0000000000..c024aca506 --- /dev/null +++ b/packages/core/src/notebooks/schemas.ts @@ -0,0 +1,31 @@ +import type { + NotebookMarkdownUpdate, + NotebookTextChange, +} from "@posthog/api-client/posthog-client"; +import type { Notebook, NotebookMinimal } from "@posthog/api-client/types"; +import { z } from "zod"; + +// Notebook boundary shapes. The generated OpenAPI types are the source of +// truth for fields (the api-client owns the HTTP boundary); zod is kept only +// for the minimal sanity check core runs before a payload reaches the domain +// store. + +/** Full notebook as returned by retrieve/create/patch/markdown_save. */ +export type NotebookRecord = Notebook; +/** List row (`GET /notebooks/` results) — no `content` field. */ +export type NotebookListItem = NotebookMinimal; +/** A text splice `{ from, to, insert }` on the markdown string. */ +export type TextChange = NotebookTextChange; +/** One missed remote update from a markdown_save 409 body. */ +export type { NotebookMarkdownUpdate }; + +// Minimal validation of list rows before they enter the notebooks store: +// only the fields core logic relies on; unknown keys pass through untouched. +export const notebookListItemSchema = z + .looseObject({ + short_id: z.string(), + title: z.string().nullish(), + created_at: z.string(), + last_modified_at: z.string(), + }) + .array(); diff --git a/packages/ui/package.json b/packages/ui/package.json index a93ec5c3a5..1d362995a8 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -59,6 +59,7 @@ "@posthog/di": "workspace:*", "@posthog/git": "workspace:*", "@posthog/host-router": "workspace:*", + "@posthog/icons": "^0.37.4", "@posthog/platform": "workspace:*", "@posthog/quill-charts": "0.3.0-beta.19", "@posthog/shared": "workspace:*", diff --git a/packages/ui/src/features/notebooks/NotebookView.tsx b/packages/ui/src/features/notebooks/NotebookView.tsx new file mode 100644 index 0000000000..94696079e2 --- /dev/null +++ b/packages/ui/src/features/notebooks/NotebookView.tsx @@ -0,0 +1,232 @@ +import { ArrowLeft, Notebook as NotebookIcon } from "@phosphor-icons/react"; +import type { PostHogAPIClient } from "@posthog/api-client/posthog-client"; +import { + getMarkdownNotebookMarkdown, + getMarkdownNotebookNodeId, + isMarkdownNotebookContent, +} from "@posthog/core/notebooks/notebookContent"; +import { NotebooksService } from "@posthog/core/notebooks/notebooksService"; +import type { NotebookRecord } from "@posthog/core/notebooks/schemas"; +import { useService } from "@posthog/di/react"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, + Spinner, +} from "@posthog/quill"; +import { useAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { navigateToNotebooks } from "@posthog/ui/router/navigationBridge"; +import { Flex, IconButton, ScrollArea, Text } from "@radix-ui/themes"; +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { MarkdownNotebook } from "./markdown-notebook/MarkdownNotebook"; +import { getNotebooksAppRegistry } from "./notebookRegistry"; +import { NotebookSyncEngine, type NotebookSyncStatus } from "./notebookSync"; +import { useNotebook } from "./useNotebook"; +import { NOTEBOOKS_QUERY_KEY } from "./useNotebooks"; + +const STATUS_LABEL: Record = { + saved: "Saved", + dirty: "Unsaved changes", + saving: "Saving…", + error: "Offline — retrying", +}; + +interface NotebookViewProps { + shortId: string; +} + +export function NotebookView({ shortId }: NotebookViewProps) { + const { data: notebook, isLoading, error } = useNotebook(shortId); + + if (isLoading) { + return ( + + + + ); + } + + if (error || !notebook) { + return ( + + + + + + + Couldn't open notebook + + {error instanceof Error ? error.message : "Notebook not found."} + + + + + ); + } + + if (!isMarkdownNotebookContent(notebook.content)) { + return ( + + + + + + + Legacy notebook format + + This notebook uses the older rich-text format. Open it in PostHog + and convert it to a Markdown notebook to edit it here. + + + + + ); + } + + return ; +} + +function NotebookEditor({ notebook }: { notebook: NotebookRecord }) { + const client = useAuthenticatedClient(); + const service = useService(NotebooksService); + const queryClient = useQueryClient(); + + const initialMarkdown = getMarkdownNotebookMarkdown(notebook.content) ?? ""; + const initialNodeId = getMarkdownNotebookNodeId(notebook.content); + + const [value, setValue] = useState(initialMarkdown); + const [remote, setRemote] = useState<{ + markdown: string; + version: number; + } | null>(null); + const [status, setStatus] = useState("saved"); + const [title, setTitle] = useState(notebook.title ?? ""); + const registry = useMemo(() => getNotebooksAppRegistry(), []); + + // The engine's deps close over the latest client without recreating the + // engine (the authenticated client's identity changes on auth-state churn). + const clientRef = useRef(client); + clientRef.current = client; + + const engine = useMemo( + () => + new NotebookSyncEngine( + { + markdown: initialMarkdown, + version: notebook.version ?? 0, + nodeId: initialNodeId ?? globalThis.crypto.randomUUID(), + }, + { + saveMarkdown: async (input) => { + const result = await service.markdownSave( + clientRef.current, + notebook.short_id, + input, + ); + if (result.status === "saved") { + return { + status: "saved", + markdown: + getMarkdownNotebookMarkdown(result.notebook.content) ?? + input.markdown, + version: result.notebook.version ?? input.version + 1, + }; + } + return result; + }, + reload: async () => { + const fresh = await service.getNotebook( + clientRef.current, + notebook.short_id, + ); + return { + markdown: getMarkdownNotebookMarkdown(fresh.content) ?? "", + version: fresh.version ?? 0, + nodeId: getMarkdownNotebookNodeId(fresh.content), + }; + }, + onRemoteContent: setRemote, + onStatusChange: setStatus, + }, + ), + // The route remounts this component per notebook (key=shortId), so the + // engine lives exactly as long as one open document. + [ + initialMarkdown, + initialNodeId, + notebook.short_id, + notebook.version, + service, + ], + ); + + useEffect(() => { + return () => { + engine.dispose({ flush: true }); + }; + }, [engine]); + + const saveTitle = async (nextTitle: string) => { + const trimmed = nextTitle.trim(); + if (trimmed === (notebook.title ?? "")) return; + await service.updateTitle(clientRef.current, notebook.short_id, trimmed); + void queryClient.invalidateQueries({ queryKey: NOTEBOOKS_QUERY_KEY }); + }; + + return ( + + + + + + setTitle(event.target.value)} + onBlur={() => void saveTitle(title)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.currentTarget.blur(); + } + }} + placeholder="Untitled" + className="min-w-0 flex-1 bg-transparent font-semibold text-base outline-none placeholder:text-(--gray-8)" + /> + + {STATUS_LABEL[status]} + + + + +
+ { + setValue(markdown); + engine.handleChange(markdown); + }} + mode="edit" + registry={registry} + clientId={engine.clientId} + remoteValue={remote?.markdown} + remoteVersion={remote?.version} + placeholder="Start writing, or press / to insert…" + autoFocus + /> +
+
+
+ ); +} diff --git a/packages/ui/src/features/notebooks/NotebooksView.tsx b/packages/ui/src/features/notebooks/NotebooksView.tsx new file mode 100644 index 0000000000..cc9a15d774 --- /dev/null +++ b/packages/ui/src/features/notebooks/NotebooksView.tsx @@ -0,0 +1,131 @@ +import { Notebook, Plus } from "@phosphor-icons/react"; +import { NotebooksService } from "@posthog/core/notebooks/notebooksService"; +import type { NotebookListItem } from "@posthog/core/notebooks/schemas"; +import { useService } from "@posthog/di/react"; +import { + Button, + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, + Spinner, +} from "@posthog/quill"; +import { useAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { navigateToNotebook } from "@posthog/ui/router/navigationBridge"; +import { Flex, ScrollArea, Text } from "@radix-ui/themes"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { NOTEBOOKS_QUERY_KEY, useNotebooks } from "./useNotebooks"; + +function relativeTime(iso: string): string { + const ms = Date.now() - new Date(iso).getTime(); + const minutes = Math.floor(ms / 60_000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + return new Date(iso).toLocaleDateString(); +} + +export function NotebooksView() { + const client = useAuthenticatedClient(); + const service = useService(NotebooksService); + const queryClient = useQueryClient(); + const { data: notebooks, isLoading, error } = useNotebooks(); + + const createNotebook = useMutation({ + mutationFn: () => service.createNotebook(client, {}), + onSuccess: (notebook) => { + void queryClient.invalidateQueries({ queryKey: NOTEBOOKS_QUERY_KEY }); + navigateToNotebook(notebook.short_id); + }, + }); + + return ( + + + + Notebooks + + + + + + {isLoading ? ( + + + + ) : error ? ( + + + + + + Couldn't load notebooks + + {error instanceof Error ? error.message : "Unknown error"} + + + + ) : !notebooks || notebooks.length === 0 ? ( + + + + + + No notebooks yet + + Notebooks combine markdown notes with live PostHog insights. + Create one to get started. + + + + ) : ( +
+ {notebooks.map((notebook) => ( + + ))} +
+ )} +
+
+ ); +} + +function NotebookRow({ notebook }: { notebook: NotebookListItem }) { + return ( + + ); +} diff --git a/packages/ui/src/features/notebooks/markdown-notebook/COMPONENTS.md b/packages/ui/src/features/notebooks/markdown-notebook/COMPONENTS.md new file mode 100644 index 0000000000..8494576a45 --- /dev/null +++ b/packages/ui/src/features/notebooks/markdown-notebook/COMPONENTS.md @@ -0,0 +1,205 @@ +# Markdown notebook components + +Markdown notebooks store interactive blocks as JSX-like tags inside markdown: + +```md + +``` + +The parser turns that tag into a `NotebookComponentBlockNode`. +Rendering, editing, validation, default props, and optional slash-menu insertion all go through `NotebookComponentDefinition`. + +## Component definition API + +Use the shared registry helpers from `lib/components/MarkdownNotebook`: + +```tsx +import { + MarkdownNotebook, + createMarkdownNotebookRegistry, + getMarkdownNotebookDefaultRegistry, + mergeMarkdownNotebookRegistries, + type NotebookComponentRenderProps, +} from 'lib/components/MarkdownNotebook' + +function RevenueCard({ node, updateProps }: NotebookComponentRenderProps): JSX.Element { + const metric = typeof node.props.metric === 'string' ? node.props.metric : 'arr' + + return ( + + ) +} + +const revenueRegistry = createMarkdownNotebookRegistry([ + { + tagName: 'RevenueCard', + label: 'Revenue card', + category: 'Data', + description: 'Revenue summary', + aliases: ['arr', 'mrr'], + defaultProps: { metric: 'arr' }, + getTitle: ({ props }) => `Revenue: ${String(props.metric ?? 'arr').toUpperCase()}`, + insertCommand: { + aliases: ['arr', 'mrr'], + }, + ViewComponent: RevenueCard, + EditComponent: RevenueCard, + }, +]) + +const registry = mergeMarkdownNotebookRegistries(getMarkdownNotebookDefaultRegistry(), revenueRegistry) + +export function Notebook(): JSX.Element { + return " registry={registry} /> +} +``` + +`tagName` is the persisted markdown tag. +Keep it stable after release; renaming it is a content migration. + +`label`, `category`, `description`, `aliases`, and `icon` describe the component in notebook UI. +Use Sentence casing for labels. + +`defaultProps` is used when the component is inserted without explicit props. +It can be a plain object or a function that returns a new object. + +`insertCommand` opts the component into the slash menu. +If it is omitted, the component can still render from markdown but does not appear in `/`. +`insertCommand.defaultProps` can override the component defaults for slash insertion. + +`validateProps` returns user-facing validation errors. +The notebook shell renders those above the component. + +`getTitle(node)` returns a computed contextual title — a compact summary such as a URL, insight name, code title, or cached AI answer summary. +If omitted, the shell falls back to string props such as `title`, `name`, `url`, `href`, `src`, or `id`. + +Every component also has a generic, user-editable title backed by the `title` prop. +In edit mode the shell renders an editable title field in the toolbar, watermarked with the `getTitle` value (or "Add a title"). +In view mode the shell shows the user's title if set, otherwise the `getTitle` value. +A `title` equal to the component's own label (e.g. code blocks default `title` to "Python") is treated as no user title, so the field reads as empty by default. + +`ViewComponent` renders the read panel. +`EditComponent` is optional; if omitted, the component only has a view panel. +Call `updateProps(partialProps)` from either component to update persisted markdown props. + +`exclusiveEditPanel` hides the view panel while the edit panel is open. +Use it for expensive or stateful components that should not mount twice. + +`hideModeActions` hides the filters/results toggle buttons while preserving the component toolbar and delete action. +Use it for components with a single meaningful display mode. + +## Prop rules + +Props must be serializable `NotebookPropValue`s: + +- `string` +- `number` +- `boolean` +- `null` +- arrays of serializable values +- objects with serializable values + +Do not put functions, dates, class instances, React nodes, or cyclic objects in props. + +String props serialize as attributes: + +```md + +``` + +Object and array props serialize with JSX-like expression syntax: + +```md + +``` + +Boolean `true` props serialize as bare JSX props. +Boolean `false` props stay explicit: + +```md + +``` + +`hideFilters` and `hideResults` are reserved props used by the notebook shell to persist which panels are hidden. +When a panel is shown, omit its prop. +Components with visible mode actions should allow these props to round-trip. +Components with `hideModeActions` do not persist them. +`Prompt` is a special AI input tag and should not use `hideFilters` or `hideResults`. + +## Adding a standalone component + +Use a standalone component when the block does not need the legacy notebook node runtime. + +1. Create a `NotebookComponentDefinition`. +2. Add `insertCommand` if it should appear in `/`. +3. Pass a registry to ``. +4. Add parser/serializer and editor tests in `MarkdownNotebook.test.ts`. + +For app-specific registries, merge with `getMarkdownNotebookDefaultRegistry()`. +For replacement behavior, pass a registry directly. + +## Adding a real notebook node + +Markdown notebook V2 can wrap existing notebook nodes so they persist as markdown tags but render through the existing node implementation. +This adapter lives in `frontend/src/scenes/notebooks/Notebook/MarkdownNotebookV2Renderer.tsx`. + +If the node type already exists: + +1. Import its node module near the other `../Nodes/NotebookNode*` imports so it registers with `KNOWN_NODES`. +2. Add a tag mapping in `MARKDOWN_TAG_TO_NOTEBOOK_NODE_TYPE`. +3. Add an entry to `MARKDOWN_NODE_DEFINITIONS`. +4. Add `insertCommand` on that entry if the node should appear in `/`. + +Example: + +```tsx +const MARKDOWN_TAG_TO_NOTEBOOK_NODE_TYPE: Partial> = { + RevenueReport: NotebookNodeType.RevenueReport, +} + +const MARKDOWN_NODE_DEFINITIONS = [ + { + tagName: 'RevenueReport', + category: 'Data', + label: 'Revenue report', + exclusiveEditPanel: true, + insertCommand: { + aliases: ['revenue', 'arr', 'mrr'], + }, + }, +] +``` + +The V2 adapter supplies: + +- `ViewComponent` +- `EditComponent` +- `defaultProps` +- icon from `NODE_ICONS` +- title fallback from `KNOWN_NODES[nodeType].titlePlaceholder` +- toolbar title from `title`, URL-ish props, code/query summaries, or the node's serialized text + +If the node type does not exist yet, create the notebook node first under `frontend/src/scenes/notebooks/Nodes`, register it in `KNOWN_NODES`, add an icon in `NODE_ICONS`, then follow the steps above. + +## Reserved tags + +Do not reuse the tags of the default registry (`registry.tsx`): `Query`, `Image`, `Divider`, `Embed`, `Latex`, `Python`, `DuckSQL`, `HogQLSQL`, `RecordingPlaylist`, `FeatureFlag`, `Experiment`, `Survey`, `Person`, `Group`, `Cohort`, `Map`. +The internal AI tag `Prompt` is also reserved (registered by the notebooks scene). +`Image` and `Divider` are special: they serialize back to plain markdown (`![alt](src)` and `---`) rather than component-tag syntax. +`Comment` is special too: its authorial-note flavor (`text` prop) serializes as a markdown `` comment, while its discussion flavor (`ref` + `replies` props, a Google Docs-style thread anchored to an inline `` highlight) serializes as a regular `` tag. +The lowercase inline tags `` and `` are part of the inline grammar, not components — component tags must start with an uppercase letter. +Code carries no inline marks, so a comment anchored to a selection inside a code block stores its anchor as a `ref=:-` token in the fence info string (e.g. ` ```python ref=abc123:4-17 `), with UTF-16 offsets into the code text. +Choose a specific tag name that describes the persisted block. + +## Testing checklist + +Add or update tests for: + +- parsing and serializing the tag +- rendering the view component +- editable toolbar title (edit-mode field, view-mode display, `getTitle` watermark) +- editing props through `updateProps` +- slash-menu insertion when `insertCommand` is present +- validation errors when `validateProps` rejects invalid props diff --git a/packages/ui/src/features/notebooks/markdown-notebook/CommentBlock.tsx b/packages/ui/src/features/notebooks/markdown-notebook/CommentBlock.tsx new file mode 100644 index 0000000000..fced82a330 --- /dev/null +++ b/packages/ui/src/features/notebooks/markdown-notebook/CommentBlock.tsx @@ -0,0 +1,155 @@ +/** + * Vendored from posthog `frontend/src/lib/components/MarkdownNotebook`. + * Keep diffs against upstream minimal: lint rules the upstream code does not + * follow are suppressed file-wide instead of rewriting vendored code. + */ +// biome-ignore-all lint/style/noRestrictedImports: vendored code, keep close to upstream + +import { IconComment } from "@posthog/icons"; +import clsx from "clsx"; + +import type { JSX } from "react"; +import { type KeyboardEvent, useState } from "react"; +import type { InsertMenuSelectionDirection } from "./editorTypes"; +import { wasNotebookNodeJustInserted } from "./freshlyInserted"; +import { LemonDropdown } from "./shims/LemonDropdown"; +import { LemonTextArea } from "./shims/lemon-ui"; +import type { + NotebookBlockNode, + NotebookComponentBlockNode, + NotebookMode, +} from "./types"; + +/** + * A markdown comment (``) rendered as a small info chip. The markdown carries + * only the comment text — no ids, no markup noise — and the chip opens a dropdown with a + * plain text editor. + */ +export function CommentBlock({ + node, + mode, + isSelected, + setBlockRef, + updateNode, + deleteNode, + deleteSelectedNotebookBlocks, + insertParagraphAfterNode, + moveFocusToAdjacentNode, +}: { + node: NotebookComponentBlockNode; + mode: NotebookMode; + isSelected: boolean; + setBlockRef: (element: HTMLElement | null) => void; + updateNode: ( + nodeId: string, + updater: (node: NotebookBlockNode) => NotebookBlockNode | null, + ) => void; + deleteNode: () => void; + deleteSelectedNotebookBlocks: () => boolean; + insertParagraphAfterNode: () => void; + moveFocusToAdjacentNode: ( + nodeId: string, + direction: InsertMenuSelectionDirection, + offset: number, + ) => boolean; +}): JSX.Element { + const text = typeof node.props.text === "string" ? node.props.text : ""; + // Freshly inserted comments open the editor right away so typing can start immediately — + // but only when this user just inserted it, never when an empty comment merely mounts + // (loading a notebook, a remote merge) where it would steal focus. + const [isEditorOpen, setIsEditorOpen] = useState( + () => mode === "edit" && !text && wasNotebookNodeJustInserted(node.id), + ); + + const setText = (value: string): void => { + updateNode(node.id, (currentNode) => + currentNode.type === "component" + ? { ...currentNode, props: { ...currentNode.props, text: value } } + : currentNode, + ); + }; + + const handleKeyDown = (event: KeyboardEvent): void => { + if (mode !== "edit" || event.target !== event.currentTarget) { + return; + } + + if (event.key === "Backspace" || event.key === "Delete") { + event.preventDefault(); + if (!deleteSelectedNotebookBlocks()) { + deleteNode(); + } + return; + } + + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + if ( + moveFocusToAdjacentNode( + node.id, + event.key === "ArrowDown" ? "next" : "previous", + 0, + ) + ) { + event.preventDefault(); + } + return; + } + + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + if (event.metaKey || event.ctrlKey) { + insertParagraphAfterNode(); + } else { + setIsEditorOpen(true); + } + } + }; + + return ( +
+ + setIsEditorOpen(visible && mode === "edit") + } + closeOnClickInside={false} + placement="bottom-start" + overlay={ +
+ +
+ } + > + +
+
+ ); +} diff --git a/packages/ui/src/features/notebooks/markdown-notebook/DividerBlock.tsx b/packages/ui/src/features/notebooks/markdown-notebook/DividerBlock.tsx new file mode 100644 index 0000000000..169550b17b --- /dev/null +++ b/packages/ui/src/features/notebooks/markdown-notebook/DividerBlock.tsx @@ -0,0 +1,87 @@ +/** + * Vendored from posthog `frontend/src/lib/components/MarkdownNotebook`. + * Keep diffs against upstream minimal: lint rules the upstream code does not + * follow are suppressed file-wide instead of rewriting vendored code. + */ +// biome-ignore-all lint/a11y/useAriaPropsForRole: vendored code, keep close to upstream +// biome-ignore-all lint/a11y/useSemanticElements: vendored code, keep close to upstream + +import clsx from "clsx"; + +import type { JSX, KeyboardEvent } from "react"; + +import type { InsertMenuSelectionDirection } from "./editorTypes"; +import type { NotebookComponentBlockNode, NotebookMode } from "./types"; + +export function DividerBlock({ + node, + mode, + isSelected, + setBlockRef, + deleteNode, + deleteSelectedNotebookBlocks, + insertParagraphAfterNode, + moveFocusToAdjacentNode, +}: { + node: NotebookComponentBlockNode; + mode: NotebookMode; + isSelected: boolean; + setBlockRef: (element: HTMLElement | null) => void; + deleteNode: () => void; + deleteSelectedNotebookBlocks: () => boolean; + insertParagraphAfterNode: () => void; + moveFocusToAdjacentNode: ( + nodeId: string, + direction: InsertMenuSelectionDirection, + offset: number, + ) => boolean; +}): JSX.Element { + const handleKeyDown = (event: KeyboardEvent): void => { + if (mode !== "edit" || event.target !== event.currentTarget) { + return; + } + + if (event.key === "Backspace" || event.key === "Delete") { + event.preventDefault(); + if (!deleteSelectedNotebookBlocks()) { + deleteNode(); + } + return; + } + + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + if ( + moveFocusToAdjacentNode( + node.id, + event.key === "ArrowDown" ? "next" : "previous", + 0, + ) + ) { + event.preventDefault(); + } + return; + } + + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + insertParagraphAfterNode(); + } + }; + + return ( +
+
+
+ ); +} diff --git a/packages/ui/src/features/notebooks/markdown-notebook/EditableCodeBlock.tsx b/packages/ui/src/features/notebooks/markdown-notebook/EditableCodeBlock.tsx new file mode 100644 index 0000000000..c071156e16 --- /dev/null +++ b/packages/ui/src/features/notebooks/markdown-notebook/EditableCodeBlock.tsx @@ -0,0 +1,409 @@ +/** + * Vendored from posthog `frontend/src/lib/components/MarkdownNotebook`. + * Keep diffs against upstream minimal: lint rules the upstream code does not + * follow are suppressed file-wide instead of rewriting vendored code. + */ +// biome-ignore-all lint/style/noRestrictedImports: vendored code, keep close to upstream +// biome-ignore-all lint/correctness/useExhaustiveDependencies: vendored code, keep close to upstream +// biome-ignore-all lint/suspicious/noArrayIndexKey: vendored code, keep close to upstream + +import { IconCopy } from "@posthog/icons"; + +import type { JSX } from "react"; +import { + type FormEvent, + type KeyboardEvent, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { updateNotebookCodeBlockText } from "./documentModel"; +import { + findTextPosition, + getElementLineHeight, + isSelectionAnchoredInsideElement, +} from "./domSelection"; +import type { TextSelectionPointerStartEvent } from "./editorTypes"; +import { copyToClipboard } from "./shims/copyToClipboard"; +import { LemonButton } from "./shims/lemon-ui"; +import type { + NotebookBlockNode, + NotebookCodeBlockNode, + NotebookCodeRefMark, + NotebookMode, +} from "./types"; + +function measureCharacterRect( + element: HTMLElement, + offset: number, +): DOMRect | null { + const startPosition = findTextPosition(element, offset); + const endPosition = findTextPosition(element, offset + 1); + const range = element.ownerDocument.createRange(); + range.setStart(startPosition.node, startPosition.offset); + range.setEnd(endPosition.node, endPosition.offset); + // jsdom ranges have no getClientRects; callers fall back to the computed line-height there + const rect = + typeof range.getClientRects === "function" + ? range.getClientRects()[0] + : undefined; + return rect && (rect.height > 0 || rect.top !== 0) ? rect : null; +} + +// Measures the rendered top of every logical code line so the line-number gutter stays aligned with +// lines that wrap onto multiple visual rows. Each line's top comes from the rect of its first +// character (or, for empty lines, the newline terminating them), so error never accumulates across +// lines. Falls back to stacking the computed line-height where rects are unavailable. +function measureCodeLineTops(element: HTMLElement, lines: string[]): number[] { + const lineHeight = getElementLineHeight(element); + const elementTop = + typeof element.getBoundingClientRect === "function" + ? element.getBoundingClientRect().top + : 0; + const textLength = (element.textContent ?? "").length; + const tops: number[] = []; + let offset = 0; + let fallbackTop = 0; + + for (const line of lines) { + let top = fallbackTop; + + if (offset < textLength) { + // The line's first character — or the "\n" terminating an empty line, which renders on + // the empty line itself. + const rect = measureCharacterRect(element, offset); + if (rect) { + // rect.top is the glyph-box top; recenter to the line-box top so the gutter number + // (which renders its own glyph with the same half-leading) lines up exactly. + top = + rect.top - elementTop - Math.max(0, (lineHeight - rect.height) / 2); + } + } else if (offset > 0) { + // Trailing empty line with no terminating character: one row below the previous "\n". + const rect = measureCharacterRect(element, offset - 1); + if (rect) { + top = + rect.top - + elementTop - + Math.max(0, (lineHeight - rect.height) / 2) + + lineHeight; + } + } + + tops.push(top); + fallbackTop = top + lineHeight; + offset += line.length + 1; + } + + return tops; +} + +function areNumberArraysEqual(left: number[], right: number[]): boolean { + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ); +} + +type CodeRefRect = { + refId: string; + top: number; + left: number; + width: number; + height: number; +}; + +// Code renders as plain text (input handling reads `textContent`), so comment highlights can't be +// inline spans — they're measured from each anchor's text range and painted as an absolutely +// positioned overlay behind the text, one box per rendered line fragment. +function measureCodeRefRects( + frameElement: HTMLElement, + codeElement: HTMLElement, + refs: NotebookCodeRefMark[], +): CodeRefRect[] { + if (typeof frameElement.getBoundingClientRect !== "function") { + return []; + } + + const frameRect = frameElement.getBoundingClientRect(); + const textLength = (codeElement.textContent ?? "").length; + const rects: CodeRefRect[] = []; + for (const ref of refs) { + const start = Math.min(ref.start, textLength); + const end = Math.min(ref.end, textLength); + if (end <= start) { + continue; + } + + const startPosition = findTextPosition(codeElement, start); + const endPosition = findTextPosition(codeElement, end); + const range = codeElement.ownerDocument.createRange(); + range.setStart(startPosition.node, startPosition.offset); + range.setEnd(endPosition.node, endPosition.offset); + // jsdom ranges have no getClientRects; the highlight is skipped there + if (typeof range.getClientRects !== "function") { + continue; + } + + for (const rect of Array.from(range.getClientRects())) { + if (!rect.width || !rect.height) { + continue; + } + rects.push({ + refId: ref.id, + top: rect.top - frameRect.top, + left: rect.left - frameRect.left, + width: rect.width, + height: rect.height, + }); + } + } + + return rects; +} + +function areCodeRefRectsEqual( + left: CodeRefRect[], + right: CodeRefRect[], +): boolean { + return ( + left.length === right.length && + left.every((rect, index) => { + const other = right[index]; + return ( + rect.refId === other.refId && + rect.top === other.top && + rect.left === other.left && + rect.width === other.width && + rect.height === other.height + ); + }) + ); +} + +// A trailing
makes the final empty line visible: browsers collapse a trailing "\n" in +// pre-wrap rendering, so without the sentinel trailing blank lines would not render or be +// reachable with the caret.
contributes nothing to textContent, so text offsets are stable. +function syncTrailingLineBreakSentinel( + element: HTMLElement, + text: string, +): void { + const hasSentinel = element.lastChild instanceof HTMLBRElement; + if (text.endsWith("\n") && !hasSentinel) { + element.appendChild(element.ownerDocument.createElement("br")); + } else if (!text.endsWith("\n") && hasSentinel && element.lastChild) { + element.removeChild(element.lastChild); + } +} + +export function EditableCodeBlock({ + node, + mode, + setBlockRef, + updateNode, + deleteSelectedNotebookBlocks, + handleSelectionChange, + startTextSelectionPointer, +}: { + node: NotebookCodeBlockNode; + mode: NotebookMode; + setBlockRef: (element: HTMLElement | null) => void; + updateNode: ( + nodeId: string, + updater: (node: NotebookBlockNode) => NotebookBlockNode | null, + ) => void; + deleteSelectedNotebookBlocks: () => boolean; + handleSelectionChange: () => void; + startTextSelectionPointer: (event: TextSelectionPointerStartEvent) => void; +}): JSX.Element { + const elementRef = useRef(null); + const frameRef = useRef(null); + const skipDomSyncForTextRef = useRef(null); + const [lineTops, setLineTops] = useState([]); + const [refRects, setRefRects] = useState([]); + + const lines = node.text.split("\n"); + const refs = node.refs; + + const setElementRef = useCallback( + (element: HTMLPreElement | null): void => { + elementRef.current = element; + setBlockRef(element); + }, + [setBlockRef], + ); + + useLayoutEffect(() => { + const element = elementRef.current; + if (!element) { + return; + } + + const selection = window.getSelection(); + const shouldSkipOwnInputSync = + (document.activeElement === element || + isSelectionAnchoredInsideElement(selection, element)) && + skipDomSyncForTextRef.current === node.text; + skipDomSyncForTextRef.current = null; + + if (shouldSkipOwnInputSync) { + return; + } + + if (element.textContent !== node.text) { + element.textContent = node.text; + } + syncTrailingLineBreakSentinel(element, node.text); + }, [node.id, node.text]); + + const measureLineTops = useCallback((): void => { + const element = elementRef.current; + if (!element) { + return; + } + + const nextTops = measureCodeLineTops( + element, + (element.textContent ?? "").split("\n"), + ); + setLineTops((currentTops) => + areNumberArraysEqual(currentTops, nextTops) ? currentTops : nextTops, + ); + }, []); + + const measureRefRects = useCallback((): void => { + const element = elementRef.current; + const frameElement = frameRef.current; + if (!element || !frameElement) { + return; + } + + const nextRects = refs?.length + ? measureCodeRefRects(frameElement, element, refs) + : []; + setRefRects((currentRects) => + areCodeRefRectsEqual(currentRects, nextRects) ? currentRects : nextRects, + ); + }, [refs]); + + useLayoutEffect(() => { + measureLineTops(); + measureRefRects(); + }, [measureLineTops, measureRefRects, node.text]); + + useEffect(() => { + const element = elementRef.current; + if (!element || typeof ResizeObserver === "undefined") { + return; + } + + const observer = new ResizeObserver(() => { + measureLineTops(); + measureRefRects(); + }); + observer.observe(element); + return () => observer.disconnect(); + }, [measureLineTops, measureRefRects]); + + const updateText = (text: string): void => { + skipDomSyncForTextRef.current = text; + updateNode(node.id, (currentNode) => { + if (currentNode.type !== "code") { + return currentNode; + } + + return updateNotebookCodeBlockText(currentNode, text); + }); + }; + + const handleInput = (event: FormEvent): void => { + updateText(event.currentTarget.textContent ?? ""); + }; + + const handleKeyDown = (event: KeyboardEvent): void => { + if ( + (event.key === "Backspace" || event.key === "Delete") && + deleteSelectedNotebookBlocks() + ) { + event.preventDefault(); + event.stopPropagation(); + } + }; + + return ( +
+ {refRects.length ? ( + + ); +} diff --git a/packages/ui/src/features/notebooks/markdown-notebook/EditableListBlock.tsx b/packages/ui/src/features/notebooks/markdown-notebook/EditableListBlock.tsx new file mode 100644 index 0000000000..035d45c984 --- /dev/null +++ b/packages/ui/src/features/notebooks/markdown-notebook/EditableListBlock.tsx @@ -0,0 +1,486 @@ +/** + * Vendored from posthog `frontend/src/lib/components/MarkdownNotebook`. + * Keep diffs against upstream minimal: lint rules the upstream code does not + * follow are suppressed file-wide instead of rewriting vendored code. + */ +// biome-ignore-all lint/a11y/noStaticElementInteractions: vendored code, keep close to upstream +// React 19 types no longer declare a global JSX namespace; import it for the upstream `JSX.Element` annotations. +import type { JSX } from "react"; + +import { + type FormEvent, + type MutableRefObject, + type ClipboardEvent as ReactClipboardEvent, + type ReactNode, + useCallback, + useLayoutEffect, + useMemo, + useRef, +} from "react"; + +import { getTaskItemShortcut, shouldUseMarkdownPaste } from "./documentModel"; +import { getInlineLinkPasteResult, getSelectionRange } from "./domSelection"; +import type { + RestoreSelectionRequest, + TextSelectionPointerStartEvent, +} from "./editorTypes"; +import { splitInlineNodesAt } from "./inlineContent"; +import { + buildRenderedListItems, + getListItemIndex, + getOrderedListStart, + type RenderedListItem, +} from "./listModel"; +import { + htmlElementToInlineNodes, + inlineNodesToHtml, + parseMarkdownNotebook, +} from "./markdown"; +import type { + NotebookBlockNode, + NotebookInlineNode, + NotebookListBlockNode, + NotebookListItem, + NotebookMode, +} from "./types"; +import { getInlineText, normalizeInlineNodes } from "./utils"; + +export function EditableListBlock({ + node, + mode, + setBlockRef, + setListItemRef, + updateNode, + handleSelectionChange, + startTextSelectionPointer, + restoreSelectionRef, +}: { + node: NotebookListBlockNode; + mode: NotebookMode; + setBlockRef: (element: HTMLElement | null) => void; + setListItemRef: ( + itemIndex: number, + itemId: string | undefined, + element: HTMLElement | null, + ) => void; + updateNode: ( + nodeId: string, + updater: (node: NotebookBlockNode) => NotebookBlockNode | null, + ) => void; + handleSelectionChange: () => void; + startTextSelectionPointer: (event: TextSelectionPointerStartEvent) => void; + restoreSelectionRef: MutableRefObject; +}): JSX.Element { + const renderedItems = useMemo( + () => buildRenderedListItems(node.items), + [node.items], + ); + const listBlockRef = useRef(null); + + const setListBlockRef = useCallback( + (element: HTMLDivElement | null): void => { + listBlockRef.current = element; + setBlockRef(element); + }, + [setBlockRef], + ); + + const updateListItem = ( + itemIndex: number, + itemId: string | undefined, + updater: (item: NotebookListItem) => NotebookListItem, + ): void => { + updateNode(node.id, (currentNode) => { + if (currentNode.type !== "list") { + return currentNode; + } + + const targetItemIndex = getListItemIndex( + currentNode.items, + itemIndex, + itemId, + ); + if (!currentNode.items[targetItemIndex]) { + return currentNode; + } + + return { + ...currentNode, + items: currentNode.items.map((item, index) => + index === targetItemIndex ? updater(item) : item, + ), + }; + }); + }; + + const updateListItemChildren = ( + itemIndex: number, + itemId: string | undefined, + children: NotebookInlineNode[], + ): void => { + updateListItem(itemIndex, itemId, (item) => ({ ...item, children })); + }; + + const getListItemContentElement = ( + target: EventTarget | Node | null, + ): HTMLElement | null => { + const element = + target instanceof Element + ? target + : target instanceof Node + ? target.parentElement + : null; + const itemElement = element?.closest( + ".MarkdownNotebook__list-item-content", + ); + if ( + !(itemElement instanceof HTMLElement) || + !listBlockRef.current?.contains(itemElement) + ) { + return null; + } + + return itemElement; + }; + + const getActiveListItemContentElement = (): HTMLElement | null => { + const selectionItemElement = getListItemContentElement( + window.getSelection()?.anchorNode ?? null, + ); + if (selectionItemElement) { + return selectionItemElement; + } + + return getListItemContentElement(document.activeElement); + }; + + const getListItemDetails = ( + element: HTMLElement, + ): { + itemIndex: number; + itemId: string | undefined; + item: NotebookListItem; + } | null => { + const itemIndex = Number(element.dataset.markdownNotebookListItemIndex); + if (!Number.isInteger(itemIndex)) { + return null; + } + + const itemId = element.dataset.markdownNotebookListItemId; + const targetItemIndex = getListItemIndex(node.items, itemIndex, itemId); + const item = node.items[targetItemIndex]; + if (!item) { + return null; + } + + return { itemIndex: targetItemIndex, itemId: item.id ?? itemId, item }; + }; + + const updateListItemChildrenFromElement = (element: HTMLElement): void => { + const details = getListItemDetails(element); + if (!details) { + return; + } + + updateListItemChildren( + details.itemIndex, + details.itemId, + htmlElementToInlineNodes(element), + ); + }; + + const handleListBlockInput = (event: FormEvent): void => { + event.stopPropagation(); + if (event.target instanceof HTMLInputElement) { + // Checkbox toggles go through onChange, not the contenteditable input flow + return; + } + const element = + getListItemContentElement(event.target) ?? + getActiveListItemContentElement(); + if (!element) { + return; + } + + const details = getListItemDetails(element); + if (!details) { + return; + } + + const children = htmlElementToInlineNodes(element); + const taskShortcut = + details.item.checked === undefined && + !(details.item.ordered ?? node.ordered) + ? getTaskItemShortcut(children) + : null; + if (taskShortcut) { + const selection = getSelectionRange(element, node.id); + const caretOffset = Math.max( + 0, + (selection?.start ?? taskShortcut.markerLength) - + taskShortcut.markerLength, + ); + updateListItem(details.itemIndex, details.itemId, (item) => ({ + ...item, + checked: taskShortcut.checked, + children: taskShortcut.children, + })); + restoreSelectionRef.current = { + nodeId: node.id, + listItemIndex: details.itemIndex, + listItemId: details.itemId, + start: caretOffset, + end: caretOffset, + }; + return; + } + + updateListItemChildren(details.itemIndex, details.itemId, children); + }; + + const handleListBlockPaste = ( + event: ReactClipboardEvent, + ): void => { + const element = getActiveListItemContentElement(); + const details = element ? getListItemDetails(element) : null; + if (!element || !details) { + return; + } + + const plainText = event.clipboardData.getData("text/plain"); + const html = event.clipboardData.getData("text/html"); + const linkPasteResult = getInlineLinkPasteResult( + element, + node.id, + details.item.children, + plainText, + ); + if (linkPasteResult) { + event.preventDefault(); + updateListItemChildren( + details.itemIndex, + details.itemId, + linkPasteResult.children, + ); + restoreSelectionRef.current = { + nodeId: node.id, + listItemIndex: details.itemIndex, + listItemId: details.itemId, + start: linkPasteResult.start, + end: linkPasteResult.end, + }; + return; + } + + const pastedDocument = plainText ? parseMarkdownNotebook(plainText) : null; + if ( + pastedDocument && + pastedDocument.nodes.length === 1 && + pastedDocument.nodes[0].type === "paragraph" && + shouldUseMarkdownPaste(plainText, html, pastedDocument) + ) { + event.preventDefault(); + const selection = getSelectionRange(element, node.id); + const currentTextLength = getInlineText(details.item.children).length; + const selectionStart = selection + ? Math.min(selection.start, selection.end) + : currentTextLength; + const selectionEnd = selection + ? Math.max(selection.start, selection.end) + : currentTextLength; + const [beforeSelection, selectionAndAfter] = splitInlineNodesAt( + details.item.children, + selectionStart, + ); + const [, afterSelection] = splitInlineNodesAt( + selectionAndAfter, + selectionEnd - selectionStart, + ); + const nextChildren = normalizeInlineNodes([ + ...beforeSelection, + ...pastedDocument.nodes[0].children, + ...afterSelection, + ]); + const nextCaretOffset = + getInlineText(beforeSelection).length + + getInlineText(pastedDocument.nodes[0].children).length; + updateListItemChildren(details.itemIndex, details.itemId, nextChildren); + restoreSelectionRef.current = { + nodeId: node.id, + listItemIndex: details.itemIndex, + listItemId: details.itemId, + start: nextCaretOffset, + end: nextCaretOffset, + }; + return; + } + + if (!html) { + return; + } + + event.preventDefault(); + const container = document.createElement("div"); + container.innerHTML = html; + document.execCommand( + "insertHTML", + false, + inlineNodesToHtml(htmlElementToInlineNodes(container)), + ); + updateListItemChildrenFromElement(element); + }; + + const toggleListItemChecked = ( + itemIndex: number, + itemId: string | undefined, + ): void => { + updateListItem(itemIndex, itemId, (item) => ({ + ...item, + checked: !item.checked, + })); + }; + + const renderListItems = ( + items: RenderedListItem[], + ordered: boolean, + ): ReactNode => + items.map((item) => { + const itemOrdered = item.ordered ?? ordered; + const isTaskItem = !itemOrdered && item.checked !== undefined; + return ( +
  • + {isTaskItem ? ( + // The checkbox replaces the bullet; contentEditable={false} keeps the caret + // and text selection out of it inside the editable list block. + event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + > + toggleListItemChecked(item.index, item.id)} + /> + + ) : null} + + {item.childrenItems.length + ? renderItems( + item.childrenItems, + item.childrenItems[0].ordered ?? itemOrdered, + ) + : null} +
  • + ); + }); + + const renderItems = ( + items: RenderedListItem[], + ordered: boolean, + fallbackStart?: number, + ): ReactNode => { + if (ordered) { + return ( +
      + {renderListItems(items, ordered)} +
    + ); + } + + return
      {renderListItems(items, ordered)}
    ; + }; + + return ( +
    + {renderItems(renderedItems, node.ordered, node.start)} +
    + ); +} + +export function EditableListItemContent({ + node, + item, + setListItemRef, +}: { + node: NotebookListBlockNode; + item: RenderedListItem; + setListItemRef: ( + itemIndex: number, + itemId: string | undefined, + element: HTMLElement | null, + ) => void; +}): JSX.Element { + const elementRef = useRef(null); + const renderedHtml = useMemo( + () => inlineNodesToHtml(item.children), + [item.children], + ); + + const setElementRef = useCallback( + (element: HTMLDivElement | null): void => { + elementRef.current = element; + setListItemRef(item.index, item.id, element); + }, + [item.id, item.index, setListItemRef], + ); + + useLayoutEffect(() => { + const element = elementRef.current; + if (!element) { + return; + } + + if (element.innerHTML === renderedHtml) { + return; + } + + // While the caret is inside the item, the DOM is the source of the latest model state: + // rewriting innerHTML would destroy the caret mid-typing, so only sync when the live DOM + // does not already represent the same content. + const selection = window.getSelection(); + if ( + selection?.anchorNode && + element.contains(selection.anchorNode) && + inlineNodesToHtml(htmlElementToInlineNodes(element)) === renderedHtml + ) { + return; + } + + element.innerHTML = renderedHtml; + }); + + return ( +
    + ); +} diff --git a/packages/ui/src/features/notebooks/markdown-notebook/EditablePromptComponent.tsx b/packages/ui/src/features/notebooks/markdown-notebook/EditablePromptComponent.tsx new file mode 100644 index 0000000000..405057ccdf --- /dev/null +++ b/packages/ui/src/features/notebooks/markdown-notebook/EditablePromptComponent.tsx @@ -0,0 +1,276 @@ +/** + * Vendored from posthog `frontend/src/lib/components/MarkdownNotebook`. + * Keep diffs against upstream minimal: lint rules the upstream code does not + * follow are suppressed file-wide instead of rewriting vendored code. + */ +// biome-ignore-all lint/style/noRestrictedImports: vendored code, keep close to upstream +// biome-ignore-all lint/a11y/useAriaPropsSupportedByRole: vendored code, keep close to upstream +// biome-ignore-all lint/a11y/noAutofocus: vendored code, keep close to upstream +// biome-ignore-all lint/correctness/useExhaustiveDependencies: vendored code, keep close to upstream + +import { IconSend, IconTrash } from "@posthog/icons"; +import clsx from "clsx"; + +import type { JSX } from "react"; +import { + type KeyboardEvent, + type MutableRefObject, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { getNotebookStringProp, isPromptComponentNode } from "./documentModel"; +import type { RestoreSelectionRequest } from "./editorTypes"; +import { LemonButton } from "./shims/lemon-ui"; +import type { + NotebookBlockNode, + NotebookComponentBlockNode, + NotebookMode, +} from "./types"; + +export function EditablePromptComponent({ + node, + mode, + setBlockRef, + updateNode, + deleteNodeAndFocusAdjacent, + updateAIPromptQuery, + submitAIPrompt, + isAIPromptSubmitDisabled, + isActive, + focusRequest, + restoreSelectionRef, +}: { + node: NotebookComponentBlockNode; + mode: NotebookMode; + setBlockRef: (element: HTMLElement | null) => void; + updateNode: ( + nodeId: string, + updater: (node: NotebookBlockNode) => NotebookBlockNode | null, + ) => void; + deleteNodeAndFocusAdjacent: () => void; + updateAIPromptQuery: (query: string) => void; + submitAIPrompt: (queryOverride?: string) => boolean; + isAIPromptSubmitDisabled: boolean; + isActive: boolean; + focusRequest?: number; + restoreSelectionRef: MutableRefObject; +}): JSX.Element { + const elementRef = useRef(null); + const handledFocusRequestRef = useRef(undefined); + const [isCollapsed, setIsCollapsed] = useState(false); + const question = getNotebookStringProp(node.props.question) ?? ""; + const isEmpty = question.length === 0; + const submitDisabledReason = question.trim() + ? isAIPromptSubmitDisabled + ? "AI is already running" + : undefined + : "Write a prompt first"; + + const setElementRef = useCallback( + (element: HTMLTextAreaElement | null): void => { + elementRef.current = element; + setBlockRef(element); + }, + [setBlockRef], + ); + + useEffect(() => { + if (isActive) { + setIsCollapsed(false); + } + }, [isActive]); + + useEffect(() => { + if ( + focusRequest !== undefined && + handledFocusRequestRef.current !== focusRequest && + isCollapsed + ) { + setIsCollapsed(false); + } + }, [focusRequest, isCollapsed]); + + useEffect(() => { + const element = elementRef.current; + if (!isActive || !element || document.activeElement === element) { + return; + } + + element.focus(); + element.setSelectionRange(question.length, question.length); + }, [isActive, question.length]); + + useEffect(() => { + const element = elementRef.current; + if ( + focusRequest === undefined || + handledFocusRequestRef.current === focusRequest || + !element + ) { + return; + } + + if (document.activeElement === element) { + handledFocusRequestRef.current = focusRequest; + return; + } + + element.focus(); + element.setSelectionRange(question.length, question.length); + handledFocusRequestRef.current = focusRequest; + }, [focusRequest, question.length, isCollapsed]); + + const updateQuestion = (nextQuestion: string): void => { + updateNode(node.id, (currentNode) => { + if (!isPromptComponentNode(currentNode)) { + return currentNode; + } + return { + ...currentNode, + props: { + ...currentNode.props, + question: nextQuestion, + }, + }; + }); + updateAIPromptQuery(nextQuestion); + }; + + const submitPrompt = (query: string = question): void => { + if (isAIPromptSubmitDisabled) { + return; + } + submitAIPrompt(query); + }; + + const deletePrompt = (): void => { + deleteNodeAndFocusAdjacent(); + }; + + const handleKeyDown = (event: KeyboardEvent): void => { + event.stopPropagation(); + + if ( + event.key === "Enter" && + !event.nativeEvent.isComposing && + !event.shiftKey + ) { + event.preventDefault(); + submitPrompt(event.currentTarget.value); + return; + } + + if (event.key === "Backspace" || event.key === "Delete") { + const selectionStart = event.currentTarget.selectionStart ?? 0; + const selectionEnd = event.currentTarget.selectionEnd ?? selectionStart; + if (selectionStart !== selectionEnd) { + const target = event.currentTarget; + const nextQuestion = `${question.slice(0, selectionStart)}${question.slice(selectionEnd)}`; + + event.preventDefault(); + updateQuestion(nextQuestion); + requestAnimationFrame(() => + target.setSelectionRange(selectionStart, selectionStart), + ); + return; + } + + if ( + event.key === "Backspace" && + selectionStart === 0 && + selectionEnd === 0 + ) { + event.preventDefault(); + updateNode(node.id, (currentNode) => { + if (!isPromptComponentNode(currentNode)) { + return currentNode; + } + return { + id: currentNode.id, + type: "paragraph", + children: question ? [{ type: "text", text: question }] : [], + }; + }); + restoreSelectionRef.current = { nodeId: node.id, start: 0, end: 0 }; + return; + } + + if (event.key === "Delete" && isEmpty) { + event.preventDefault(); + deletePrompt(); + } + } + }; + + return ( +
    +
    +
    + +
    + {isCollapsed ? null : ( +
    +