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..d161a98406 100644 --- a/packages/api-client/src/posthog-client.test.ts +++ b/packages/api-client/src/posthog-client.test.ts @@ -1696,3 +1696,285 @@ 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" }); + }); + + // The real shared fetcher (buildApiFetcher) throws on any non-2xx instead + // of returning the response — these cover the recovery path it exercises. + it("recovers a conflict from the fetcher's thrown 409 error", async () => { + const conflictBody = { + version: 7, + updates: [ + { version: 6, diff: [{ start: 2, end: 2, text: "y" }], base_crc: 42 }, + ], + }; + const fetch = vi + .fn() + .mockRejectedValue( + new Error(`Failed request: [409] ${JSON.stringify(conflictBody)}`), + ); + const client = makeClient(fetch); + + await expect( + client.notebookMarkdownSave("abc123", saveBody), + ).resolves.toEqual({ + status: "conflict", + serverVersion: 7, + updates: conflictBody.updates, + }); + }); + + it("recovers gone from the fetcher's thrown 410 error", async () => { + const fetch = vi + .fn() + .mockRejectedValue(new Error('Failed request: [410] {"detail":"gone"}')); + const client = makeClient(fetch); + + await expect( + client.notebookMarkdownSave("abc123", saveBody), + ).resolves.toEqual({ status: "gone" }); + }); + + it("rethrows non-protocol thrown failures", async () => { + const fetch = vi + .fn() + .mockRejectedValue(new Error('Failed request: [500] {"error":"boom"}')); + const client = makeClient(fetch); + + await expect( + client.notebookMarkdownSave("abc123", saveBody), + ).rejects.toThrow("[500]"); + }); + + 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"); + }); +}); + +describe("PostHogAPIClient.notebookCollabStream", () => { + 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; + } + + function sseResponse(chunks: string[]): Response { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)); + } + controller.close(); + }, + }); + return new Response(stream, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + } + + it("parses SSE frames and passes the Last-Event-ID header", async () => { + const fetch = vi.fn().mockResolvedValue( + sseResponse([ + ": keepalive\n\n", + 'id: 12-1\nevent: update\ndata: {"type":"update",\ndata: "version":12}\n\n', + // Frame split across chunks: the parser must buffer partial lines. + 'event: presence\ndata: {"type":"pres', + 'ence"}\n\n', + ]), + ); + const client = makeClient(fetch); + + const events: { id?: string; event: string; data: string }[] = []; + await client.notebookCollabStream("abc123", { + lastEventId: "11-0", + signal: new AbortController().signal, + onEvent: (event) => events.push(event), + }); + + expect(fetch).toHaveBeenCalledWith( + expect.objectContaining({ + method: "get", + path: "/api/projects/123/notebooks/abc123/collab/stream", + parameters: { + header: { + Accept: "text/event-stream", + "Last-Event-ID": "11-0", + }, + }, + }), + ); + // The keepalive comment produced no event; multi-line data joined with \n; + // presence frames have no id. + expect(events).toEqual([ + { id: "12-1", event: "update", data: '{"type":"update",\n"version":12}' }, + { id: undefined, event: "presence", data: '{"type":"presence"}' }, + ]); + }); + + it("omits the Last-Event-ID header on a fresh connection", async () => { + const fetch = vi.fn().mockResolvedValue(sseResponse([])); + const client = makeClient(fetch); + + await client.notebookCollabStream("abc123", { + signal: new AbortController().signal, + onEvent: () => {}, + }); + + expect(fetch.mock.calls[0][0].parameters.header).toEqual({ + Accept: "text/event-stream", + }); + }); + + it("resolves when the stream ends and discards a trailing partial frame", async () => { + const fetch = vi.fn().mockResolvedValue( + sseResponse([ + 'id: 3-1\nevent: update\ndata: {"version":3}\n\n', + // No terminating blank line: incomplete, discarded per the SSE spec. + "id: 4-1\nevent: update", + ]), + ); + const client = makeClient(fetch); + + const events: { id?: string; event: string; data: string }[] = []; + await client.notebookCollabStream("abc123", { + signal: new AbortController().signal, + onEvent: (event) => events.push(event), + }); + + expect(events).toEqual([ + { id: "3-1", event: "update", data: '{"version":3}' }, + ]); + }); +}); + +describe("PostHogAPIClient.notebookPublishPresence", () => { + it("posts the caret to the collab presence endpoint", async () => { + const fetch = vi + .fn() + .mockResolvedValue(new Response(null, { status: 204 })); + 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 } }; + + const body = { + client_id: "client-1", + version: 3, + cursor: { node_index: 1, offset: 2 }, + }; + await client.notebookPublishPresence("abc123", body); + + expect(fetch).toHaveBeenCalledWith( + expect.objectContaining({ + method: "post", + path: "/api/projects/123/notebooks/abc123/collab/presence/", + overrides: { body: JSON.stringify(body) }, + }), + ); + }); +}); diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 18a2aa6da6..9e1fb0e3e9 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -138,6 +138,53 @@ 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; +} + +/** + * Recover the HTTP status and JSON body from the shared fetcher's thrown + * `Failed request: [] ` error, for endpoints where specific + * non-2xx statuses are protocol states rather than failures. + */ +function parseFailedRequestError( + error: unknown, +): { status: number; body: unknown } | null { + if (!(error instanceof Error)) return null; + const match = error.message.match(/^Failed request: \[(\d{3})\] (.*)$/s); + if (!match) return null; + let body: unknown = null; + try { + body = JSON.parse(match[2]); + } catch { + body = null; + } + return { status: Number(match[1]), body }; +} + +/** 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 +1942,890 @@ 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; + } + + // A body carrying `content` MUST include `version` (the baseline the edit + // was derived from) — the backend 409s content updates whose version + // doesn't match the persisted notebook. + async patchNotebook( + shortId: string, + body: { + title?: string; + content?: unknown; + text_content?: string | null; + version?: number; + }, + ): 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, + }, + ); + } + + // Start (or continue) a Max AI conversation turn and return the raw SSE + // Response for the caller to stream. The shared fetcher throws on non-2xx, + // so a returned Response is always ok. + async startConversationStream( + body: Record, + signal: AbortSignal, + ): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/environments/${teamId}/conversations/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + // No Accept header: DRF content negotiation on this endpoint 406s + // `text/event-stream` even though the response body streams SSE. The + // PostHog web app sends no Accept header here either. + return await this.api.fetcher.fetch({ + method: "post", + url, + path: urlPath, + overrides: { + body: JSON.stringify(body), + signal, + }, + }); + } + + // Cancel an in-progress Max AI conversation turn (best-effort; the caller + // usually also aborts its stream fetch). Same verb/path as the PostHog web + // app: PATCH .../conversations/{id}/cancel/. + async cancelConversation(conversationId: string): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/environments/${teamId}/conversations/${encodeURIComponent(conversationId)}/cancel/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + await this.api.fetcher.fetch({ + method: "patch", + url, + path: urlPath, + overrides: { + body: "{}", + }, + }); + } + + // ------------------------------------------------------------------------- + // Notebook kernel + runnable SQL cells. These routes aren't in the generated + // OpenAPI client, so everything goes through the raw fetcher. Response + // interfaces (NotebookKernel*, NotebookSqlV2*) live at the bottom of this + // file. Base path: /api/projects/{teamId}/notebooks/{shortId}/… + // ------------------------------------------------------------------------- + + private async notebookActionUrl( + shortId: string, + action: string, + ): Promise<{ urlPath: string; url: URL }> { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/notebooks/${encodeURIComponent(shortId)}/${action}`; + return { urlPath, url: new URL(`${this.api.baseUrl}${urlPath}`) }; + } + + /** Boot the notebook's Python kernel (503 propagates as a throw). */ + async notebookKernelStart( + shortId: string, + ): Promise<{ id: string; status: string }> { + const { urlPath, url } = await this.notebookActionUrl( + shortId, + "kernel/start", + ); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: urlPath, + overrides: { body: "{}" }, + }); + return (await response.json()) as { id: string; status: string }; + } + + async notebookKernelStop(shortId: string): Promise<{ stopped: boolean }> { + const { urlPath, url } = await this.notebookActionUrl( + shortId, + "kernel/stop", + ); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: urlPath, + overrides: { body: "{}" }, + }); + return (await response.json()) as { stopped: boolean }; + } + + async notebookKernelRestart( + shortId: string, + ): Promise<{ id: string; status: string }> { + const { urlPath, url } = await this.notebookActionUrl( + shortId, + "kernel/restart", + ); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: urlPath, + overrides: { body: "{}" }, + }); + return (await response.json()) as { id: string; status: string }; + } + + /** Kernel status; `backend === null` means no kernel exists yet. */ + async notebookKernelStatus(shortId: string): Promise { + const { urlPath, url } = await this.notebookActionUrl( + shortId, + "kernel/status", + ); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + return (await response.json()) as NotebookKernelStatus; + } + + /** Persist the kernel compute profile; applied on the next start/restart. */ + async notebookKernelConfig( + shortId: string, + body: { + cpu_cores?: number; + memory_gb?: number; + idle_timeout_seconds?: number; + }, + ): Promise> { + const { urlPath, url } = await this.notebookActionUrl( + shortId, + "kernel/config", + ); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: urlPath, + overrides: { body: JSON.stringify(body) }, + }); + return (await response.json()) as Record; + } + + /** Run code on the kernel (lazily boots it) and await the execution dict. */ + async notebookKernelExecute( + shortId: string, + body: { code: string; return_variables?: boolean; timeout?: number }, + ): Promise { + const { urlPath, url } = await this.notebookActionUrl( + shortId, + "kernel/execute", + ); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: urlPath, + overrides: { body: JSON.stringify(body) }, + }); + return (await response.json()) as NotebookKernelExecution; + } + + // Streamed kernel execution over SSE (POST body, unlike EventSource). Frames + // arrive as `event: stdout|stderr|result|error` with a JSON `data:` payload — + // stdout/stderr carry `{text}`, `result` the full execution dict (terminal), + // `error` `{error}`. Same hand-rolled SSE parsing as notebookCollabStream: + // the shared fetcher returns the raw Response on 2xx, so the body streams. + // Resolves when the server ends the stream; rejects on network errors and on + // abort (an `AbortError`). + async notebookKernelExecuteStream( + shortId: string, + body: { code: string; return_variables?: boolean; timeout?: number }, + opts: { + signal: AbortSignal; + onEvent: (event: { event: string; data: string }) => void; + }, + ): Promise { + const { urlPath, url } = await this.notebookActionUrl( + shortId, + "kernel/execute/stream", + ); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: urlPath, + overrides: { + body: JSON.stringify(body), + signal: opts.signal, + }, + }); + if (!response.body) { + throw new Error("Kernel execute stream has no response body"); + } + + // Minimal SSE parser (see notebookCollabStream): accumulate field lines, + // dispatch a frame on each blank line, ignore `:` keepalive comments. A + // partial frame at EOF is discarded, per the SSE spec. + let eventName: string | undefined; + let dataLines: string[] = []; + const dispatch = (): void => { + if (eventName === undefined && dataLines.length === 0) return; + opts.onEvent({ + event: eventName ?? "message", + data: dataLines.join("\n"), + }); + eventName = undefined; + dataLines = []; + }; + const handleLine = (rawLine: string): void => { + const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; + if (line === "") { + dispatch(); + return; + } + if (line.startsWith(":")) return; // comment (keepalive) + const colon = line.indexOf(":"); + const field = colon === -1 ? line : line.slice(0, colon); + let value = colon === -1 ? "" : line.slice(colon + 1); + if (value.startsWith(" ")) value = value.slice(1); + if (field === "event") eventName = value; + else if (field === "data") dataLines.push(value); + }; + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let newline = buffer.indexOf("\n"); + while (newline !== -1) { + handleLine(buffer.slice(0, newline)); + buffer = buffer.slice(newline + 1); + newline = buffer.indexOf("\n"); + } + } + } finally { + reader.releaseLock(); + } + } + + /** Page a live kernel dataframe variable (503 = kernel not running). */ + async notebookKernelDataframe( + shortId: string, + params: { variableName: string; offset?: number; limit?: number }, + ): Promise { + const { urlPath, url } = await this.notebookActionUrl( + shortId, + "kernel/dataframe", + ); + url.searchParams.set("variable_name", params.variableName); + url.searchParams.set("offset", String(params.offset ?? 0)); + url.searchParams.set("limit", String(params.limit ?? 100)); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + const data = (await response.json()) as { + columns?: string[]; + rows?: Record[]; + row_count?: number; + }; + return { + columns: data.columns ?? [], + rows: data.rows ?? [], + row_count: data.row_count ?? 0, + }; + } + + // Plain HogQL execution — no kernel involved. The endpoint 400s on a bad + // query with `{error}`; that's a protocol state here, so it's recovered from + // the fetcher's thrown `Failed request: [400] {json}` and returned as + // `{error}` instead of surfacing a throw. + async notebookHogqlExecute( + shortId: string, + query: string, + ): Promise { + const { urlPath, url } = await this.notebookActionUrl( + shortId, + "hogql/execute", + ); + try { + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: urlPath, + overrides: { body: JSON.stringify({ query }) }, + }); + return (await response.json()) as NotebookHogqlExecuteResponse; + } catch (error) { + const parsed = parseFailedRequestError(error); + if (parsed?.status === 400) { + const body = parsed.body as { error?: string } | null; + return { error: body?.error ?? "Query failed" }; + } + throw error; + } + } + + // SQL v2 runs are server-side gated: the routes 404 when the feature is + // disabled for the project. All three methods return a discriminated + // `{status: "disabled"}` on 404 instead of throwing so cells can render a + // "not enabled" hint. + async notebookSqlV2Run( + shortId: string, + body: { node_id: string; code: string; refs?: Record }, + ): Promise { + const { urlPath, url } = await this.notebookActionUrl( + shortId, + "sql_v2/run", + ); + try { + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: urlPath, + overrides: { body: JSON.stringify({ refs: {}, ...body }) }, + }); + const data = (await response.json()) as { run_id: string }; + return { status: "started", runId: data.run_id }; + } catch (error) { + if (parseFailedRequestError(error)?.status === 404) { + return { status: "disabled" }; + } + throw error; + } + } + + async notebookSqlV2RunStatus( + shortId: string, + runId: string, + ): Promise { + const { urlPath, url } = await this.notebookActionUrl( + shortId, + `sql_v2/runs/${encodeURIComponent(runId)}`, + ); + try { + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + const data = (await response.json()) as { + status: "running" | "done" | "failed"; + result?: NotebookSqlV2ResultEnvelope | null; + error?: string | null; + }; + if (data.status === "done") { + return { status: "done", result: data.result ?? null }; + } + if (data.status === "failed") { + return { status: "failed", error: data.error ?? null }; + } + return { status: "running" }; + } catch (error) { + if (parseFailedRequestError(error)?.status === 404) { + return { status: "disabled" }; + } + throw error; + } + } + + // Page a finished SQL v2 run (needs a running kernel). 409 = the result was + // replaced by a newer run ("stale"), 429 = the kernel is busy — both are + // protocol states the pagination UI recovers from, so they come back as + // discriminated statuses rather than throws. + async notebookSqlV2RunPage( + shortId: string, + runId: string, + params: { offset: number; limit: number }, + ): Promise { + const { urlPath, url } = await this.notebookActionUrl( + shortId, + `sql_v2/runs/${encodeURIComponent(runId)}/page`, + ); + url.searchParams.set("offset", String(params.offset)); + url.searchParams.set("limit", String(params.limit)); + try { + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + const data = (await response.json()) as { + columns?: string[]; + types?: [string, string][]; + rows?: (string | number | null)[][]; + has_more?: boolean; + }; + return { + status: "ok", + page: { + columns: data.columns ?? [], + types: data.types ?? [], + rows: data.rows ?? [], + has_more: data.has_more ?? false, + }, + }; + } catch (error) { + const status = parseFailedRequestError(error)?.status; + if (status === 404) return { status: "disabled" }; + if (status === 409) return { status: "stale" }; + if (status === 429) return { status: "busy" }; + throw error; + } + } + + // 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}`); + let response: Response; + try { + response = await this.api.fetcher.fetch({ + method: "post", + url, + path: urlPath, + overrides: { + body: JSON.stringify(body), + }, + }); + } catch (error) { + // The shared fetcher throws on any non-2xx as + // `Failed request: [] ` (see fetcher.ts), but for + // this endpoint 409/410 are protocol states, not failures — recover + // them from the error instead of surfacing a throw. + const parsed = parseFailedRequestError(error); + if (parsed?.status === 409) { + const conflict = parsed.body as { + updates?: NotebookMarkdownUpdate[]; + version?: number; + } | null; + if (conflict && Array.isArray(conflict.updates)) { + return { + status: "conflict", + serverVersion: conflict.version ?? 0, + updates: conflict.updates, + }; + } + } + if (parsed?.status === 410) { + return { status: "gone" }; + } + throw error; + } + if (response.ok) { + return { + status: "saved", + notebook: (await response.json()) as Schemas.Notebook, + }; + } + // Reached only when a caller supplies a non-throwing fetcher (tests). + 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}`); + } + + // Subscribe to the notebook collab SSE stream (`collab/stream`): accepted + // markdown updates and presence pings. Hand-rolled SSE parsing over the raw + // fetcher — the shared fetcher returns the raw Response on 2xx, so the body + // is streamable. `lastEventId` resumes from a previous connection via the + // `Last-Event-ID` request header. Resolves when the server ends the stream + // (it caps connections at ~5 minutes — callers reconnect immediately); + // rejects on network errors and on abort (an `AbortError`). + async notebookCollabStream( + shortId: string, + opts: { + lastEventId?: string; + signal: AbortSignal; + onEvent: (event: { id?: string; event: string; data: string }) => void; + }, + ): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/notebooks/${encodeURIComponent(shortId)}/collab/stream`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const header: Record = { Accept: "text/event-stream" }; + if (opts.lastEventId) { + header["Last-Event-ID"] = opts.lastEventId; + } + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + parameters: { header }, + overrides: { signal: opts.signal }, + }); + if (!response.body) { + throw new Error("Notebook collab stream has no response body"); + } + + // Minimal SSE parser: accumulate `id:`/`event:`/`data:` field lines + // (multiple `data:` lines join with newlines), dispatch a frame on each + // blank line, ignore `:` comment lines (the server's keepalives). A + // partial frame at EOF is discarded, per the SSE spec. + let id: string | undefined; + let eventName: string | undefined; + let dataLines: string[] = []; + const dispatch = (): void => { + if (id === undefined && eventName === undefined && dataLines.length === 0) + return; + opts.onEvent({ + id, + event: eventName ?? "message", + data: dataLines.join("\n"), + }); + id = undefined; + eventName = undefined; + dataLines = []; + }; + const handleLine = (rawLine: string): void => { + const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; + if (line === "") { + dispatch(); + return; + } + if (line.startsWith(":")) return; // comment (keepalive) + const colon = line.indexOf(":"); + const field = colon === -1 ? line : line.slice(0, colon); + let value = colon === -1 ? "" : line.slice(colon + 1); + if (value.startsWith(" ")) value = value.slice(1); + if (field === "id") id = value; + else if (field === "event") eventName = value; + else if (field === "data") dataLines.push(value); + }; + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let newline = buffer.indexOf("\n"); + while (newline !== -1) { + handleLine(buffer.slice(0, newline)); + buffer = buffer.slice(newline + 1); + newline = buffer.indexOf("\n"); + } + } + } finally { + reader.releaseLock(); + } + } + + // Broadcast the caller's caret position to the other clients on this + // notebook's collab stream. Fire-and-forget on the backend (a short-TTL + // Redis stream); presence is lossy by design — the next ping self-heals. + async notebookPublishPresence( + shortId: string, + body: { + client_id: string; + version: number; + cursor: { + head?: number; + node_index?: number; + offset?: number; + list_item_index?: number; + }; + }, + ): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/notebooks/${encodeURIComponent(shortId)}/collab/presence/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + await this.api.fetcher.fetch({ + method: "post", + url, + path: urlPath, + overrides: { + body: JSON.stringify(body), + }, + }); + } + + // 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, + }; + } + + // ------------------------------------------------------------------------- + // Notebook embeds — REST lookups for entities referenced from notebook + // component blocks (feature flags, experiments, surveys, persons, …). + // These routes aren't in the generated OpenAPI client, so we use the raw + // fetcher. The shared fetcher throws on any non-2xx + // (`Failed request: [] `), so failures simply propagate. + // Interface shapes live at the bottom of this file (NotebookEmbed*). + // ------------------------------------------------------------------------- + + /** Cloud web-app origin (same host as the API) for "open in PostHog" links. */ + get appHost(): string { + return this.api.baseUrl; + } + + /** Public accessor for the effective project (team) id, fetched once if unknown. */ + async getProjectId(): Promise { + return this.getTeamId(); + } + + private async fetchJson(urlPath: string): Promise { + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + return (await response.json()) as T; + } + + async getFeatureFlag(idOrKey: string): Promise { + const teamId = await this.getTeamId(); + // The detail endpoint only accepts numeric ids; notebook markdown often + // stores the flag KEY instead, so non-numeric lookups go through the + // list endpoint's search and match on the exact key. + if (/^\d+$/.test(idOrKey)) { + return this.fetchJson( + `/api/projects/${teamId}/feature_flags/${idOrKey}/`, + ); + } + const page = await this.fetchJson<{ + results?: NotebookEmbedFeatureFlag[]; + }>( + `/api/projects/${teamId}/feature_flags/?search=${encodeURIComponent(idOrKey)}&limit=20`, + ); + const match = page.results?.find((flag) => flag.key === idOrKey); + if (!match) { + throw new Error(`Feature flag "${idOrKey}" not found`); + } + return match; + } + + async getExperiment(id: number): Promise { + const teamId = await this.getTeamId(); + return this.fetchJson(`/api/projects/${teamId}/experiments/${id}/`); + } + + async getSurvey(id: string): Promise { + const teamId = await this.getTeamId(); + return this.fetchJson( + `/api/projects/${teamId}/surveys/${encodeURIComponent(id)}/`, + ); + } + + async getEarlyAccessFeature( + id: string, + ): Promise { + const teamId = await this.getTeamId(); + return this.fetchJson( + `/api/projects/${teamId}/early_access_feature/${encodeURIComponent(id)}/`, + ); + } + + async getCohort(id: number): Promise { + const teamId = await this.getTeamId(); + return this.fetchJson(`/api/projects/${teamId}/cohorts/${id}/`); + } + + async getPerson(opts: { + uuid?: string; + distinctId?: string; + }): Promise { + const teamId = await this.getTeamId(); + if (opts.uuid) { + return this.fetchJson( + `/api/environments/${teamId}/persons/${encodeURIComponent(opts.uuid)}/`, + ); + } + if (!opts.distinctId) { + throw new Error("Person lookup requires a uuid or distinct id"); + } + const body = await this.fetchJson<{ results?: NotebookEmbedPerson[] }>( + `/api/environments/${teamId}/persons/?distinct_id=${encodeURIComponent(opts.distinctId)}`, + ); + const person = body.results?.[0]; + if (!person) { + throw new Error("Person not found"); + } + return person; + } + + // Same endpoint the PostHog webapp uses to look up a single group + // (see upstream `frontend/src/scenes/groups/groupLogic.ts`). + async getGroup( + groupTypeIndex: number, + groupKey: string, + ): Promise { + const teamId = await this.getTeamId(); + return this.fetchJson( + `/api/environments/${teamId}/groups/find?group_type_index=${encodeURIComponent(String(groupTypeIndex))}&group_key=${encodeURIComponent(groupKey)}`, + ); + } + + async getSessionRecordingMeta( + id: string, + ): Promise { + const teamId = await this.getTeamId(); + return this.fetchJson( + `/api/environments/${teamId}/session_recordings/${encodeURIComponent(id)}/`, + ); + } + + /** + * Fetch a same-origin media path (e.g. `/uploaded_media/`) with auth + * headers attached, for notebook images that plain `` can't load. + */ + async fetchAuthenticatedMedia(path: string): Promise { + const url = new URL(`${this.api.baseUrl}${path}`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path, + }); + return await response.blob(); + } + async listSignalSourceConfigs( projectId: number, ): Promise { @@ -6149,3 +7080,178 @@ export class PostHogAPIClient { ); } } + +// ----------------------------------------------------------------------------- +// Minimal response shapes for the notebook-embed lookups above. Only the +// fields the notebook embed cards render — everything else stays `unknown`. +// ----------------------------------------------------------------------------- + +export interface NotebookEmbedFeatureFlag { + id: number; + key: string; + /** Feature flags use `name` as the human description. */ + name: string | null; + active: boolean; + filters: unknown; +} + +export interface NotebookEmbedExperiment { + id: number; + name: string; + description?: string | null; + start_date?: string | null; + end_date?: string | null; + feature_flag_key?: string | null; + /** Contains `feature_flag_variants` among other launch parameters. */ + parameters?: unknown; + metrics?: unknown[]; +} + +export interface NotebookEmbedSurvey { + id: string; + name: string; + description?: string | null; + type?: string | null; + start_date?: string | null; + end_date?: string | null; + questions?: unknown[]; +} + +export interface NotebookEmbedEarlyAccessFeature { + id: string; + name: string; + description?: string | null; + stage?: string | null; + documentation_url?: string | null; +} + +export interface NotebookEmbedCohort { + id: number; + name: string; + count?: number | null; + is_static?: boolean; +} + +export interface NotebookEmbedPerson { + id?: string | number; + name?: string | null; + distinct_ids?: string[]; + properties?: Record; + created_at?: string | null; +} + +export interface NotebookEmbedGroup { + group_type_index: number; + group_key: string; + group_properties?: Record; + created_at?: string | null; +} + +export interface NotebookEmbedRecordingMeta { + id: string; + start_time?: string | null; + end_time?: string | null; + /** Seconds. */ + recording_duration?: number | null; + person?: { + name?: string | null; + distinct_ids?: string[]; + properties?: Record; + } | null; + click_count?: number | null; + keypress_count?: number | null; + console_error_count?: number | null; +} + +// ----------------------------------------------------------------------------- +// Notebook kernel + SQL-cell response shapes (see the notebookKernel* / +// notebookSqlV2* methods above). +// ----------------------------------------------------------------------------- + +export type NotebookKernelBackend = "docker" | "modal"; + +export type NotebookKernelRunState = + | "running" + | "starting" + | "stopped" + | "timed_out" + | "discarded" + | "error"; + +export interface NotebookKernelStatus { + /** `null` means no kernel exists for this notebook yet. */ + backend: NotebookKernelBackend | null; + status: NotebookKernelRunState | null; + last_used_at?: string | null; + last_error?: string | null; + kernel_id?: string | null; + sandbox_id?: string | null; + cpu_cores?: number | null; + memory_gb?: number | null; + disk_size_gb?: number | null; + idle_timeout_seconds?: number | null; +} + +/** The kernel execution dict returned by execute and the stream's `result` frame. */ +export interface NotebookKernelExecution { + status: "ok" | "error" | "running"; + stdout?: string; + stderr?: string; + /** Mime-type map, e.g. `{"text/plain": "42", "image/png": ""}`. */ + result?: Record | null; + media?: { mime_type: string; data: string }[] | null; + execution_count?: number | null; + error_name?: string | null; + traceback?: string[]; + variables?: Record | null; + started_at?: string; + completed_at?: string; +} + +export interface NotebookKernelDataframePage { + columns: string[]; + rows: Record[]; + row_count: number; +} + +/** 200 body of hogql/execute — a 400 comes back as just `{error}`. */ +export interface NotebookHogqlExecuteResponse { + columns?: string[]; + results?: unknown[]; + types?: unknown[]; + error?: string; +} + +export type NotebookSqlV2RunResponse = + | { status: "started"; runId: string } + | { status: "disabled" }; + +export interface NotebookSqlV2ResultEnvelope { + status?: string; + columns?: string[]; + types?: [string, string][]; + row_count?: number; + has_more?: boolean; + first_page?: (string | number | null)[][]; + result_id?: string; + error?: string | null; +} + +export type NotebookSqlV2RunStatusResponse = + | { status: "running" } + | { status: "done"; result: NotebookSqlV2ResultEnvelope | null } + | { status: "failed"; error: string | null } + | { status: "disabled" }; + +export interface NotebookSqlV2Page { + columns: string[]; + types: [string, string][]; + rows: (string | number | null)[][]; + has_more: boolean; +} + +export type NotebookSqlV2PageResponse = + | { status: "ok"; page: NotebookSqlV2Page } + | { status: "stale" } + | { status: "busy" } + | { status: "disabled" }; 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..1bfd358e44 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -19,6 +19,7 @@ "dependencies": { "@agentclientprotocol/sdk": "0.22.1", "@base-ui/react": "^1.3.0", + "@codemirror/commands": "^6.8.1", "@codemirror/lang-angular": "^0.1.4", "@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-css": "^6.3.1", diff --git a/packages/ui/src/features/notebooks/NotebookView.tsx b/packages/ui/src/features/notebooks/NotebookView.tsx new file mode 100644 index 0000000000..c8e625700d --- /dev/null +++ b/packages/ui/src/features/notebooks/NotebookView.tsx @@ -0,0 +1,382 @@ +import { + ArrowLeft, + Cpu, + Notebook as NotebookIcon, +} from "@phosphor-icons/react"; +import type { PostHogAPIClient } from "@posthog/api-client/posthog-client"; +import { + buildMarkdownNotebookContent, + 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, + useOptionalAuthenticatedClient, +} 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 { KernelPanel } from "./cells/KernelPanel"; +import { NotebookCellContextProvider } from "./cells/NotebookCellContext"; +import { + convertNotebookContentToMarkdown, + type NotebookContentForMarkdownConversion, +} from "./markdown-notebook/legacyConversion"; +import { MarkdownNotebook } from "./markdown-notebook/MarkdownNotebook"; +import type { + MarkdownNotebookCaretPosition, + RemoteNotebookCaret, +} from "./markdown-notebook/remoteCarets"; +import { NotebookInlineAIController } from "./notebookInlineAI"; +import { getNotebooksAppRegistry } from "./notebookRegistry"; +import { NotebookStreamController } from "./notebookStream"; +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 ; + } + + return ; +} + +// Legacy (TipTap rich-text) notebooks are converted to markdown in place the +// first time they're opened here: convert the content JSON client-side, PATCH +// the notebook with the markdown envelope, then refetch so NotebookView +// remounts the editor on the fresh (now-markdown) notebook. +function LegacyNotebookConverter({ notebook }: { notebook: NotebookRecord }) { + const client = useOptionalAuthenticatedClient(); + const queryClient = useQueryClient(); + const [conversionError, setConversionError] = useState(null); + + // The conversion must fire exactly once per mount, even across StrictMode's + // dev double-effect and auth-state re-renders (the ref survives both). + const conversionStartedRef = useRef(false); + useEffect(() => { + if (!client || conversionStartedRef.current) { + return; + } + conversionStartedRef.current = true; + void (async () => { + try { + const markdown = convertNotebookContentToMarkdown( + notebook.content as NotebookContentForMarkdownConversion, + ); + await client.patchNotebook(notebook.short_id, { + content: buildMarkdownNotebookContent(markdown), + text_content: markdown, + version: notebook.version ?? 0, + }); + // Refetch so NotebookView re-renders with the markdown notebook (and + // its server-bumped version) and mounts the editor. + await queryClient.invalidateQueries({ + queryKey: ["notebook", notebook.short_id], + }); + } catch (error) { + setConversionError( + error instanceof Error ? error.message : String(error), + ); + } + })(); + }, [client, notebook, queryClient]); + + if (conversionError) { + return ( + + + + + + + Legacy notebook format + + This notebook uses the older rich-text format and couldn't be + converted to Markdown automatically: {conversionError} + + + + + ); + } + + return ( + + + + Converting notebook to Markdown… + + + ); +} + +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 [remoteCarets, setRemoteCarets] = useState([]); + const [showKernelPanel, setShowKernelPanel] = useState(false); + const [aiWritingNodeIndexes, setAiWritingNodeIndexes] = useState( + [], + ); + const [focusAIPromptRequest, setFocusAIPromptRequest] = useState(0); + 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; + // Synchronous mirrors of the latest editor value/title, so the AI runner + // reads the current document even between React commits. + const valueRef = useRef(initialMarkdown); + const titleRef = useRef(notebook.title ?? ""); + titleRef.current = title; + + // The engine is created (and disposed) inside an effect rather than a + // useMemo: StrictMode's dev double-mount runs the cleanup once against the + // first mount, and a memoized engine would stay permanently disposed while + // the surviving render kept handing out its dead callbacks. + const [engine, setEngine] = useState(null); + const streamRef = useRef(null); + const aiRef = useRef(null); + useEffect(() => { + const nextEngine = 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, + }, + ); + // Realtime: SSE update/presence stream feeding the engine and the caret + // overlay, plus the inline-AI runner that weaves Max AI responses into + // the document (its writes flow through setMarkdown → the same autosave + // path as user edits). + const stream = new NotebookStreamController({ + shortId: notebook.short_id, + clientId: nextEngine.clientId, + engine: nextEngine, + getClient: () => clientRef.current, + onPresence: setRemoteCarets, + }); + const ai = new NotebookInlineAIController({ + getClient: () => clientRef.current, + getNotebookMeta: () => ({ + shortId: notebook.short_id, + title: titleRef.current, + }), + getMarkdown: () => valueRef.current, + setMarkdown: (markdown) => { + valueRef.current = markdown; + setValue(markdown); + nextEngine.handleChange(markdown); + }, + setWritingNodeIndexes: setAiWritingNodeIndexes, + requestPromptFocus: () => setFocusAIPromptRequest((n) => n + 1), + }); + streamRef.current = stream; + aiRef.current = ai; + setEngine(nextEngine); + return () => { + streamRef.current = null; + aiRef.current = null; + stream.dispose(); + ai.dispose(); + nextEngine.dispose({ flush: true }); + }; + // The route remounts this component per notebook (key=shortId) and the + // query data is stable while mounted, so this runs once per open document. + }, [ + initialMarkdown, + initialNodeId, + notebook.short_id, + notebook.version, + service, + ]); + + 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]} + + setShowKernelPanel((open) => !open)} + aria-label="Kernel info" + > + + + + + +
+ {showKernelPanel ? ( +
+ +
+ ) : null} + {engine ? ( + + { + const previous = valueRef.current; + valueRef.current = markdown; + setValue(markdown); + engine.handleChange(markdown); + // Remap active AI response ranges through the edit (no-ops + // for the AI runner's own writes echoed back by the editor). + aiRef.current?.handleDocumentChange(previous, markdown); + }} + mode="edit" + registry={registry} + clientId={engine.clientId} + remoteValue={remote?.markdown} + remoteVersion={remote?.version} + remoteCarets={remoteCarets} + onCaretChange={( + position: MarkdownNotebookCaretPosition | null, + ) => streamRef.current?.publishCaret(position)} + onAskAI={(request) => aiRef.current?.handleAskAI(request)} + aiWritingNodeIndexes={aiWritingNodeIndexes} + focusAIPromptRequest={focusAIPromptRequest} + placeholder="Start writing, or press / to insert…" + autoFocus + /> + + ) : null} +
+
+
+ ); +} 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/cells/CellCodeEditor.tsx b/packages/ui/src/features/notebooks/cells/CellCodeEditor.tsx new file mode 100644 index 0000000000..c0aee7ec6f --- /dev/null +++ b/packages/ui/src/features/notebooks/cells/CellCodeEditor.tsx @@ -0,0 +1,128 @@ +import { + defaultKeymap, + history, + historyKeymap, + indentWithTab, +} from "@codemirror/commands"; +import { python } from "@codemirror/lang-python"; +import { sql } from "@codemirror/lang-sql"; +import { EditorState, Prec } from "@codemirror/state"; +import { + EditorView, + keymap, + placeholder as placeholderExtension, +} from "@codemirror/view"; +import { + oneDark, + oneLight, +} from "@posthog/ui/features/code-editor/theme/editorTheme"; +import { useThemeStore } from "@posthog/ui/shell/themeStore"; +import { useEffect, useMemo, useRef } from "react"; + +export interface CellCodeEditorProps { + value: string; + onChange: (value: string) => void; + language: "sql" | "python"; + /** Wired to Mod-Enter inside the editor. */ + onRun?: () => void; + placeholder?: string; +} + +const cellTheme = EditorView.theme({ + "&": { minHeight: "120px" }, + ".cm-content": { minHeight: "120px", padding: "8px 0" }, + ".cm-gutters": { display: "none" }, +}); + +/** + * Thin editable CodeMirror wrapper for runnable notebook cells, reusing the + * repo's code-editor theme. The editor instance is created once per language/ + * theme; `value` and the callbacks sync through refs so parent re-renders + * (e.g. our own onChange persisting props) never tear the editor down. + */ +export function CellCodeEditor({ + value, + onChange, + language, + onRun, + placeholder, +}: CellCodeEditorProps) { + const containerRef = useRef(null); + const viewRef = useRef(null); + const isDarkMode = useThemeStore((state) => state.isDarkMode); + + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + const onRunRef = useRef(onRun); + onRunRef.current = onRun; + const valueRef = useRef(value); + valueRef.current = value; + + const extensions = useMemo(() => { + return [ + // Highest precedence so Mod-Enter beats the default newline binding. + Prec.highest( + keymap.of([ + { + key: "Mod-Enter", + run: () => { + onRunRef.current?.(); + return true; + }, + }, + ]), + ), + history(), + keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]), + language === "python" ? python() : sql(), + EditorView.lineWrapping, + isDarkMode ? oneDark : oneLight, + cellTheme, + ...(placeholder ? [placeholderExtension(placeholder)] : []), + EditorView.updateListener.of((update) => { + if (!update.docChanged) return; + const next = update.state.doc.toString(); + valueRef.current = next; + onChangeRef.current(next); + }), + ]; + }, [language, isDarkMode, placeholder]); + + useEffect(() => { + if (!containerRef.current) return; + viewRef.current?.destroy(); + viewRef.current = new EditorView({ + state: EditorState.create({ doc: valueRef.current, extensions }), + parent: containerRef.current, + }); + return () => { + viewRef.current?.destroy(); + viewRef.current = null; + }; + }, [extensions]); + + // Adopt external value changes (remote collab edits) without recreating. + useEffect(() => { + const view = viewRef.current; + if (!view) return; + const current = view.state.doc.toString(); + if (current !== value) { + view.dispatch({ + changes: { from: 0, to: current.length, insert: value }, + }); + } + }, [value]); + + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: event fencing only — CodeMirror inside provides the interactive surface +
event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + /> + ); +} diff --git a/packages/ui/src/features/notebooks/cells/KernelPanel.tsx b/packages/ui/src/features/notebooks/cells/KernelPanel.tsx new file mode 100644 index 0000000000..0d8ea24b0c --- /dev/null +++ b/packages/ui/src/features/notebooks/cells/KernelPanel.tsx @@ -0,0 +1,294 @@ +import type { NotebookKernelStatus } from "@posthog/api-client/posthog-client"; +import { Badge, Button } from "@posthog/quill"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { Loader2, RefreshCw } from "lucide-react"; +import type { JSX } from "react"; +import { useState } from "react"; +import { useKernelStatus } from "./useKernelStatus"; + +// Allowed compute-profile values (validated server-side on kernel/config). +const CPU_CORE_OPTIONS = [0.125, 0.25, 0.5, 1, 2, 4, 6, 8, 16, 32, 64]; +const MEMORY_GB_OPTIONS = [0.25, 0.5, 1, 2, 4, 8, 16, 32, 64, 128, 256]; +const IDLE_TIMEOUT_OPTIONS: { value: number; label: string }[] = [ + { value: 600, label: "10 minutes" }, + { value: 1800, label: "30 minutes" }, + { value: 3600, label: "1 hour" }, + { value: 10800, label: "3 hours" }, + { value: 21600, label: "6 hours" }, + { value: 43200, label: "12 hours" }, +]; + +// Modal pricing (same figures the PostHog webapp shows). +const CPU_PRICE_PER_CORE_HOUR = 0.1419; +const MEMORY_PRICE_PER_GIB_HOUR = 0.0242; + +type BadgeVariant = "default" | "success" | "info" | "warning" | "destructive"; + +const STATUS_BADGES: Record = + { + running: { label: "Running", variant: "success" }, + starting: { label: "Starting", variant: "info" }, + stopped: { label: "Stopped", variant: "default" }, + timed_out: { label: "Timed out", variant: "warning" }, + discarded: { label: "Discarded", variant: "warning" }, + error: { label: "Error", variant: "destructive" }, + }; + +function formatCores(value: number): string { + const formatted = + value % 1 === 0 + ? value.toString() + : value.toFixed(3).replace(/0+$/, "").replace(/\.$/, ""); + return `${formatted}x`; +} + +function formatMemory(value: number): string { + return value < 1 ? `${Math.round(value * 1024)} MB` : `${value} GB`; +} + +export interface KernelPanelProps { + /** The notebook's short id. */ + shortId: string; +} + +/** + * Kernel panel for a notebook (the webapp's NotebookKernelInfo equivalent): + * status + backend badges, CPU/RAM/idle-timeout controls (modal backend + * only), Start/Restart, Stop and Refresh. Polls the status while mounted — + * every 2s while starting, every 10s otherwise. + */ +export function KernelPanel({ shortId }: KernelPanelProps): JSX.Element { + const client = useOptionalAuthenticatedClient(); + const { status, loading, error, refresh, applyStatus, hasClient } = + useKernelStatus(shortId); + const [action, setAction] = useState< + "start" | "restart" | "stop" | "refresh" | null + >(null); + const [actionError, setActionError] = useState(null); + + // Compute-profile drafts; null = follow the kernel's reported value. + const [cpuDraft, setCpuDraft] = useState(null); + const [memoryDraft, setMemoryDraft] = useState(null); + const [idleDraft, setIdleDraft] = useState(null); + + const isModal = status?.backend === "modal"; + const isRunning = status?.status === "running"; + const isStarting = status?.status === "starting"; + const selectedCpu = cpuDraft ?? status?.cpu_cores ?? 1; + const selectedMemory = memoryDraft ?? status?.memory_gb ?? 2; + const selectedIdle = idleDraft ?? status?.idle_timeout_seconds ?? 1800; + const hasConfigChanges = + (cpuDraft !== null && cpuDraft !== status?.cpu_cores) || + (memoryDraft !== null && memoryDraft !== status?.memory_gb) || + (idleDraft !== null && idleDraft !== status?.idle_timeout_seconds); + + const runAction = async ( + kind: "start" | "restart" | "stop" | "refresh", + ): Promise => { + if (!client || action) return; + setAction(kind); + setActionError(null); + try { + if (kind === "refresh") { + await refresh(); + return; + } + if (kind === "stop") { + await client.notebookKernelStop(shortId); + } else { + // Persist a changed compute profile first — it applies on start/restart. + if (isModal && hasConfigChanges) { + await client.notebookKernelConfig(shortId, { + cpu_cores: selectedCpu, + memory_gb: selectedMemory, + idle_timeout_seconds: selectedIdle, + }); + setCpuDraft(null); + setMemoryDraft(null); + setIdleDraft(null); + } + const result = + kind === "restart" + ? await client.notebookKernelRestart(shortId) + : await client.notebookKernelStart(shortId); + applyStatus({ + ...(status ?? { backend: null, status: null }), + status: result.status, + } as NotebookKernelStatus); + } + await refresh(); + } catch (runError) { + setActionError( + runError instanceof Error ? runError.message : "Kernel action failed", + ); + } finally { + setAction(null); + } + }; + + const badge = status?.status ? STATUS_BADGES[status.status] : null; + const hourlyPrice = + selectedCpu * CPU_PRICE_PER_CORE_HOUR + + selectedMemory * MEMORY_PRICE_PER_GIB_HOUR; + + return ( +
+
+ + Kernel + + +
+ + {!hasClient ? ( +
+ Sign in to PostHog to manage the kernel. +
+ ) : loading ? ( +
+ + Loading kernel status… +
+ ) : ( + <> +
+ {badge ? ( + {badge.label} + ) : ( + No kernel + )} + {status?.backend ? ( + + {status.backend === "modal" ? "Modal" : "Local – Docker"} + + ) : null} + {isModal ? ( + + ${hourlyPrice.toFixed(2)} / h + + ) : null} +
+ + {error ? ( +
{error}
+ ) : null} + {status?.last_error ? ( +
+ Error: {status.last_error} +
+ ) : null} + + {isModal ? ( +
+ ({ + value, + label: formatCores(value), + }))} + onChange={setCpuDraft} + /> + ({ + value, + label: formatMemory(value), + }))} + onChange={setMemoryDraft} + /> + + {hasConfigChanges ? ( +
+ Changes apply on the next start or restart. +
+ ) : null} +
+ ) : status?.backend === "docker" ? ( +
+ Using the docker-based local kernel. Can't update the compute + profile. +
+ ) : null} + +
+ + +
+ {actionError ? ( +
+ {actionError} +
+ ) : null} + + )} +
+ ); +} + +function ConfigSelect({ + label, + value, + options, + onChange, +}: { + label: string; + value: number; + options: { value: number; label: string }[]; + onChange: (value: number) => void; +}): JSX.Element { + return ( + + ); +} diff --git a/packages/ui/src/features/notebooks/cells/NotebookCellContext.tsx b/packages/ui/src/features/notebooks/cells/NotebookCellContext.tsx new file mode 100644 index 0000000000..bdd0e32924 --- /dev/null +++ b/packages/ui/src/features/notebooks/cells/NotebookCellContext.tsx @@ -0,0 +1,33 @@ +import { createContext, type ReactNode, useContext } from "react"; + +export interface NotebookCellContextValue { + /** The notebook's short id — the key for all kernel/SQL cell API calls. */ + shortId: string; +} + +const NotebookCellContext = createContext( + null, +); + +/** + * Provides the notebook identity to runnable cells (Python/SQL). The notebook + * view mounts this around the markdown editor; cells render a hint when it is + * absent (e.g. a registry preview outside a notebook). + */ +export function NotebookCellContextProvider({ + shortId, + children, +}: { + shortId: string; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useNotebookCellContext(): NotebookCellContextValue | null { + return useContext(NotebookCellContext); +} diff --git a/packages/ui/src/features/notebooks/cells/PythonCellEmbed.tsx b/packages/ui/src/features/notebooks/cells/PythonCellEmbed.tsx new file mode 100644 index 0000000000..8ee0e004fc --- /dev/null +++ b/packages/ui/src/features/notebooks/cells/PythonCellEmbed.tsx @@ -0,0 +1,251 @@ +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { Code } from "lucide-react"; +import type { JSX } from "react"; +import { useEffect, useRef, useState } from "react"; +import type { + NotebookComponentRenderProps, + NotebookPropValue, +} from "../markdown-notebook/types"; +import { CellCodeEditor } from "./CellCodeEditor"; +import { + CellError, + CellFrame, + CellHint, + CellOutputBlock, + RunCellButton, +} from "./cellBlocks"; +import { + buildErrorExecution, + buildMediaSource, + type CellExecution, + formatTraceback, + isSessionOnlyEndpointError, + KERNEL_SESSION_ONLY_MESSAGE, + normalizeKernelExecution, + stripAnsi, +} from "./cellExecution"; +import { useNotebookCellContext } from "./NotebookCellContext"; + +interface LiveRun { + stdout: string; + stderr: string; +} + +/** + * Runnable `` cell: CodeMirror python editor, streamed + * stdout/stderr while the kernel executes, and the final execution dict + * persisted into `pythonExecution` so results survive reload and sync to + * other clients (matching the PostHog webapp's Python node). + */ +export function PythonCellEmbed({ + node, + updateProps, +}: NotebookComponentRenderProps): JSX.Element { + const cellContext = useNotebookCellContext(); + const client = useOptionalAuthenticatedClient(); + const code = typeof node.props.code === "string" ? node.props.code : ""; + const title = + typeof node.props.title === "string" && node.props.title.trim() + ? node.props.title + : "Python"; + + const [live, setLive] = useState(null); + const [runError, setRunError] = useState(null); + const running = live !== null; + const abortRef = useRef(null); + useEffect(() => { + return () => abortRef.current?.abort(); + }, []); + + // Refs so the Mod-Enter handler and stream callbacks see current values. + const codeRef = useRef(code); + codeRef.current = code; + + const persistExecution = (execution: unknown): void => { + updateProps({ + pythonExecution: execution as NotebookPropValue, + }); + }; + + const run = async (): Promise => { + if (!client || !cellContext || abortRef.current?.signal.aborted === false) { + return; + } + const controller = new AbortController(); + abortRef.current = controller; + setRunError(null); + setLive({ stdout: "", stderr: "" }); + let sawTerminalFrame = false; + try { + await client.notebookKernelExecuteStream( + cellContext.shortId, + { code: codeRef.current }, + { + signal: controller.signal, + onEvent: (event) => { + let payload: Record | null = null; + try { + const parsed: unknown = JSON.parse(event.data); + payload = + parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + payload = null; + } + if (event.event === "stdout" || event.event === "stderr") { + const stream = event.event as "stdout" | "stderr"; + const text = + typeof payload?.text === "string" ? payload.text : ""; + if (!text) return; + setLive((current) => + current + ? { + ...current, + [stream]: current[stream] + text, + } + : current, + ); + } else if (event.event === "result") { + sawTerminalFrame = true; + persistExecution(payload); + } else if (event.event === "error") { + sawTerminalFrame = true; + const message = + typeof payload?.error === "string" + ? payload.error + : "Execution failed"; + persistExecution(buildErrorExecution(message)); + } + }, + }, + ); + if (!sawTerminalFrame) { + setRunError("The kernel stream ended without a result."); + } + } catch (error) { + if (!controller.signal.aborted) { + setRunError( + isSessionOnlyEndpointError(error) + ? KERNEL_SESSION_ONLY_MESSAGE + : error instanceof Error + ? error.message + : "Execution failed", + ); + } + } finally { + if (abortRef.current === controller) { + abortRef.current = null; + setLive(null); + } + } + }; + + const execution = normalizeKernelExecution(node.props.pythonExecution); + const disabledReason = !cellContext + ? "This cell can only run inside a notebook." + : !client + ? "Sign in to PostHog to run this cell." + : null; + + return ( + } + title={title} + actions={ + void run()} + /> + } + > + updateProps({ code: value })} + onRun={() => void run()} + /> + {disabledReason ? {disabledReason} : null} + {running ? ( +
+ {!live.stdout && !live.stderr ? ( + + Running… the kernel boots on first run, which can take up to a + minute. + + ) : null} + {live.stdout ? ( + + ) : null} + {live.stderr ? ( + + ) : null} +
+ ) : ( + + )} + {runError && !running ? {runError} : null} +
+ ); +} + +function PythonExecutionOutput({ + execution, +}: { + execution: CellExecution | null; +}): JSX.Element | null { + if (!execution) return null; + const hasAnyOutput = + execution.stdout || + execution.stderr || + execution.resultText || + execution.media.length > 0 || + execution.traceback.length > 0; + if (!hasAnyOutput) { + return Ran without output.; + } + return ( +
+ {execution.stdout ? ( + + ) : null} + {execution.stderr ? ( + + ) : null} + {execution.resultText ? ( + + ) : null} + {execution.media.map((media, index) => { + const source = buildMediaSource(media); + if (!source) return null; + return ( + Python output + ); + })} + {execution.status === "error" && execution.traceback.length > 0 ? ( + + ) : null} +
+ ); +} diff --git a/packages/ui/src/features/notebooks/cells/ResultsTable.tsx b/packages/ui/src/features/notebooks/cells/ResultsTable.tsx new file mode 100644 index 0000000000..fae53ef2fb --- /dev/null +++ b/packages/ui/src/features/notebooks/cells/ResultsTable.tsx @@ -0,0 +1,194 @@ +// biome-ignore-all lint/suspicious/noArrayIndexKey: result rows and cells are purely positional +import { Button } from "@posthog/quill"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +const PAGE_SIZES = [10, 50, 100]; + +export interface ResultsTableProps { + columns: string[]; + /** First page of rows (offset 0), positional per `columns`. */ + rows: unknown[][]; + /** Total row count when known; null renders "n+ rows" when hasMore. */ + rowCount: number | null; + hasMore?: boolean; + /** + * Server-side pager for rows beyond the initial page. Without it the table + * paginates `rows` client-side. + */ + fetchPage?: (offset: number, limit: number) => Promise<{ rows: unknown[][] }>; +} + +/** + * Columns+rows table with pagination for SQL/dataframe cell results, styled + * to match the notebook Query embed's table. + */ +export function ResultsTable({ + columns, + rows, + rowCount, + hasMore = false, + fetchPage, +}: ResultsTableProps) { + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(PAGE_SIZES[0]); + const [remoteRows, setRemoteRows] = useState(null); + const [loading, setLoading] = useState(false); + const [pageError, setPageError] = useState(null); + const fetchIdRef = useRef(0); + + // A new result resets paging (rows identity changes per run). + // biome-ignore lint/correctness/useExhaustiveDependencies: rows identity is the reset signal + useEffect(() => { + setPage(0); + setRemoteRows(null); + setPageError(null); + }, [rows]); + + const offset = page * pageSize; + const initialCoversPage = + !fetchPage || (offset + pageSize <= rows.length && page === 0) || !hasMore; + + useEffect(() => { + if (initialCoversPage || !fetchPage) { + setRemoteRows(null); + return; + } + const fetchId = ++fetchIdRef.current; + setLoading(true); + setPageError(null); + fetchPage(offset, pageSize) + .then((result) => { + if (fetchIdRef.current !== fetchId) return; + setRemoteRows(result.rows); + }) + .catch((error: unknown) => { + if (fetchIdRef.current !== fetchId) return; + setPageError( + error instanceof Error ? error.message : "Failed to fetch page", + ); + setRemoteRows(null); + }) + .finally(() => { + if (fetchIdRef.current === fetchId) setLoading(false); + }); + }, [initialCoversPage, fetchPage, offset, pageSize]); + + const visibleRows = remoteRows ?? rows.slice(offset, offset + pageSize); + const knownTotal = rowCount ?? (hasMore ? null : rows.length); + const lastVisible = offset + visibleRows.length; + // Without a server pager only the supplied rows are reachable, even when + // the total row count is larger (persisted previews are capped). + const reachableTotal = fetchPage ? knownTotal : rows.length; + const canGoNext = + reachableTotal !== null + ? lastVisible < reachableTotal + : visibleRows.length >= pageSize; + + return ( +
+
+ + + + {columns.map((column) => ( + + ))} + + + + {visibleRows.map((row, rowIndex) => ( + + {row.map((cell, cellIndex) => ( + + ))} + + ))} + {visibleRows.length === 0 && !loading ? ( + + + + ) : null} + +
+ {column} +
+ {formatResultCell(cell)} +
+ No rows. +
+
+ {pageError ? ( +
{pageError}
+ ) : null} +
+ + {visibleRows.length > 0 + ? `Rows ${offset + 1}–${lastVisible}` + : "No rows"} + {knownTotal !== null + ? ` of ${knownTotal.toLocaleString()}` + : hasMore + ? " (more available)" + : ""} + +
+ + + +
+
+
+ ); +} + +export function formatResultCell(cell: unknown): string { + if (cell == null) return ""; + if (typeof cell === "object") { + try { + return JSON.stringify(cell); + } catch { + return String(cell); + } + } + return String(cell); +} diff --git a/packages/ui/src/features/notebooks/cells/SqlCellEmbed.tsx b/packages/ui/src/features/notebooks/cells/SqlCellEmbed.tsx new file mode 100644 index 0000000000..bd660cf8a9 --- /dev/null +++ b/packages/ui/src/features/notebooks/cells/SqlCellEmbed.tsx @@ -0,0 +1,341 @@ +import type { PostHogAPIClient } from "@posthog/api-client/posthog-client"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { Database } from "lucide-react"; +import type { JSX } from "react"; +import { useEffect, useRef, useState } from "react"; +import type { + NotebookComponentRenderProps, + NotebookPropValue, +} from "../markdown-notebook/types"; +import { CellCodeEditor } from "./CellCodeEditor"; +import { + CellError, + CellFrame, + CellHint, + CellOutputBlock, + ReturnVariableInput, + RunCellButton, +} from "./cellBlocks"; +import { + buildDuckSqlCode, + buildSqlResultSummary, + formatTraceback, + hasHogqlPlaceholders, + isSessionOnlyEndpointError, + KERNEL_SESSION_ONLY_MESSAGE, + normalizeKernelExecution, + normalizeSqlResultSummary, + resolveReturnVariable, + type SqlCellResultSummary, + toPositionalRows, +} from "./cellExecution"; +import { useNotebookCellContext } from "./NotebookCellContext"; +import { ResultsTable } from "./ResultsTable"; + +type SqlDialect = "hogql" | "duck"; + +const DIALECT_CONFIG: Record< + SqlDialect, + { + defaultTitle: string; + executionProp: "hogqlExecution" | "duckExecution"; + returnVariableFallback: string; + } +> = { + hogql: { + defaultTitle: "SQL (HogQL)", + executionProp: "hogqlExecution", + returnVariableFallback: "hogql_df", + }, + duck: { + defaultTitle: "SQL (DuckDB)", + executionProp: "duckExecution", + returnVariableFallback: "duck_df", + }, +}; + +const LIVE_PAGE_LIMIT = 500; + +interface LiveTable { + columns: string[]; + rows: unknown[][]; + rowCount: number; + /** Duck results can page against the live kernel dataframe. */ + dataframeVariable: string | null; +} + +export function HogqlSqlCellEmbed( + props: NotebookComponentRenderProps, +): JSX.Element { + return ; +} + +export function DuckSqlCellEmbed( + props: NotebookComponentRenderProps, +): JSX.Element { + return ; +} + +/** + * Runnable SQL cell shared by `` and ``. HogQL runs + * through the notebook's plain `hogql/execute` endpoint (no kernel); DuckDB + * runs through the Python kernel with the webapp's `duck_execute` wrapper + * code, then pages the resulting dataframe. A result summary persists into + * `hogqlExecution`/`duckExecution` so tables survive reload. + */ +function SqlCellEmbed({ + node, + updateProps, + dialect, +}: NotebookComponentRenderProps & { dialect: SqlDialect }): JSX.Element { + const config = DIALECT_CONFIG[dialect]; + const cellContext = useNotebookCellContext(); + const client = useOptionalAuthenticatedClient(); + const code = typeof node.props.code === "string" ? node.props.code : ""; + const title = + typeof node.props.title === "string" && node.props.title.trim() + ? node.props.title + : config.defaultTitle; + const returnVariable = resolveReturnVariable( + node.props.returnVariable, + config.returnVariableFallback, + ); + + const [running, setRunning] = useState(false); + const [runError, setRunError] = useState(null); + const [errorDetail, setErrorDetail] = useState(null); + const [liveTable, setLiveTable] = useState(null); + const mountedRef = useRef(true); + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const codeRef = useRef(code); + codeRef.current = code; + const returnVariableRef = useRef(returnVariable); + returnVariableRef.current = returnVariable; + + const persistSummary = (summary: SqlCellResultSummary): void => { + updateProps({ + [config.executionProp]: summary as unknown as NotebookPropValue, + }); + }; + + const runHogql = async ( + activeClient: PostHogAPIClient, + _shortId: string, + ): Promise => { + // Deliberately the generic /query/ endpoint, not the notebook-scoped + // hogql/execute: the latter declares no API scopes upstream, so PostHog + // rejects all token auth ("does not support personal API key access") — + // /query/ accepts the desktop token and returns the same columns/rows. + const response = (await activeClient.runQueryNode({ + kind: "HogQLQuery", + query: codeRef.current, + })) as { columns?: unknown[]; results?: unknown[]; error?: string }; + if (!mountedRef.current) return; + if (response.error) { + setRunError(response.error); + persistSummary( + buildSqlResultSummary({ + columns: [], + rows: [], + rowCount: 0, + error: response.error, + }), + ); + return; + } + const columns = (response.columns ?? []).map(String); + const results = Array.isArray(response.results) ? response.results : []; + setLiveTable({ + columns, + rows: toPositionalRows(results, columns), + rowCount: results.length, + dataframeVariable: null, + }); + persistSummary( + buildSqlResultSummary({ + columns, + rows: results, + rowCount: results.length, + }), + ); + }; + + const runDuck = async ( + activeClient: PostHogAPIClient, + shortId: string, + ): Promise => { + const variable = resolveReturnVariable( + returnVariableRef.current, + "duck_df", + ); + const execution = await activeClient.notebookKernelExecute(shortId, { + code: buildDuckSqlCode(codeRef.current, variable), + }); + if (!mountedRef.current) return; + const normalized = normalizeKernelExecution(execution); + if (!normalized || normalized.status === "error") { + const message = normalized?.errorName ?? "Execution failed"; + setRunError(message); + setErrorDetail( + normalized && normalized.traceback.length > 0 + ? formatTraceback(normalized.traceback) + : normalized?.stderr || null, + ); + persistSummary( + buildSqlResultSummary({ + columns: [], + rows: [], + rowCount: 0, + error: message, + }), + ); + return; + } + const dataframe = await activeClient.notebookKernelDataframe(shortId, { + variableName: variable, + offset: 0, + limit: LIVE_PAGE_LIMIT, + }); + if (!mountedRef.current) return; + setLiveTable({ + columns: dataframe.columns, + rows: toPositionalRows(dataframe.rows, dataframe.columns), + rowCount: dataframe.row_count, + dataframeVariable: variable, + }); + persistSummary( + buildSqlResultSummary({ + columns: dataframe.columns, + rows: dataframe.rows, + rowCount: dataframe.row_count, + }), + ); + }; + + const run = async (): Promise => { + if (!client || !cellContext || running) return; + setRunning(true); + setRunError(null); + setErrorDetail(null); + setLiveTable(null); + try { + if (dialect === "hogql") { + await runHogql(client, cellContext.shortId); + } else { + await runDuck(client, cellContext.shortId); + } + } catch (error) { + if (mountedRef.current) { + setRunError( + isSessionOnlyEndpointError(error) + ? KERNEL_SESSION_ONLY_MESSAGE + : error instanceof Error + ? error.message + : "Query failed", + ); + } + } finally { + if (mountedRef.current) setRunning(false); + } + }; + + const persisted = normalizeSqlResultSummary(node.props[config.executionProp]); + const disabledReason = !cellContext + ? "This cell can only run inside a notebook." + : !client + ? "Sign in to PostHog to run this cell." + : null; + const showPlaceholderHint = dialect === "hogql" && hasHogqlPlaceholders(code); + + // Live duck results page against the kernel's dataframe variable. + const fetchDataframePage = + liveTable?.dataframeVariable && client && cellContext + ? async (offset: number, limit: number) => { + const page = await client.notebookKernelDataframe( + cellContext.shortId, + { + variableName: liveTable.dataframeVariable as string, + offset, + limit, + }, + ); + return { rows: toPositionalRows(page.rows, page.columns) }; + } + : undefined; + + return ( + } + title={title} + actions={ + void run()} + /> + } + > + updateProps({ code: value })} + onRun={() => void run()} + /> + {disabledReason ? {disabledReason} : null} + {showPlaceholderHint ? ( + + This query contains {"{placeholders}"}; they resolve from Python + variables in the webapp and may fail here. + + ) : null} + {running ? ( + + {dialect === "duck" + ? "Running… the kernel boots on first run, which can take up to a minute." + : "Running query…"} + + ) : null} + {runError && !running ? {runError} : null} + {errorDetail && !running ? ( + + ) : null} + {!running && !runError ? ( + liveTable ? ( + + ) : persisted && persisted.status === "ok" ? ( + + ) : persisted?.error ? ( + {persisted.error} + ) : null + ) : null} + + updateProps({ + returnVariable: value || config.returnVariableFallback, + }) + } + /> + + ); +} diff --git a/packages/ui/src/features/notebooks/cells/SqlV2CellEmbed.tsx b/packages/ui/src/features/notebooks/cells/SqlV2CellEmbed.tsx new file mode 100644 index 0000000000..12695fc63a --- /dev/null +++ b/packages/ui/src/features/notebooks/cells/SqlV2CellEmbed.tsx @@ -0,0 +1,220 @@ +import type { + NotebookSqlV2ResultEnvelope, + PostHogAPIClient, +} from "@posthog/api-client/posthog-client"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { Database } from "lucide-react"; +import type { JSX } from "react"; +import { useEffect, useRef, useState } from "react"; +import type { + NotebookComponentRenderProps, + NotebookPropValue, +} from "../markdown-notebook/types"; +import { CellCodeEditor } from "./CellCodeEditor"; +import { + CellError, + CellFrame, + CellHint, + ReturnVariableInput, + RunCellButton, +} from "./cellBlocks"; +import { resolveReturnVariable } from "./cellExecution"; +import { useNotebookCellContext } from "./NotebookCellContext"; +import { ResultsTable } from "./ResultsTable"; +import { SqlV2RunTracker } from "./sqlV2RunTracker"; + +const SQL_V2_PAGE_LIMIT = 500; + +/** + * Runnable `` cell: submits the query as an async run + * (`sql_v2/run`), persists the `runId`, polls until the run terminates, then + * persists the result envelope and renders its first page (further pages come + * from `sql_v2/runs/{id}/page`). A mount with a persisted `runId` and no + * `result` resumes polling. When the server 404s the feature, the cell shows + * a "not enabled" hint. + */ +export function SqlV2CellEmbed({ + node, + updateProps, +}: NotebookComponentRenderProps): JSX.Element { + const cellContext = useNotebookCellContext(); + const client = useOptionalAuthenticatedClient(); + const code = typeof node.props.code === "string" ? node.props.code : ""; + const title = + typeof node.props.title === "string" && node.props.title.trim() + ? node.props.title + : "SQL (v2)"; + const returnVariable = resolveReturnVariable( + node.props.returnVariable, + "sql_df", + ); + const persistedRunId = + typeof node.props.runId === "string" && node.props.runId + ? node.props.runId + : null; + const envelope = coerceEnvelope(node.props.result); + + const [running, setRunning] = useState(false); + const [runError, setRunError] = useState(null); + const [disabled, setDisabled] = useState(false); + + // Latest client/updateProps for the tracker callbacks (created once). + const clientRef = useRef(client); + clientRef.current = client; + const shortIdRef = useRef(cellContext?.shortId ?? null); + shortIdRef.current = cellContext?.shortId ?? null; + const updatePropsRef = useRef(updateProps); + updatePropsRef.current = updateProps; + const codeRef = useRef(code); + codeRef.current = code; + + const trackerRef = useRef(null); + if (trackerRef.current === null) { + trackerRef.current = new SqlV2RunTracker({ + poll: (runId) => { + const activeClient = clientRef.current; + const shortId = shortIdRef.current; + if (!activeClient || !shortId) { + return Promise.reject(new Error("Not connected to PostHog")); + } + return activeClient.notebookSqlV2RunStatus(shortId, runId); + }, + onDone: (_runId, result) => { + updatePropsRef.current({ + result: (result ?? null) as NotebookPropValue, + }); + setRunning(false); + }, + onFailed: (_runId, error) => { + setRunError(error); + setRunning(false); + }, + onDisabled: () => { + setDisabled(true); + setRunning(false); + }, + }); + } + const tracker = trackerRef.current; + + // Recover after a reload/remount: a persisted runId with no result means the + // run may still be in flight or already finished — poll to catch up. + // biome-ignore lint/correctness/useExhaustiveDependencies: mount-only recovery + useEffect(() => { + if (persistedRunId && !envelope) { + setRunning(true); + tracker.start(persistedRunId); + } + return () => tracker.stop(); + }, []); + + const run = async (): Promise => { + const activeClient = clientRef.current; + const shortId = shortIdRef.current; + if (!activeClient || !shortId || running) return; + if (!codeRef.current.trim()) { + setRunError("Query is empty — type some SQL first."); + return; + } + setRunError(null); + setRunning(true); + try { + const response = await activeClient.notebookSqlV2Run(shortId, { + node_id: node.id, + code: codeRef.current, + refs: {}, + }); + if (response.status === "disabled") { + setDisabled(true); + setRunning(false); + return; + } + updatePropsRef.current({ runId: response.runId, result: null }); + tracker.start(response.runId); + } catch (error) { + setRunError(error instanceof Error ? error.message : "Failed to run"); + setRunning(false); + } + }; + + const disabledReason = !cellContext + ? "This cell can only run inside a notebook." + : !client + ? "Sign in to PostHog to run this cell." + : null; + + const fetchPage = + client && cellContext && persistedRunId + ? async (offset: number, limit: number) => { + const response = await client.notebookSqlV2RunPage( + cellContext.shortId, + persistedRunId, + { offset, limit: Math.min(limit, SQL_V2_PAGE_LIMIT) }, + ); + if (response.status === "stale") { + throw new Error( + "This result was replaced by a newer run — re-run the query.", + ); + } + if (response.status === "busy") { + throw new Error("The kernel is busy — try again in a moment."); + } + if (response.status === "disabled") { + throw new Error("SQL v2 runs aren't enabled for this project."); + } + return { rows: response.page.rows }; + } + : undefined; + + return ( + } + title={title} + actions={ + void run()} + /> + } + > + updateProps({ code: value })} + onRun={() => void run()} + /> + {disabledReason ? {disabledReason} : null} + {disabled ? ( + SQL v2 runs aren't enabled for this project. + ) : null} + {running ? Running… polling for the result. : null} + {runError && !running ? {runError} : null} + {!running && envelope ? ( + envelope.error ? ( + {envelope.error} + ) : ( + + ) + ) : null} + updateProps({ returnVariable: value || "sql_df" })} + /> + + ); +} + +function coerceEnvelope(value: unknown): NotebookSqlV2ResultEnvelope | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + return value as NotebookSqlV2ResultEnvelope; +} diff --git a/packages/ui/src/features/notebooks/cells/cellBlocks.tsx b/packages/ui/src/features/notebooks/cells/cellBlocks.tsx new file mode 100644 index 0000000000..13894ddb2c --- /dev/null +++ b/packages/ui/src/features/notebooks/cells/cellBlocks.tsx @@ -0,0 +1,136 @@ +import { Button, Input } from "@posthog/quill"; +import { Loader2, Play } from "lucide-react"; +import type { ReactNode } from "react"; +import { useEffect, useState } from "react"; + +/** Card shell shared by the runnable cells (Python / SQL / SQL v2). */ +export function CellFrame({ + icon, + title, + actions, + children, +}: { + icon?: ReactNode; + title: string; + actions?: ReactNode; + children: ReactNode; +}) { + return ( +
+
+ {icon ? ( + + {icon} + + ) : null} + + {title} + + {actions} +
+ {children} +
+ ); +} + +export function RunCellButton({ + running, + disabled, + disabledReason, + onRun, +}: { + running: boolean; + disabled?: boolean; + disabledReason?: string | null; + onRun: () => void; +}) { + return ( + + ); +} + +/** Labelled monospace output block (stdout / stderr / result / traceback). */ +export function CellOutputBlock({ + title, + tone = "default", + text, +}: { + title: string; + tone?: "default" | "danger"; + text: string; +}) { + return ( +
+
+ {title} +
+
+        {text}
+      
+
+ ); +} + +export function CellHint({ children }: { children: ReactNode }) { + return
{children}
; +} + +export function CellError({ children }: { children: ReactNode }) { + return
{children}
; +} + +/** + * Editable "return variable" footer input, committed on blur/Enter so a + * half-typed identifier never lands in the persisted markdown. + */ +export function ReturnVariableInput({ + value, + placeholder, + onCommit, +}: { + value: string; + placeholder: string; + onCommit: (value: string) => void; +}) { + const [draft, setDraft] = useState(value); + useEffect(() => { + setDraft(value); + }, [value]); + const commit = () => { + const next = draft.trim(); + if (next !== value) onCommit(next); + }; + return ( +
+ Return variable + setDraft(event.target.value)} + onBlur={commit} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + commit(); + } + event.stopPropagation(); + }} + /> +
+ ); +} diff --git a/packages/ui/src/features/notebooks/cells/cellExecution.test.ts b/packages/ui/src/features/notebooks/cells/cellExecution.test.ts new file mode 100644 index 0000000000..dae98d6960 --- /dev/null +++ b/packages/ui/src/features/notebooks/cells/cellExecution.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it } from "vitest"; +import { + buildDuckSqlCode, + buildErrorExecution, + buildHogqlSqlAssignmentCode, + buildMediaSource, + buildSqlResultSummary, + formatTraceback, + hasHogqlPlaceholders, + normalizeKernelExecution, + normalizeSqlResultSummary, + resolveReturnVariable, + stripAnsi, + toPositionalRows, +} from "./cellExecution"; + +describe("normalizeKernelExecution", () => { + it("normalizes a wire execution dict", () => { + const execution = normalizeKernelExecution({ + status: "ok", + stdout: "hello\n", + stderr: "", + result: { "text/plain": "42" }, + media: [{ mime_type: "image/png", data: "abc123" }], + execution_count: 3, + error_name: null, + traceback: [], + started_at: "2026-01-01T00:00:00Z", + completed_at: "2026-01-01T00:00:01Z", + }); + expect(execution).toEqual({ + status: "ok", + stdout: "hello\n", + stderr: "", + resultText: "42", + media: [{ mimeType: "image/png", data: "abc123" }], + executionCount: 3, + errorName: null, + traceback: [], + startedAt: "2026-01-01T00:00:00Z", + completedAt: "2026-01-01T00:00:01Z", + }); + }); + + it("round-trips a previously normalized (persisted) execution", () => { + const first = normalizeKernelExecution({ + status: "error", + stdout: "", + stderr: "boom", + error_name: "ValueError", + traceback: ["Traceback", "ValueError: boom"], + }); + expect(normalizeKernelExecution(first)).toEqual(first); + }); + + it.each([ + [null, null], + ["nope", null], + [[1, 2], null], + ])("returns null for non-object input %j", (input, expected) => { + expect(normalizeKernelExecution(input)).toBe(expected); + }); + + it("suppresses the text result when the result is image-only", () => { + const execution = normalizeKernelExecution({ + status: "ok", + result: { "image/png": "abc" }, + }); + expect(execution?.resultText).toBeNull(); + }); + + it("treats unknown statuses as error", () => { + expect(normalizeKernelExecution({ status: "wat" })?.status).toBe("error"); + }); +}); + +describe("buildErrorExecution", () => { + it("wraps a message into an error-shaped execution", () => { + const execution = buildErrorExecution("network down"); + expect(execution.status).toBe("error"); + expect(execution.errorName).toBe("RuntimeError"); + expect(execution.traceback).toEqual(["network down"]); + }); +}); + +describe("stripAnsi / formatTraceback", () => { + it("strips ANSI color escapes", () => { + expect(stripAnsi("\u001b[0;31mValueError\u001b[0m: boom")).toBe( + "ValueError: boom", + ); + }); + + it("joins traceback lines and strips escapes", () => { + expect(formatTraceback(["\u001b[1mTraceback\u001b[0m", "boom"])).toBe( + "Traceback\nboom", + ); + }); +}); + +describe("buildMediaSource", () => { + it("builds a data URI", () => { + expect(buildMediaSource({ mimeType: "image/png", data: "abc" })).toBe( + "data:image/png;base64,abc", + ); + }); + + it("returns null when fields are missing", () => { + expect(buildMediaSource({ mimeType: "", data: "abc" })).toBeNull(); + }); +}); + +describe("resolveReturnVariable", () => { + it.each([ + ["my_df", "duck_df", "my_df"], + [" padded ", "duck_df", "padded"], + ["", "duck_df", "duck_df"], + [" ", "hogql_df", "hogql_df"], + [undefined, "sql_df", "sql_df"], + [42, "sql_df", "sql_df"], + ])("resolves %j (fallback %s) to %s", (input, fallback, expected) => { + expect(resolveReturnVariable(input, fallback)).toBe(expected); + }); +}); + +describe("buildDuckSqlCode", () => { + it("matches the webapp's wrapper template", () => { + expect(buildDuckSqlCode("SELECT 1", "duck_df", 10)).toBe( + `import json\n` + + `duck_df = duck_execute("SELECT 1")\n` + + `duck_save_table("duck_df", duck_df)\n` + + `json.dumps(notebook_dataframe_page(duck_df, offset=0, limit=10))`, + ); + }); + + it("escapes quotes and newlines in the SQL literal", () => { + const code = buildDuckSqlCode('SELECT "a"\nFROM t', "df", 5); + expect(code).toContain('duck_execute("SELECT \\"a\\"\\nFROM t")'); + expect(code).toContain("limit=5"); + }); + + it("clamps the page size to at least 1 and defaults the variable", () => { + const code = buildDuckSqlCode("SELECT 1", " ", 0); + expect(code).toContain("duck_df = duck_execute"); + expect(code).toContain("limit=10"); + }); +}); + +describe("buildHogqlSqlAssignmentCode", () => { + it("assigns hogql_execute output to the return variable", () => { + expect(buildHogqlSqlAssignmentCode("SELECT 1", "hogql_df")).toBe( + 'hogql_df = hogql_execute("SELECT 1")', + ); + }); +}); + +describe("hasHogqlPlaceholders", () => { + it.each([ + ["SELECT * FROM events WHERE x = {filters}", true], + ["SELECT 1", false], + ["SELECT '{not_a_placeholder}'", false], + [`SELECT "{also_not}"`, false], + ["SELECT '\\'{still_string}'", false], + ["SELECT 'quoted' || {real}", true], + ["", false], + ])("detects placeholders in %j → %s", (code, expected) => { + expect(hasHogqlPlaceholders(code)).toBe(expected); + }); +}); + +describe("toPositionalRows", () => { + it("passes array rows through, coercing cells", () => { + expect(toPositionalRows([[1, "a", null, { x: 1 }]], [])).toEqual([ + [1, "a", null, '{"x":1}'], + ]); + }); + + it("orders record rows by the column list", () => { + expect(toPositionalRows([{ b: 2, a: 1 }], ["a", "b"])).toEqual([[1, 2]]); + }); + + it("falls back to record values without columns", () => { + expect(toPositionalRows([{ a: 1, b: 2 }], [])).toEqual([[1, 2]]); + }); + + it("wraps scalar rows", () => { + expect(toPositionalRows([7], ["n"])).toEqual([[7]]); + }); +}); + +describe("sql result summaries", () => { + it("builds an ok summary with a capped preview", () => { + const rows = Array.from({ length: 80 }, (_, index) => [index]); + const summary = buildSqlResultSummary({ + columns: ["n"], + rows, + rowCount: 80, + }); + expect(summary.status).toBe("ok"); + expect(summary.rows).toHaveLength(50); + expect(summary.rowCount).toBe(80); + expect(summary.error).toBeNull(); + }); + + it("builds an error summary", () => { + const summary = buildSqlResultSummary({ + columns: [], + rows: [], + rowCount: 0, + error: "bad query", + }); + expect(summary.status).toBe("error"); + expect(summary.error).toBe("bad query"); + }); + + it("round-trips through normalizeSqlResultSummary", () => { + const summary = buildSqlResultSummary({ + columns: ["a"], + rows: [[1]], + rowCount: 1, + }); + const restored = normalizeSqlResultSummary( + JSON.parse(JSON.stringify(summary)), + ); + expect(restored).toEqual(summary); + }); + + it.each([[null], ["junk"], [{}]])( + "returns null for empty/junk props %j", + (input) => { + expect(normalizeSqlResultSummary(input)).toBeNull(); + }, + ); +}); diff --git a/packages/ui/src/features/notebooks/cells/cellExecution.ts b/packages/ui/src/features/notebooks/cells/cellExecution.ts new file mode 100644 index 0000000000..f604579073 --- /dev/null +++ b/packages/ui/src/features/notebooks/cells/cellExecution.ts @@ -0,0 +1,356 @@ +/** + * Framework-free helpers for runnable notebook cells: kernel execution-dict + * normalization, traceback formatting, the DuckDB/HogQL kernel wrapper-code + * builders (mirroring the PostHog webapp's notebookNodeLogic templates), and + * HogQL placeholder detection. + */ + +export interface CellExecutionMedia { + mimeType: string; + data: string; +} + +/** Normalized execution outcome a cell renders and persists into node props. */ +export interface CellExecution { + status: "ok" | "error" | "running"; + stdout: string; + stderr: string; + /** `result["text/plain"]` (or html fallback) when the value isn't an image. */ + resultText: string | null; + media: CellExecutionMedia[]; + executionCount: number | null; + errorName: string | null; + traceback: string[]; + startedAt: string | null; + completedAt: string | null; +} + +const IMAGE_MIME_TYPES = [ + "image/png", + "image/jpeg", + "image/jpg", + "image/svg+xml", + "image/gif", + "image/webp", +]; + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function extractResultText(result: unknown): string | null { + const record = asRecord(result); + if (!record) return null; + const preferred = record["text/plain"] ?? record["text/html"]; + if (typeof preferred === "string") return preferred; + // Pure-image results render as media, not as a text block. + if (IMAGE_MIME_TYPES.some((mimeType) => record[mimeType])) return null; + try { + return JSON.stringify(record); + } catch { + return null; + } +} + +function extractMedia(value: unknown): CellExecutionMedia[] { + if (!Array.isArray(value)) return []; + const media: CellExecutionMedia[] = []; + for (const item of value) { + const record = asRecord(item); + const mimeType = record?.mime_type ?? record?.mimeType; + const data = record?.data; + if (typeof mimeType === "string" && typeof data === "string") { + media.push({ mimeType, data }); + } + } + return media; +} + +/** + * Normalize a kernel execution dict (from execute, the stream's `result` + * frame, or a persisted node prop) into the shape cells render. Tolerates + * both snake_case wire fields and a previously-persisted normalized value. + */ +export function normalizeKernelExecution(raw: unknown): CellExecution | null { + const record = asRecord(raw); + if (!record) return null; + const status = + record.status === "ok" || record.status === "running" + ? record.status + : "error"; + const resultText = + typeof record.resultText === "string" + ? record.resultText + : extractResultText(record.result); + return { + status, + stdout: asString(record.stdout), + stderr: asString(record.stderr), + resultText, + media: extractMedia(record.media), + executionCount: + typeof record.execution_count === "number" + ? record.execution_count + : typeof record.executionCount === "number" + ? record.executionCount + : null, + errorName: + typeof record.error_name === "string" + ? record.error_name + : typeof record.errorName === "string" + ? record.errorName + : null, + traceback: Array.isArray(record.traceback) + ? record.traceback.filter((line): line is string => { + return typeof line === "string"; + }) + : [], + startedAt: + typeof record.started_at === "string" + ? record.started_at + : typeof record.startedAt === "string" + ? record.startedAt + : null, + completedAt: + typeof record.completed_at === "string" + ? record.completed_at + : typeof record.completedAt === "string" + ? record.completedAt + : null, + }; +} + +/** Build an error-shaped execution (e.g. a transport failure) for rendering. */ +export function buildErrorExecution(message: string): CellExecution { + return { + status: "error", + stdout: "", + stderr: "", + resultText: null, + media: [], + executionCount: null, + errorName: "RuntimeError", + traceback: [message], + startedAt: null, + completedAt: null, + }; +} + +// Kernel tracebacks arrive with ANSI color escapes; we render plain text. +// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escapes are control characters by definition +const ANSI_ESCAPE_PATTERN = /\u001b\[[0-9;]*m/g; + +export function stripAnsi(text: string): string { + return text.replace(ANSI_ESCAPE_PATTERN, ""); +} + +export function formatTraceback(traceback: string[]): string { + return stripAnsi(traceback.join("\n")); +} + +export function buildMediaSource(media: CellExecutionMedia): string | null { + if (!media.mimeType || !media.data) return null; + return `data:${media.mimeType};base64,${media.data}`; +} + +// --------------------------------------------------------------------------- +// Kernel wrapper-code builders — copied from the webapp's notebookNodeLogic. +// `duck_execute`, `duck_save_table`, `hogql_execute` and +// `notebook_dataframe_page` are helpers pre-defined inside the kernel. +// --------------------------------------------------------------------------- + +export const DEFAULT_DATAFRAME_PAGE_SIZE = 10; + +export function resolveReturnVariable( + returnVariable: unknown, + fallback: string, +): string { + const trimmed = + typeof returnVariable === "string" ? returnVariable.trim() : ""; + return trimmed || fallback; +} + +export function buildDuckSqlCode( + code: string, + returnVariable: string, + pageSize: number = DEFAULT_DATAFRAME_PAGE_SIZE, +): string { + const resolvedReturnVariable = resolveReturnVariable( + returnVariable, + "duck_df", + ); + const sqlLiteral = JSON.stringify(code ?? ""); + const tableNameLiteral = JSON.stringify(resolvedReturnVariable); + const previewPageSize = Math.max(1, pageSize || DEFAULT_DATAFRAME_PAGE_SIZE); + return ( + `import json\n` + + `${resolvedReturnVariable} = duck_execute(${sqlLiteral})\n` + + `duck_save_table(${tableNameLiteral}, ${resolvedReturnVariable})\n` + + `json.dumps(notebook_dataframe_page(${resolvedReturnVariable}, offset=0, limit=${previewPageSize}))` + ); +} + +export function buildHogqlSqlAssignmentCode( + code: string, + returnVariable: string, +): string { + const resolvedReturnVariable = resolveReturnVariable( + returnVariable, + "hogql_df", + ); + const sqlLiteral = JSON.stringify(code ?? ""); + return `${resolvedReturnVariable} = hogql_execute(${sqlLiteral})`; +} + +/** + * True when the HogQL source contains `{placeholder}` syntax outside string + * literals. Placeholder values live in the Python kernel, so plain + * `hogql/execute` can't resolve them — cells show a hint in that case. + */ +export function hasHogqlPlaceholders(code: string): boolean { + let quote: "'" | '"' | null = null; + for (let index = 0; index < code.length; index += 1) { + const char = code[index]; + if (quote) { + if (char === "\\") { + index += 1; // skip the escaped character + } else if (char === quote) { + quote = null; + } + continue; + } + if (char === "'" || char === '"') { + quote = char; + } else if (char === "{") { + return true; + } + } + return false; +} + +// --------------------------------------------------------------------------- +// Persisted SQL-cell result summaries (`hogqlExecution` / `duckExecution` +// props): enough to re-render the table after a reload without a kernel. +// --------------------------------------------------------------------------- + +export interface SqlCellResultSummary { + status: "ok" | "error"; + columns: string[]; + /** Preview rows (positional, matching `columns`). */ + rows: (string | number | boolean | null)[][]; + rowCount: number; + error: string | null; + completedAt: string | null; +} + +const MAX_PERSISTED_PREVIEW_ROWS = 50; + +function toPreviewCell(value: unknown): string | number | boolean | null { + if (value == null) return null; + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return value; + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +/** Coerce arbitrary result rows (arrays or records) into positional preview rows. */ +export function toPositionalRows( + rows: unknown[], + columns: string[], +): (string | number | boolean | null)[][] { + return rows.map((row) => { + if (Array.isArray(row)) { + return row.map(toPreviewCell); + } + const record = asRecord(row); + if (record) { + if (columns.length > 0) { + return columns.map((column) => toPreviewCell(record[column])); + } + return Object.values(record).map(toPreviewCell); + } + return [toPreviewCell(row)]; + }); +} + +export function buildSqlResultSummary(input: { + columns: string[]; + rows: unknown[]; + rowCount: number; + error?: string | null; +}): SqlCellResultSummary { + return { + status: input.error ? "error" : "ok", + columns: input.columns, + rows: toPositionalRows( + input.rows.slice(0, MAX_PERSISTED_PREVIEW_ROWS), + input.columns, + ), + rowCount: input.rowCount, + error: input.error ?? null, + completedAt: new Date().toISOString(), + }; +} + +/** Recover a persisted result summary from node props (tolerates junk). */ +export function normalizeSqlResultSummary( + raw: unknown, +): SqlCellResultSummary | null { + const record = asRecord(raw); + if (!record) return null; + const columns = Array.isArray(record.columns) + ? record.columns.map(String) + : []; + const rows = Array.isArray(record.rows) + ? toPositionalRows(record.rows, columns) + : []; + const error = typeof record.error === "string" ? record.error : null; + if (columns.length === 0 && rows.length === 0 && !error) return null; + return { + status: error ? "error" : "ok", + columns, + rows, + rowCount: + typeof record.rowCount === "number" ? record.rowCount : rows.length, + error, + completedAt: + typeof record.completedAt === "string" ? record.completedAt : null, + }; +} + +/** + * The notebook kernel/* and hogql/execute backend actions declare no API + * scopes, so PostHog rejects token-authenticated calls to them — as a 403 + * "This action does not support personal API key access" through the scope + * layer, or as a 401 "Invalid access token" from the OAuth authentication + * class. They are session-auth only until upstream adds required_scopes. + * Detect those rejections so cells can explain the situation instead of + * dumping the raw error. (Only used for kernel-backed calls, where a 401 + * cannot mean an expired session: the shared fetcher already retried with a + * freshly refreshed token before throwing.) + */ +export const KERNEL_SESSION_ONLY_MESSAGE = + "PostHog doesn't allow kernel access with desktop app authentication yet — kernel-backed cells currently run only in the PostHog web app."; + +export function isSessionOnlyEndpointError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + return ( + error.message.includes("does not support personal API key access") || + (error.message.includes("[401]") && + error.message.includes("authentication_failed")) + ); +} diff --git a/packages/ui/src/features/notebooks/cells/sqlV2RunTracker.test.ts b/packages/ui/src/features/notebooks/cells/sqlV2RunTracker.test.ts new file mode 100644 index 0000000000..3fc7c2f341 --- /dev/null +++ b/packages/ui/src/features/notebooks/cells/sqlV2RunTracker.test.ts @@ -0,0 +1,203 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { SqlV2PollOutcome } from "./sqlV2RunTracker"; +import { SqlV2RunTracker } from "./sqlV2RunTracker"; + +interface Harness { + tracker: SqlV2RunTracker; + polls: string[]; + done: { runId: string; result: unknown }[]; + failed: { runId: string; error: string }[]; + disabledCount: () => number; + queueOutcome: (...outcomes: SqlV2PollOutcome[]) => void; + setPollImpl: (impl: (runId: string) => Promise) => void; +} + +function makeHarness(options?: { maxAttempts?: number }): Harness { + const polls: string[] = []; + const done: { runId: string; result: unknown }[] = []; + const failed: { runId: string; error: string }[] = []; + let disabled = 0; + let queued: SqlV2PollOutcome[] = []; + let pollImpl = (_runId: string): Promise => { + const next = queued.shift(); + return Promise.resolve(next ?? { status: "running" }); + }; + + const tracker = new SqlV2RunTracker({ + poll: (runId) => { + polls.push(runId); + return pollImpl(runId); + }, + onDone: (runId, result) => done.push({ runId, result }), + onFailed: (runId, error) => failed.push({ runId, error }), + onDisabled: () => { + disabled += 1; + }, + intervalMs: 1000, + maxAttempts: options?.maxAttempts, + }); + + return { + tracker, + polls, + done, + failed, + disabledCount: () => disabled, + queueOutcome: (...outcomes) => { + queued = [...queued, ...outcomes]; + }, + setPollImpl: (impl) => { + pollImpl = impl; + }, + }; +} + +async function flush(): Promise { + // Let promise callbacks queued by resolved polls run. + await Promise.resolve(); + await Promise.resolve(); +} + +describe("SqlV2RunTracker", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("polls immediately, then every interval, until done", async () => { + const harness = makeHarness(); + harness.queueOutcome( + { status: "running" }, + { status: "running" }, + { status: "done", result: { columns: ["a"] } }, + ); + harness.tracker.start("run-1"); + await flush(); + expect(harness.polls).toEqual(["run-1"]); + + await vi.advanceTimersByTimeAsync(1000); + expect(harness.polls).toHaveLength(2); + expect(harness.done).toHaveLength(0); + + await vi.advanceTimersByTimeAsync(1000); + expect(harness.done).toEqual([ + { runId: "run-1", result: { columns: ["a"] } }, + ]); + expect(harness.tracker.runningRunId).toBeNull(); + + // Polling stopped after the terminal outcome. + await vi.advanceTimersByTimeAsync(5000); + expect(harness.polls).toHaveLength(3); + }); + + it("reports a failed run with its error", async () => { + const harness = makeHarness(); + harness.queueOutcome({ status: "failed", error: "syntax error" }); + harness.tracker.start("run-1"); + await flush(); + expect(harness.failed).toEqual([{ runId: "run-1", error: "syntax error" }]); + }); + + it("defaults the failure message when the server omits it", async () => { + const harness = makeHarness(); + harness.queueOutcome({ status: "failed", error: null }); + harness.tracker.start("run-1"); + await flush(); + expect(harness.failed[0]?.error).toBe("Run failed"); + }); + + it("reports disabled and stops", async () => { + const harness = makeHarness(); + harness.queueOutcome({ status: "disabled" }); + harness.tracker.start("run-1"); + await flush(); + expect(harness.disabledCount()).toBe(1); + await vi.advanceTimersByTimeAsync(3000); + expect(harness.polls).toHaveLength(1); + }); + + it("times out after maxAttempts polls", async () => { + const harness = makeHarness({ maxAttempts: 3 }); + harness.tracker.start("run-1"); + await flush(); + await vi.advanceTimersByTimeAsync(3000); + expect(harness.polls).toHaveLength(3); + expect(harness.failed).toEqual([ + { runId: "run-1", error: "Timed out waiting for result" }, + ]); + // No further polls after giving up. + await vi.advanceTimersByTimeAsync(3000); + expect(harness.polls).toHaveLength(3); + }); + + it("fails on a poll transport error", async () => { + const harness = makeHarness(); + harness.setPollImpl(() => Promise.reject(new Error("network down"))); + harness.tracker.start("run-1"); + await flush(); + expect(harness.failed).toEqual([{ runId: "run-1", error: "network down" }]); + }); + + it("drops a stale in-flight response when a newer run supersedes it", async () => { + const harness = makeHarness(); + let resolveFirst: (outcome: SqlV2PollOutcome) => void = () => {}; + harness.setPollImpl((runId) => { + if (runId === "run-1") { + return new Promise((resolve) => { + resolveFirst = resolve; + }); + } + return Promise.resolve({ status: "done", result: { columns: ["b"] } }); + }); + + harness.tracker.start("run-1"); + await flush(); + // First poll for run-1 is still in flight; a new run supersedes it. + harness.tracker.start("run-2"); + await flush(); + + // run-1's poll resolves "done" late — it must be discarded. + resolveFirst({ status: "done", result: { columns: ["a"] } }); + await flush(); + expect(harness.done).toEqual([ + { runId: "run-2", result: { columns: ["b"] } }, + ]); + expect(harness.tracker.runningRunId).toBeNull(); + }); + + it("skips a tick while a poll is still in flight instead of queueing", async () => { + const harness = makeHarness(); + let resolvePoll: (outcome: SqlV2PollOutcome) => void = () => {}; + harness.setPollImpl( + () => + new Promise((resolve) => { + resolvePoll = resolve; + }), + ); + harness.tracker.start("run-1"); + await flush(); + expect(harness.polls).toHaveLength(1); + + // Two intervals pass with the first poll unresolved — no extra polls. + await vi.advanceTimersByTimeAsync(2000); + expect(harness.polls).toHaveLength(1); + + resolvePoll({ status: "running" }); + await flush(); + await vi.advanceTimersByTimeAsync(1000); + expect(harness.polls).toHaveLength(2); + }); + + it("stop() halts polling without callbacks", async () => { + const harness = makeHarness(); + harness.tracker.start("run-1"); + await flush(); + harness.tracker.stop(); + await vi.advanceTimersByTimeAsync(5000); + expect(harness.polls).toHaveLength(1); + expect(harness.done).toHaveLength(0); + expect(harness.failed).toHaveLength(0); + }); +}); diff --git a/packages/ui/src/features/notebooks/cells/sqlV2RunTracker.ts b/packages/ui/src/features/notebooks/cells/sqlV2RunTracker.ts new file mode 100644 index 0000000000..9636d4e5c0 --- /dev/null +++ b/packages/ui/src/features/notebooks/cells/sqlV2RunTracker.ts @@ -0,0 +1,107 @@ +import type { NotebookSqlV2ResultEnvelope } from "@posthog/api-client/posthog-client"; + +/** What one poll of the run-status endpoint reported. */ +export type SqlV2PollOutcome = + | { status: "running" } + | { status: "done"; result: NotebookSqlV2ResultEnvelope | null } + | { status: "failed"; error: string | null } + | { status: "disabled" }; + +export interface SqlV2RunTrackerDeps { + poll: (runId: string) => Promise; + onDone: (runId: string, result: NotebookSqlV2ResultEnvelope | null) => void; + onFailed: (runId: string, error: string) => void; + onDisabled: () => void; + intervalMs?: number; + maxAttempts?: number; +} + +const DEFAULT_POLL_INTERVAL_MS = 1000; +const DEFAULT_MAX_POLL_ATTEMPTS = 150; // ~2.5 minutes at 1s — matches the webapp + +/** + * Polls a SQL v2 run until it terminates. Plain class (no React) so the poll + * state machine is testable with fake timers. Semantics mirror the webapp's + * notebookNodeSQLV2Logic: + * - one poll in flight at a time (a slow response skips ticks, not queues); + * - `start()` with a new runId supersedes the previous run — a stale in-flight + * response can neither report a result nor stop the new run's polling; + * - gives up with `onFailed` after `maxAttempts` polls. + */ +export class SqlV2RunTracker { + private readonly deps: SqlV2RunTrackerDeps; + private activeRunId: string | null = null; + private intervalId: ReturnType | null = null; + private attempts = 0; + /** + * The runId whose poll is currently in flight. Keyed by runId (not a + * boolean) so a superseding run's first poll isn't blocked by the stale + * run's still-unresolved request. + */ + private pollInFlightFor: string | null = null; + + constructor(deps: SqlV2RunTrackerDeps) { + this.deps = deps; + } + + get runningRunId(): string | null { + return this.activeRunId; + } + + start(runId: string): void { + this.stop(); + this.activeRunId = runId; + this.attempts = 0; + void this.tick(runId); + this.intervalId = setInterval( + () => void this.tick(runId), + this.deps.intervalMs ?? DEFAULT_POLL_INTERVAL_MS, + ); + } + + stop(): void { + if (this.intervalId !== null) { + clearInterval(this.intervalId); + this.intervalId = null; + } + this.activeRunId = null; + } + + private async tick(runId: string): Promise { + if (this.pollInFlightFor === runId || runId !== this.activeRunId) return; + this.attempts += 1; + if (this.attempts > (this.deps.maxAttempts ?? DEFAULT_MAX_POLL_ATTEMPTS)) { + this.stop(); + this.deps.onFailed(runId, "Timed out waiting for result"); + return; + } + this.pollInFlightFor = runId; + try { + const outcome = await this.deps.poll(runId); + // A newer run started while this poll was in flight — drop the response. + if (runId !== this.activeRunId) return; + if (outcome.status === "done") { + this.stop(); + this.deps.onDone(runId, outcome.result); + } else if (outcome.status === "failed") { + this.stop(); + this.deps.onFailed(runId, outcome.error ?? "Run failed"); + } else if (outcome.status === "disabled") { + this.stop(); + this.deps.onDisabled(); + } + // "running" → keep polling + } catch (error) { + if (runId !== this.activeRunId) return; + this.stop(); + this.deps.onFailed( + runId, + error instanceof Error ? error.message : "Failed to fetch result", + ); + } finally { + if (this.pollInFlightFor === runId) { + this.pollInFlightFor = null; + } + } + } +} diff --git a/packages/ui/src/features/notebooks/cells/useKernelStatus.ts b/packages/ui/src/features/notebooks/cells/useKernelStatus.ts new file mode 100644 index 0000000000..6136d9dc11 --- /dev/null +++ b/packages/ui/src/features/notebooks/cells/useKernelStatus.ts @@ -0,0 +1,93 @@ +import type { NotebookKernelStatus } from "@posthog/api-client/posthog-client"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + isSessionOnlyEndpointError, + KERNEL_SESSION_ONLY_MESSAGE, +} from "./cellExecution"; + +const STARTING_POLL_MS = 2000; +const IDLE_POLL_MS = 10_000; + +export interface KernelStatusState { + status: NotebookKernelStatus | null; + /** True only until the first response arrives. */ + loading: boolean; + error: string | null; + refresh: () => Promise; + /** Optimistically adopt a status returned by a start/stop/restart call. */ + applyStatus: (status: NotebookKernelStatus) => void; + hasClient: boolean; +} + +/** + * Polls the notebook kernel status while the consuming component (the kernel + * panel) is mounted: every 2s while the kernel is `starting`, every 10s + * otherwise. Unmounting stops the polling entirely. + */ +export function useKernelStatus(shortId: string): KernelStatusState { + const client = useOptionalAuthenticatedClient(); + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const clientRef = useRef(client); + clientRef.current = client; + const statusRef = useRef(status); + statusRef.current = status; + + const refresh = useCallback(async (): Promise => { + const activeClient = clientRef.current; + if (!activeClient) return; + try { + const next = await activeClient.notebookKernelStatus(shortId); + setStatus(next); + setError(null); + } catch (fetchError) { + setError( + isSessionOnlyEndpointError(fetchError) + ? KERNEL_SESSION_ONLY_MESSAGE + : fetchError instanceof Error + ? fetchError.message + : "Failed to fetch kernel status", + ); + } finally { + setLoading(false); + } + }, [shortId]); + + useEffect(() => { + if (!client) { + setLoading(false); + return; + } + let cancelled = false; + let timeoutId: ReturnType | null = null; + const tick = async (): Promise => { + await refresh(); + if (cancelled) return; + const interval = + statusRef.current?.status === "starting" + ? STARTING_POLL_MS + : IDLE_POLL_MS; + timeoutId = setTimeout(() => void tick(), interval); + }; + void tick(); + return () => { + cancelled = true; + if (timeoutId !== null) clearTimeout(timeoutId); + }; + }, [client, refresh]); + + const applyStatus = useCallback((next: NotebookKernelStatus) => { + setStatus(next); + }, []); + + return { + status, + loading, + error, + refresh, + applyStatus, + hasClient: client !== null, + }; +} diff --git a/packages/ui/src/features/notebooks/embeds/CohortEmbed.tsx b/packages/ui/src/features/notebooks/embeds/CohortEmbed.tsx new file mode 100644 index 0000000000..ab9d65ddba --- /dev/null +++ b/packages/ui/src/features/notebooks/embeds/CohortEmbed.tsx @@ -0,0 +1,60 @@ +import { Users } from "lucide-react"; +import type { JSX } from "react"; +import type { NotebookComponentRenderProps } from "../markdown-notebook/types"; +import { + EmbedCard, + EmbedCardError, + EmbedCardHint, + EmbedCardRow, + EmbedCardSkeleton, +} from "./EmbedCard"; +import { getNumberProp } from "./embedProps"; +import { buildPostHogEntityUrl } from "./openInPostHog"; +import { useEmbedQuery } from "./useEmbedQuery"; + +export function CohortEmbed({ + node, +}: NotebookComponentRenderProps): JSX.Element { + const id = getNumberProp(node.props.id); + const { data, teamId, appHost, isLoading, error } = useEmbedQuery( + ["cohort", id], + (client) => client.getCohort(id ?? 0), + { enabled: id !== null }, + ); + + if (id === null) { + return ( + } title="Cohort"> + No cohort configured. + + ); + } + if (isLoading) return ; + if (error || !data) { + return ; + } + + const url = + appHost && teamId != null + ? buildPostHogEntityUrl(appHost, teamId, { + kind: "cohort", + id: String(data.id), + }) + : null; + + return ( + } + title={data.name} + badge={data.is_static ? "Static" : "Dynamic"} + badgeVariant={data.is_static ? "default" : "info"} + url={url} + > + {data.count != null ? ( + + {data.count.toLocaleString()} + + ) : null} + + ); +} diff --git a/packages/ui/src/features/notebooks/embeds/DiscussionCommentEmbed.tsx b/packages/ui/src/features/notebooks/embeds/DiscussionCommentEmbed.tsx new file mode 100644 index 0000000000..2017d34363 --- /dev/null +++ b/packages/ui/src/features/notebooks/embeds/DiscussionCommentEmbed.tsx @@ -0,0 +1,252 @@ +import { Button } from "@posthog/quill"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { useQuery } from "@tanstack/react-query"; +import { Send, Trash2 } from "lucide-react"; +import { type KeyboardEvent, useEffect, useRef, useState } from "react"; +import { wasNotebookNodeJustInserted } from "../markdown-notebook/freshlyInserted"; +import type { + NotebookComponentBlockNode, + NotebookComponentRenderProps, + NotebookPropValue, +} from "../markdown-notebook/types"; + +/** + * One human reply inside a `` tag. Replies + * live in the markdown itself, keyed by `id` so concurrent replies from + * different people merge instead of clobbering each other (see + * mergeIdKeyedArrayPropValues in the vendored collaboration.ts). Port of the + * webapp's NotebookDiscussionComment. + */ +export interface NotebookCommentReply { + id: string; + text: string; + author?: string; + authorId?: number; + at?: string; +} + +export function getNotebookCommentReplies( + value: NotebookPropValue | undefined, +): NotebookCommentReply[] { + if (!Array.isArray(value)) { + return []; + } + return value.flatMap((entry): NotebookCommentReply[] => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return []; + } + const { id, text, author, authorId, at } = entry as Record< + string, + NotebookPropValue + >; + if (typeof id !== "string" || typeof text !== "string") { + return []; + } + return [ + { + id, + text, + author: typeof author === "string" ? author : undefined, + authorId: typeof authorId === "number" ? authorId : undefined, + at: typeof at === "string" ? at : undefined, + }, + ]; + }); +} + +export function getNotebookDiscussionCommentTitle( + node: NotebookComponentBlockNode, +): string | null { + const firstReply = getNotebookCommentReplies(node.props.replies)[0]; + return firstReply ? firstReply.text : "Comment thread"; +} + +function replyTime(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return ""; + const ms = Date.now() - date.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`; + return date.toLocaleDateString(); +} + +function AuthorAvatar({ name }: { name?: string }) { + return ( +
+ {(name ?? "?").slice(0, 1)} +
+ ); +} + +/** + * A Google Docs-style inline comment thread anchored to highlighted text: the + * thread holds people's replies; the `` span it points at carries a + * persistent highlight that lights up while hovering the thread. + */ +export function DiscussionCommentEmbed({ + node, + mode, + updateProps, + deleteNode, +}: NotebookComponentRenderProps) { + const client = useOptionalAuthenticatedClient(); + const { data: user } = useQuery({ + queryKey: ["current-user"], + queryFn: () => { + if (!client) throw new Error("Not authenticated"); + return client.getCurrentUser(); + }, + enabled: client !== null, + staleTime: Number.POSITIVE_INFINITY, + }); + const replies = getNotebookCommentReplies(node.props.replies); + const refId = typeof node.props.ref === "string" ? node.props.ref : null; + const [draft, setDraft] = useState(""); + const [isHovered, setIsHovered] = useState(false); + const repliesRef = useRef(null); + const isEditable = mode === "edit"; + const draftText = draft.trim(); + + // Light up the anchored highlight while the cursor is over the thread. + useEffect(() => { + if (!refId || !isHovered || typeof document === "undefined") { + return; + } + const highlighted = Array.from( + document.querySelectorAll(`[data-notebook-ref="${CSS.escape(refId)}"]`), + ); + for (const element of highlighted) { + element.classList.add("MarkdownNotebook__ref--active"); + } + return () => { + for (const element of highlighted) { + element.classList.remove("MarkdownNotebook__ref--active"); + } + }; + }, [refId, isHovered]); + + // The replies list is height-capped; new replies should land in view. + useEffect(() => { + const element = repliesRef.current; + if (element) { + element.scrollTop = element.scrollHeight; + } + }, []); + + const submitReply = (): void => { + if (!draftText || !isEditable) { + return; + } + const reply: NotebookCommentReply = { + id: globalThis.crypto.randomUUID(), + text: draftText, + author: user?.first_name || user?.email || undefined, + authorId: typeof user?.id === "number" ? user.id : undefined, + at: new Date().toISOString(), + }; + updateProps({ + replies: [...replies, reply] as unknown as NotebookPropValue, + }); + setDraft(""); + }; + + const handleComposerKeyDown = ( + event: KeyboardEvent, + ): void => { + event.stopPropagation(); + if ( + event.key === "Enter" && + !event.nativeEvent.isComposing && + !event.shiftKey + ) { + event.preventDefault(); + submitReply(); + } + }; + + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: hover only tints the anchored highlight — decorative, keyboard access unaffected +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + data-attr="notebook-discussion-comment" + > +
+ {replies.map((reply) => ( +
+ +
+
+ + {reply.author ?? "Someone"} + + {reply.at ? ( + + {replyTime(reply.at)} + + ) : null} +
+
+ {reply.text} +
+
+
+ ))} + {!replies.length && !isEditable ? ( +
+ No replies yet +
+ ) : null} +
+ {isEditable ? ( +
+
+