diff --git a/apps/code/package.json b/apps/code/package.json index 6127e7156..e6849dc66 100644 --- a/apps/code/package.json +++ b/apps/code/package.json @@ -90,6 +90,7 @@ "dependencies": { "@fontsource-variable/inter": "^5.2.8", "@inversifyjs/strongly-typed": "2.2.0", + "@json-render/core": "^0.19.0", "@modelcontextprotocol/sdk": "^1.12.1", "@opentelemetry/api-logs": "^0.208.0", "@opentelemetry/exporter-logs-otlp-http": "^0.208.0", diff --git a/apps/code/src/main/di/bindings.ts b/apps/code/src/main/di/bindings.ts index e3967e4f6..7e26d0511 100644 --- a/apps/code/src/main/di/bindings.ts +++ b/apps/code/src/main/di/bindings.ts @@ -8,6 +8,7 @@ import type { AUTH_TOKEN_CIPHER, AUTH_TOKEN_OVERRIDE, } from "@posthog/core/auth/identifiers"; +import type { CANVAS_GEN_SERVICE } from "@posthog/core/canvas/identifiers"; import type { CLOUD_TASK_AUTH, ICloudTaskAuth, @@ -93,6 +94,7 @@ import type { GIT_PR_STATUS_PROVIDER, IGitPrStatus, } from "@posthog/host-router/ports/git-pr-status"; +import type { CanvasGenService } from "@posthog/host-router/services/canvas-gen.service"; import type { ANALYTICS_SERVICE, IAnalytics, @@ -417,6 +419,7 @@ export interface MainBindings { [SECURE_STORE_SERVICE]: ISecureStoreService; [LOGS_SERVICE]: ILogsService; [MAIN_ENCRYPTION_SERVICE]: EncryptionService; + [CANVAS_GEN_SERVICE]: CanvasGenService; // ws-server git service (bound to(GitService)) [WS_GIT_SERVICE]: GitService; diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index 08f266d8b..fe87b4b91 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -18,6 +18,8 @@ import { AUTH_TOKEN_CIPHER, AUTH_TOKEN_OVERRIDE, } from "@posthog/core/auth/identifiers"; +import { canvasCoreModule } from "@posthog/core/canvas/canvas.module"; +import { CANVAS_GEN_SERVICE } from "@posthog/core/canvas/identifiers"; import { cloudTaskModule } from "@posthog/core/cloud-task/cloud-task.module"; import { CLOUD_TASK_AUTH, @@ -87,6 +89,7 @@ import { GIT_PR_STATUS_PROVIDER, type IGitPrStatus, } from "@posthog/host-router/ports/git-pr-status"; +import { CanvasGenService } from "@posthog/host-router/services/canvas-gen.service"; import { ANALYTICS_SERVICE } from "@posthog/platform/analytics"; import { APP_LIFECYCLE_SERVICE } from "@posthog/platform/app-lifecycle"; import { APP_META_SERVICE } from "@posthog/platform/app-meta"; @@ -690,3 +693,11 @@ container.bind(LOGS_SERVICE).toDynamicValue((ctx) => { }; }); container.bind(MAIN_ENCRYPTION_SERVICE).to(EncryptionService); + +// Canvas / dashboards (project-bluebird). The host-agnostic dashboard services +// live in @posthog/core (bound via canvasCoreModule); CanvasGenService is the +// desktop-bound agent surface (a singleton holding per-thread agent state + a +// forwarding loop for app life). Both resolve through ctx.container in the +// host-router routers. +container.load(canvasCoreModule); +container.bind(CANVAS_GEN_SERVICE).to(CanvasGenService).inSingletonScope(); diff --git a/apps/code/src/main/trpc/router.ts b/apps/code/src/main/trpc/router.ts index ee14eb33b..62cd7e9dd 100644 --- a/apps/code/src/main/trpc/router.ts +++ b/apps/code/src/main/trpc/router.ts @@ -3,9 +3,11 @@ import { agentRouter } from "@posthog/host-router/routers/agent.router"; import { analyticsRouter } from "@posthog/host-router/routers/analytics.router"; import { archiveRouter } from "@posthog/host-router/routers/archive.router"; import { authRouter } from "@posthog/host-router/routers/auth.router"; +import { canvasGenRouter } from "@posthog/host-router/routers/canvas-gen.router"; import { cloudTaskRouter } from "@posthog/host-router/routers/cloud-task.router"; import { connectivityRouter } from "@posthog/host-router/routers/connectivity.router"; import { contextMenuRouter } from "@posthog/host-router/routers/context-menu.router"; +import { dashboardsRouter } from "@posthog/host-router/routers/dashboards.router"; import { deepLinkRouter } from "@posthog/host-router/routers/deep-link.router"; import { enrichmentRouter } from "@posthog/host-router/routers/enrichment.router"; import { environmentRouter } from "@posthog/host-router/routers/environment.router"; @@ -47,6 +49,8 @@ export const trpcRouter = router({ analytics: analyticsRouter, archive: archiveRouter, auth: authRouter, + canvasGen: canvasGenRouter, + dashboards: dashboardsRouter, cloudTask: cloudTaskRouter, connectivity: connectivityRouter, contextMenu: contextMenuRouter, diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index c2699f79e..a5a01c842 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -689,6 +689,104 @@ export class PostHogAPIClient { return data; } + // Desktop file system — the backend surface that backs canvas channels + // (top-level folders) and dashboards. These routes aren't in the generated + // OpenAPI client, so we use the raw fetcher. + async getDesktopFileSystem(): Promise { + const DESKTOP_FILE_SYSTEM_MAX_PAGES = 50; + const teamId = await this.getTeamId(); + const all: Schemas.FileSystem[] = []; + let urlPath: string = `/api/projects/${teamId}/desktop_file_system/`; + for (let i = 0; i < DESKTOP_FILE_SYSTEM_MAX_PAGES; i++) { + 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 desktop file system: ${response.statusText}`, + ); + } + const page = (await response.json()) as Schemas.PaginatedFileSystemList; + all.push(...page.results); + if (!page.next) return all; + const nextUrl = new URL(page.next); + urlPath = `${nextUrl.pathname}${nextUrl.search}`; + } + log.warn( + `getDesktopFileSystem hit MAX_PAGES (${DESKTOP_FILE_SYSTEM_MAX_PAGES}); returning partial results`, + { returned: all.length }, + ); + return all; + } + + // Create a top-level channel (a folder row whose path is a single segment). + async createDesktopFileSystemChannel( + name: string, + ): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/desktop_file_system/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: urlPath, + overrides: { + body: JSON.stringify({ path: name, type: "folder", depth: 1 }), + }, + }); + if (!response.ok) { + throw new Error( + `Failed to create desktop file system channel: ${response.statusText}`, + ); + } + return (await response.json()) as Schemas.FileSystem; + } + + // Rename a top-level channel: PATCH its path (a single segment) to the new + // name. The backend recomputes depth from the path. + async renameDesktopFileSystemChannel( + id: string, + name: string, + ): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/desktop_file_system/${encodeURIComponent(id)}/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "patch", + url, + path: urlPath, + overrides: { + body: JSON.stringify({ path: name }), + }, + }); + if (!response.ok) { + throw new Error( + `Failed to rename desktop file system channel: ${response.statusText}`, + ); + } + return (await response.json()) as Schemas.FileSystem; + } + + // Delete a desktop file system entry by id (used to remove top-level channels). + async deleteDesktopFileSystem(id: string): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/desktop_file_system/${encodeURIComponent(id)}/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "delete", + url, + path: urlPath, + }); + if (!response.ok && response.status !== 404) { + throw new Error( + `Failed to delete desktop file system channel: ${response.statusText}`, + ); + } + } + async getGithubLogin(): Promise { const data = (await this.api.get("/api/users/{uuid}/github_login/", { path: { uuid: "@me" }, diff --git a/packages/core/src/canvas/canvas.module.ts b/packages/core/src/canvas/canvas.module.ts new file mode 100644 index 000000000..6704d2e9a --- /dev/null +++ b/packages/core/src/canvas/canvas.module.ts @@ -0,0 +1,15 @@ +import { ContainerModule } from "inversify"; +import { DashboardQueryService } from "./dashboardQueryService"; +import { DashboardsService } from "./dashboardsService"; +import { DASHBOARD_QUERY_SERVICE, DASHBOARDS_SERVICE } from "./identifiers"; + +// Host-agnostic canvas services (dashboards + their HogQL refresh). They only +// need AuthService + fetch, so they live in @posthog/core and any host (desktop, +// web, server) can bind them by loading this module. +export const canvasCoreModule = new ContainerModule(({ bind }) => { + bind(DashboardQueryService).toSelf().inSingletonScope(); + bind(DASHBOARD_QUERY_SERVICE).toService(DashboardQueryService); + + bind(DashboardsService).toSelf().inSingletonScope(); + bind(DASHBOARDS_SERVICE).toService(DashboardsService); +}); diff --git a/packages/core/src/canvas/dashboardQueryService.ts b/packages/core/src/canvas/dashboardQueryService.ts new file mode 100644 index 000000000..c31462e6c --- /dev/null +++ b/packages/core/src/canvas/dashboardQueryService.ts @@ -0,0 +1,126 @@ +import type { AuthService } from "@posthog/core/auth/auth"; +import { AUTH_SERVICE } from "@posthog/core/auth/auth.module"; +import { + ROOT_LOGGER, + type RootLogger, + type ScopedLogger, +} from "@posthog/di/logger"; +import { inject, injectable } from "inversify"; +import type { + DashboardQuery, + DashboardQueryResult, + DashboardQueryRunInput, +} from "./querySchemas"; + +// Run at most this many HogQL queries at once so a wide dashboard doesn't +// hammer the query endpoint. +const CONCURRENCY = 5; + +interface HogQLResponse { + results?: unknown[]; + columns?: string[]; + error?: string | null; +} + +// Executes the HogQL queries stored on a dashboard's data points and returns a +// single scalar value per point. Used by the dashboard refresh flow. +@injectable() +export class DashboardQueryService { + private readonly log: ScopedLogger; + + constructor( + @inject(AUTH_SERVICE) + private readonly authService: AuthService, + @inject(ROOT_LOGGER) + rootLogger: RootLogger, + ) { + this.log = rootLogger.scope("dashboard-query"); + } + + async run(input: DashboardQueryRunInput): Promise { + const { queries } = input; + if (queries.length === 0) return []; + + const { apiHost } = await this.authService.getValidAccessToken(); + const projectId = this.authService.getState().currentProjectId; + if (projectId == null) { + return queries.map((q) => this.fail(q, "No PostHog project selected")); + } + + const url = `${apiHost}/api/projects/${projectId}/query/`; + const results: DashboardQueryResult[] = []; + + // Simple capped batches; preserves input order in the output. + for (let i = 0; i < queries.length; i += CONCURRENCY) { + const batch = queries.slice(i, i + CONCURRENCY); + const settled = await Promise.allSettled( + batch.map((q) => this.runOne(url, q)), + ); + settled.forEach((s, j) => { + results.push( + s.status === "fulfilled" + ? s.value + : this.fail(batch[j], errorMessage(s.reason)), + ); + }); + } + + return results; + } + + private async runOne( + url: string, + q: DashboardQuery, + ): Promise { + const response = await this.authService.authenticatedFetch(fetch, url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query: { kind: "HogQLQuery", query: q.query } }), + }); + + if (!response.ok) { + return this.fail(q, `Query failed (${response.status})`); + } + + const body = (await response.json()) as HogQLResponse; + if (body.error) return this.fail(q, body.error); + + const rows = body.results; + if (!Array.isArray(rows) || rows.length === 0) { + return this.fail(q, "Query returned no rows"); + } + + const firstRow = rows[0]; + if (!Array.isArray(firstRow)) { + return this.fail(q, "Unexpected result shape"); + } + + // Read the named column if given, else the first cell of the first row. + const colIndex = + q.column && body.columns ? body.columns.indexOf(q.column) : 0; + const cell = firstRow[colIndex >= 0 ? colIndex : 0]; + + if (typeof cell === "number" || typeof cell === "string") { + return { + ok: true, + elementKey: q.elementKey, + propPath: q.propPath, + value: cell, + }; + } + return this.fail(q, "Unsupported value type"); + } + + private fail(q: DashboardQuery, error: string): DashboardQueryResult { + this.log.warn("Dashboard query failed", { + elementKey: q.elementKey, + propPath: q.propPath, + error, + }); + return { ok: false, elementKey: q.elementKey, propPath: q.propPath, error }; + } +} + +function errorMessage(reason: unknown): string { + return reason instanceof Error ? reason.message : String(reason); +} diff --git a/packages/core/src/canvas/dashboardSchemas.ts b/packages/core/src/canvas/dashboardSchemas.ts new file mode 100644 index 000000000..a2824b3c1 --- /dev/null +++ b/packages/core/src/canvas/dashboardSchemas.ts @@ -0,0 +1,69 @@ +import { z } from "zod"; + +// A json-render Spec (root + flat element map). Stored verbatim; null = empty. +export const dashboardSpecSchema = z.record(z.string(), z.unknown()).nullable(); + +export const dashboardRecordSchema = z.object({ + id: z.string(), + // The channel (desktop file-system folder) this dashboard belongs to. + // Defaults to "" so dashboards saved before channel scoping still parse; + // they read as orphans and get adopted into the default channel on load. + channelId: z.string().default(""), + name: z.string(), + spec: dashboardSpecSchema, + createdAt: z.number(), + updatedAt: z.number(), +}); +export type DashboardRecord = z.infer; + +// What a dashboard stores in its desktop file-system row's free-form `meta` JSON +// blob. The FileSystem row itself carries id/path/type/created_at; everything +// below is our own payload that the model has no columns for. Documenting the +// shape here keeps the otherwise-untyped `meta` honest. +export const dashboardFileMetaSchema = z.object({ + // The json-render Spec (root + flat element map). null/absent = empty board. + spec: dashboardSpecSchema.optional(), + // The channel folder's stable file-system id. Stored here rather than derived + // from the path so renaming/moving the channel folder can't reparent the board. + channelId: z.string().optional(), + // Epoch ms. createdAt mirrors the row's created_at; updatedAt is ours because + // the FileSystem row has no updated_at column to sort the dashboards list by. + createdAt: z.number().optional(), + updatedAt: z.number().optional(), +}); +export type DashboardFileMeta = z.infer; + +export const dashboardSummarySchema = z.object({ + id: z.string(), + channelId: z.string(), + name: z.string(), + updatedAt: z.number(), + // The full spec is already loaded when listing (it rides in the FS row's + // meta), so include it here to render grid previews without an N+1 of get()s. + spec: dashboardSpecSchema, +}); +export type DashboardSummary = z.infer; + +export const listDashboardsInput = z.object({ channelId: z.string().min(1) }); + +export const createDashboardInput = z.object({ + channelId: z.string().min(1), + name: z.string().min(1), + spec: dashboardSpecSchema, +}); + +export const updateDashboardInput = z.object({ + id: z.string().min(1), + name: z.string().min(1).optional(), + spec: dashboardSpecSchema, +}); + +export const dashboardIdInput = z.object({ id: z.string().min(1) }); + +export const refreshDashboardInput = z.object({ + id: z.string().min(1), + // Limit the refresh to these elements' subtrees (per-card refresh). + elementKeys: z.array(z.string()).optional(), + // Skip bumping updatedAt (e.g. for background polling) to avoid reordering. + touchUpdatedAt: z.boolean().optional(), +}); diff --git a/packages/core/src/canvas/dashboardsService.ts b/packages/core/src/canvas/dashboardsService.ts new file mode 100644 index 000000000..4b5d28d03 --- /dev/null +++ b/packages/core/src/canvas/dashboardsService.ts @@ -0,0 +1,351 @@ +import type { AuthService } from "@posthog/core/auth/auth"; +import { AUTH_SERVICE } from "@posthog/core/auth/auth.module"; +import { inject, injectable } from "inversify"; +import type { DashboardQueryService } from "./dashboardQueryService"; +import type { + DashboardFileMeta, + DashboardRecord, + DashboardSummary, +} from "./dashboardSchemas"; +import { DASHBOARD_QUERY_SERVICE } from "./identifiers"; +import type { DashboardQuery } from "./querySchemas"; + +// Desktop file-system "type" tag for a dashboard entry. Channels are `folder` +// rows (depth 1); dashboards are these `dashboard` files nested beneath them. +const DASHBOARD_TYPE = "dashboard"; +const MAX_PAGES = 50; + +// The slice of a desktop file-system row we read back. Our payload rides in +// `meta` — see DashboardFileMeta for what that blob holds. +interface FsEntry { + id: string; + path: string; + type?: string; + meta?: DashboardFileMeta | null; + created_at?: string; +} + +/** + * Dashboards backed by the PostHog desktop file system (not local files), so a + * dashboard is a `dashboard`-typed row nested under its channel folder and its + * name is the last path segment — i.e. the canvas h1. The json-render spec lives + * in the row's `meta.spec`. This keeps dashboards (and their names) in sync with + * the backend, the same surface that owns channel names. + */ +@injectable() +export class DashboardsService { + constructor( + @inject(AUTH_SERVICE) + private readonly authService: AuthService, + @inject(DASHBOARD_QUERY_SERVICE) + private readonly dashboardQuery: DashboardQueryService, + ) {} + + // Raw fetch against this project's desktop_file_system surface. `suffix` is + // appended after `.../desktop_file_system/` (e.g. `/` or a `?offset=` page). + private async fsFetch(suffix: string, init?: RequestInit): Promise { + const { apiHost } = await this.authService.getValidAccessToken(); + const projectId = this.authService.getState().currentProjectId; + if (projectId == null) throw new Error("No PostHog project selected"); + const url = `${apiHost}/api/projects/${projectId}/desktop_file_system/${suffix}`; + return this.authService.authenticatedFetch(fetch, url, init); + } + + private async listAll(): Promise { + const all: FsEntry[] = []; + let suffix = ""; + for (let i = 0; i < MAX_PAGES; i++) { + const res = await this.fsFetch(suffix); + if (!res.ok) throw new Error(`Failed to list dashboards (${res.status})`); + const page = (await res.json()) as { + next: string | null; + results: FsEntry[]; + }; + all.push(...page.results); + if (!page.next) return all; + suffix = new URL(page.next).search; // carries the pagination offset + } + return all; + } + + private async getEntry(id: string): Promise { + const res = await this.fsFetch(`${encodeURIComponent(id)}/`); + if (res.status === 404) return null; + if (!res.ok) throw new Error(`Failed to load dashboard (${res.status})`); + return (await res.json()) as FsEntry; + } + + async list(channelId: string): Promise { + const entries = await this.listAll(); + return entries + .filter( + (e) => e.type === DASHBOARD_TYPE && e.meta?.channelId === channelId, + ) + .map((e) => toRecord(e)) + .sort((a, b) => b.updatedAt - a.updatedAt) + .map(({ id, channelId: cid, name, updatedAt, spec }) => ({ + id, + channelId: cid, + name, + updatedAt, + spec, + })); + } + + async get(id: string): Promise { + const entry = await this.getEntry(id); + return entry ? toRecord(entry) : null; + } + + async create(input: { + channelId: string; + name: string; + spec: Record | null; + }): Promise { + const channelPath = await this.channelPath(input.channelId); + const now = Date.now(); + const meta: DashboardFileMeta = { + spec: input.spec, + channelId: input.channelId, + createdAt: now, + updatedAt: now, + }; + const res = await this.fsFetch("", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: `${channelPath}/${sanitizeSegment(input.name)}`, + type: DASHBOARD_TYPE, + meta, + }), + }); + if (!res.ok) throw new Error(`Failed to create dashboard (${res.status})`); + return toRecord((await res.json()) as FsEntry); + } + + async update(input: { + id: string; + name?: string; + spec: Record | null; + }): Promise { + const entry = await this.getEntry(input.id); + const now = Date.now(); + const prevMeta = entry?.meta ?? {}; + const meta: DashboardFileMeta = { + ...prevMeta, + spec: input.spec, + updatedAt: now, + createdAt: prevMeta.createdAt ?? toEpoch(entry?.created_at), + }; + + const body: Record = { meta }; + // A new name renames the file: keep it under the same parent folder so the + // canvas h1 stays the dashboard's name on the backend too. + if (input.name && entry) { + const parent = parentPath(entry.path); + const next = sanitizeSegment(input.name); + const newPath = parent ? `${parent}/${next}` : next; + if (newPath !== entry.path) body.path = newPath; + } + + const res = await this.fsFetch(`${encodeURIComponent(input.id)}/`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`Failed to save dashboard (${res.status})`); + return toRecord((await res.json()) as FsEntry); + } + + async delete(id: string): Promise { + const res = await this.fsFetch(`${encodeURIComponent(id)}/`, { + method: "DELETE", + }); + // Already gone is a successful delete; surface anything else. + if (!res.ok && res.status !== 404) { + throw new Error(`Failed to delete dashboard (${res.status})`); + } + } + + // Re-run the HogQL queries stored at spec.state.queries and write the fresh + // values back into the spec props. `elementKeys` (a card's element) limits the + // refresh to that card's subtree. Failures keep their prior literal. + async refresh(input: { + id: string; + elementKeys?: string[]; + touchUpdatedAt?: boolean; + }): Promise<{ + updated: number; + failures: { elementKey: string; error: string }[]; + }> { + const entry = await this.getEntry(input.id); + const spec = entry?.meta?.spec; + if (!entry || !spec) return { updated: 0, failures: [] }; + + const queries = collectQueries(spec, input.elementKeys); + if (queries.length === 0) return { updated: 0, failures: [] }; + + const results = await this.dashboardQuery.run({ queries }); + + let nextSpec = spec; + let updated = 0; + const failures: { elementKey: string; error: string }[] = []; + for (const r of results) { + if (r.ok) { + const patched = patchProp(nextSpec, r.elementKey, r.propPath, r.value); + if (patched !== nextSpec) { + nextSpec = patched; + updated++; + } + } else { + failures.push({ elementKey: r.elementKey, error: r.error }); + } + } + + // Only write when a value actually changed (the `updated > 0` guard already + // skips no-op polls). This is still last-write-wins on `meta.spec`: a polling + // refresh and a concurrent edit on another client can clobber each other. The + // desktop FS rows carry no `base_version` for `meta` (unlike folder + // instructions), so true optimistic concurrency is deferred — for now refresh + // is UI-gated to view mode, which avoids self-clobber within one client. + if (updated > 0) { + const prevMeta = entry.meta ?? {}; + const meta: DashboardFileMeta = { + ...prevMeta, + spec: nextSpec, + updatedAt: + input.touchUpdatedAt === false + ? (prevMeta.updatedAt ?? toEpoch(entry.created_at)) + : Date.now(), + }; + await this.fsFetch(`${encodeURIComponent(input.id)}/`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ meta }), + }); + } + return { updated, failures }; + } + + // Resolve a channel's folder path from its file-system id so child dashboards + // can be created beneath it (paths are name-based, ids are not). + private async channelPath(channelId: string): Promise { + const entry = await this.getEntry(channelId); + if (!entry) throw new Error("Channel not found"); + return entry.path; + } +} + +// Build the renderer-facing record from a file-system row. The name is the last +// path segment (the canvas h1); spec + timestamps ride in `meta`. +function toRecord(entry: FsEntry): DashboardRecord { + const meta = entry.meta ?? {}; + const createdAt = meta.createdAt ?? toEpoch(entry.created_at); + return { + id: entry.id, + channelId: meta.channelId ?? "", + name: lastSegment(entry.path), + spec: meta.spec ?? null, + createdAt, + updatedAt: meta.updatedAt ?? createdAt, + }; +} + +// Path segments are "/"-separated on the backend, so a name can't contain one. +function sanitizeSegment(name: string): string { + const cleaned = name.replace(/\//g, " ").replace(/\s+/g, " ").trim(); + return cleaned || "Untitled dashboard"; +} + +function parentPath(path: string): string { + const i = path.lastIndexOf("/"); + return i === -1 ? "" : path.slice(0, i); +} + +function lastSegment(path: string): string { + const i = path.lastIndexOf("/"); + return i === -1 ? path : path.slice(i + 1); +} + +function toEpoch(iso?: string): number { + if (!iso) return Date.now(); + const t = Date.parse(iso); + return Number.isNaN(t) ? Date.now() : t; +} + +type SpecElements = Record; +type StoredQuery = { query?: unknown; column?: unknown }; + +// Collect refreshable queries from spec.state.queries, optionally limited to the +// subtree(s) of `elementKeys` and skipping queries whose element no longer exists. +function collectQueries( + spec: Record, + elementKeys?: string[], +): DashboardQuery[] { + const state = spec.state as Record | undefined; + const queriesMap = state?.queries as + | Record> + | undefined; + if (!queriesMap) return []; + + const elements = spec.elements as SpecElements | undefined; + const allowed = + elementKeys && elements ? descendantKeys(elements, elementKeys) : null; + + const out: DashboardQuery[] = []; + for (const [elementKey, props] of Object.entries(queriesMap)) { + if (allowed && !allowed.has(elementKey)) continue; + if (elements && !elements[elementKey]) continue; // stale key + for (const [propPath, stored] of Object.entries(props)) { + if (stored && typeof stored.query === "string") { + out.push({ + elementKey, + propPath, + query: stored.query, + column: typeof stored.column === "string" ? stored.column : undefined, + }); + } + } + } + return out; +} + +// Keys reachable from any of `roots` via `children` (inclusive of the roots). +function descendantKeys(elements: SpecElements, roots: string[]): Set { + const seen = new Set(); + const stack = [...roots]; + while (stack.length > 0) { + const key = stack.pop(); + if (!key || seen.has(key)) continue; + seen.add(key); + const children = elements[key]?.children; + if (children) stack.push(...children); + } + return seen; +} + +// Immutably set spec.elements[elementKey].props[]; no-op (same ref) +// when the element is absent. +function patchProp( + spec: Record, + elementKey: string, + propPath: string, + value: string | number, +): Record { + const elements = spec.elements as + | Record }> + | undefined; + const el = elements?.[elementKey]; + if (!elements || !el) return spec; + const propName = propPath.replace(/^\//, ""); + return { + ...spec, + elements: { + ...elements, + [elementKey]: { + ...el, + props: { ...(el.props ?? {}), [propName]: value }, + }, + }, + }; +} diff --git a/packages/core/src/canvas/genSchemas.ts b/packages/core/src/canvas/genSchemas.ts new file mode 100644 index 000000000..4e630cc78 --- /dev/null +++ b/packages/core/src/canvas/genSchemas.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; + +// Input for generating / extending a canvas from a chat prompt. +export const canvasGenerateInput = z.object({ + threadId: z.string().min(1), + prompt: z.string().min(1), + /** + * The json-render system prompt describing the component catalog. Computed in + * the renderer from the shared catalog and applied once when the ephemeral + * agent session for this thread is created. + */ + systemPrompt: z.string().min(1), + model: z.string().optional(), +}); +export type CanvasGenerateInput = z.infer; + +export const canvasThreadInput = z.object({ threadId: z.string().min(1) }); +export type CanvasThreadInput = z.infer; + +// Events streamed to the renderer as the agent responds. `spec` carries the +// full assembled json-render Spec snapshot after each applied JSONL patch. +export const canvasStreamEventSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("started") }), + z.object({ type: z.literal("prose"), text: z.string() }), + z.object({ + type: z.literal("spec"), + spec: z.record(z.string(), z.unknown()), + }), + z.object({ + type: z.literal("tool"), + toolName: z.string(), + status: z.string(), + }), + z.object({ type: z.literal("done") }), + z.object({ type: z.literal("error"), message: z.string() }), +]); +export type CanvasStreamEvent = z.infer; + +export const CanvasGenEvent = { Event: "canvas-event" } as const; + +export interface CanvasGenEventPayload { + threadId: string; + event: CanvasStreamEvent; +} + +export interface CanvasGenEvents { + [CanvasGenEvent.Event]: CanvasGenEventPayload; +} diff --git a/packages/core/src/canvas/identifiers.ts b/packages/core/src/canvas/identifiers.ts new file mode 100644 index 000000000..c702d3d95 --- /dev/null +++ b/packages/core/src/canvas/identifiers.ts @@ -0,0 +1,11 @@ +// DI tokens for the canvas/dashboards services. They live in @posthog/core so +// both the host-router routers and the host DI container can reference them +// without depending on the desktop app's main process (where the concrete +// service classes are bound). +export const CANVAS_GEN_SERVICE = Symbol.for("posthog.core.canvas.genService"); +export const DASHBOARDS_SERVICE = Symbol.for( + "posthog.core.canvas.dashboardsService", +); +export const DASHBOARD_QUERY_SERVICE = Symbol.for( + "posthog.core.canvas.dashboardQueryService", +); diff --git a/packages/core/src/canvas/querySchemas.ts b/packages/core/src/canvas/querySchemas.ts new file mode 100644 index 000000000..4744d8e85 --- /dev/null +++ b/packages/core/src/canvas/querySchemas.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; + +// A single data point to refresh: the element + prop it feeds, and the HogQL +// that produces its value. `column` optionally names a result column to read +// instead of the first one. +export const dashboardQueryInput = z.object({ + elementKey: z.string().min(1), + propPath: z.string().min(1), + query: z.string().min(1), + column: z.string().optional(), +}); +export type DashboardQuery = z.infer; + +export const dashboardQueryRunInput = z.object({ + queries: z.array(dashboardQueryInput), +}); +export type DashboardQueryRunInput = z.infer; + +// Per-point result. Success/failure is encoded (not thrown) so one bad query +// never fails the batch. +export const dashboardQueryResultSchema = z.discriminatedUnion("ok", [ + z.object({ + ok: z.literal(true), + elementKey: z.string(), + propPath: z.string(), + value: z.union([z.string(), z.number()]), + }), + z.object({ + ok: z.literal(false), + elementKey: z.string(), + propPath: z.string(), + error: z.string(), + }), +]); +export type DashboardQueryResult = z.infer; diff --git a/packages/core/src/canvas/services.ts b/packages/core/src/canvas/services.ts new file mode 100644 index 000000000..2d303db58 --- /dev/null +++ b/packages/core/src/canvas/services.ts @@ -0,0 +1,52 @@ +import type { DashboardRecord, DashboardSummary } from "./dashboardSchemas"; +import type { + CanvasGenEventPayload, + CanvasGenerateInput, + CanvasThreadInput, +} from "./genSchemas"; +import type { + DashboardQueryResult, + DashboardQueryRunInput, +} from "./querySchemas"; + +// Structural service interfaces the host-router routers depend on. The concrete +// implementations live in the desktop app's main process and are bound to the +// tokens in identifiers.ts; the router only needs the method surface. + +export interface ICanvasGenService { + generate(input: CanvasGenerateInput): Promise; + reset(input: CanvasThreadInput): Promise; + /** Async iterable of canvas stream events (for the onEvent subscription). */ + toIterable( + event: "canvas-event", + opts?: { signal?: AbortSignal }, + ): AsyncIterable; +} + +export interface IDashboardsService { + list(channelId: string): Promise; + get(id: string): Promise; + create(input: { + channelId: string; + name: string; + spec: Record | null; + }): Promise; + update(input: { + id: string; + name?: string; + spec: Record | null; + }): Promise; + delete(id: string): Promise; + refresh(input: { + id: string; + elementKeys?: string[]; + touchUpdatedAt?: boolean; + }): Promise<{ + updated: number; + failures: { elementKey: string; error: string }[]; + }>; +} + +export interface IDashboardQueryService { + run(input: DashboardQueryRunInput): Promise; +} diff --git a/packages/host-router/package.json b/packages/host-router/package.json index 6e82ae551..d00eca634 100644 --- a/packages/host-router/package.json +++ b/packages/host-router/package.json @@ -15,9 +15,13 @@ "clean": "node ../../scripts/rimraf.mjs .turbo" }, "dependencies": { + "@agentclientprotocol/sdk": "0.22.1", + "@json-render/core": "^0.19.0", "@posthog/core": "workspace:*", + "@posthog/di": "workspace:*", "@posthog/host-trpc": "workspace:*", "@posthog/platform": "workspace:*", + "@posthog/shared": "workspace:*", "@posthog/workspace-client": "workspace:*", "@posthog/workspace-server": "workspace:*", "@trpc/client": "catalog:" diff --git a/packages/host-router/src/router.ts b/packages/host-router/src/router.ts index 89929364e..d03171118 100644 --- a/packages/host-router/src/router.ts +++ b/packages/host-router/src/router.ts @@ -4,9 +4,11 @@ import { agentRouter } from "./routers/agent.router"; import { analyticsRouter } from "./routers/analytics.router"; import { archiveRouter } from "./routers/archive.router"; import { authRouter } from "./routers/auth.router"; +import { canvasGenRouter } from "./routers/canvas-gen.router"; import { cloudTaskRouter } from "./routers/cloud-task.router"; import { connectivityRouter } from "./routers/connectivity.router"; import { contextMenuRouter } from "./routers/context-menu.router"; +import { dashboardsRouter } from "./routers/dashboards.router"; import { deepLinkRouter } from "./routers/deep-link.router"; import { enrichmentRouter } from "./routers/enrichment.router"; import { environmentRouter } from "./routers/environment.router"; @@ -46,9 +48,11 @@ export const hostRouter = router({ analytics: analyticsRouter, archive: archiveRouter, auth: authRouter, + canvasGen: canvasGenRouter, cloudTask: cloudTaskRouter, connectivity: connectivityRouter, contextMenu: contextMenuRouter, + dashboards: dashboardsRouter, deepLink: deepLinkRouter, enrichment: enrichmentRouter, environment: environmentRouter, diff --git a/packages/host-router/src/routers/canvas-gen.router.ts b/packages/host-router/src/routers/canvas-gen.router.ts new file mode 100644 index 000000000..6b2a134fa --- /dev/null +++ b/packages/host-router/src/routers/canvas-gen.router.ts @@ -0,0 +1,35 @@ +import { + CanvasGenEvent, + canvasGenerateInput, + canvasThreadInput, +} from "@posthog/core/canvas/genSchemas"; +import { CANVAS_GEN_SERVICE } from "@posthog/core/canvas/identifiers"; +import type { ICanvasGenService } from "@posthog/core/canvas/services"; +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; + +export const canvasGenRouter = router({ + generate: publicProcedure + .input(canvasGenerateInput) + .mutation(({ ctx, input }) => + ctx.container.get(CANVAS_GEN_SERVICE).generate(input), + ), + reset: publicProcedure + .input(canvasThreadInput) + .mutation(({ ctx, input }) => + ctx.container.get(CANVAS_GEN_SERVICE).reset(input), + ), + onEvent: publicProcedure + .input(canvasThreadInput) + .subscription(async function* (opts) { + const service = + opts.ctx.container.get(CANVAS_GEN_SERVICE); + const iterable = service.toIterable(CanvasGenEvent.Event, { + signal: opts.signal, + }); + for await (const payload of iterable) { + if (payload.threadId === opts.input.threadId) { + yield payload.event; + } + } + }), +}); diff --git a/packages/host-router/src/routers/dashboards.router.ts b/packages/host-router/src/routers/dashboards.router.ts new file mode 100644 index 000000000..c3fd70eff --- /dev/null +++ b/packages/host-router/src/routers/dashboards.router.ts @@ -0,0 +1,54 @@ +import { + createDashboardInput, + dashboardIdInput, + dashboardRecordSchema, + dashboardSummarySchema, + listDashboardsInput, + refreshDashboardInput, + updateDashboardInput, +} from "@posthog/core/canvas/dashboardSchemas"; +import { DASHBOARDS_SERVICE } from "@posthog/core/canvas/identifiers"; +import type { IDashboardsService } from "@posthog/core/canvas/services"; +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import { z } from "zod"; + +export const dashboardsRouter = router({ + list: publicProcedure + .input(listDashboardsInput) + .output(z.array(dashboardSummarySchema)) + .query(({ ctx, input }) => + ctx.container + .get(DASHBOARDS_SERVICE) + .list(input.channelId), + ), + get: publicProcedure + .input(dashboardIdInput) + .output(dashboardRecordSchema.nullable()) + .query(({ ctx, input }) => + ctx.container.get(DASHBOARDS_SERVICE).get(input.id), + ), + create: publicProcedure + .input(createDashboardInput) + .output(dashboardRecordSchema) + .mutation(({ ctx, input }) => + ctx.container.get(DASHBOARDS_SERVICE).create(input), + ), + update: publicProcedure + .input(updateDashboardInput) + .output(dashboardRecordSchema) + .mutation(({ ctx, input }) => + ctx.container.get(DASHBOARDS_SERVICE).update(input), + ), + delete: publicProcedure + .input(dashboardIdInput) + .mutation(({ ctx, input }) => + ctx.container + .get(DASHBOARDS_SERVICE) + .delete(input.id), + ), + refresh: publicProcedure + .input(refreshDashboardInput) + .mutation(({ ctx, input }) => + ctx.container.get(DASHBOARDS_SERVICE).refresh(input), + ), +}); diff --git a/packages/host-router/src/services/canvas-gen.service.ts b/packages/host-router/src/services/canvas-gen.service.ts new file mode 100644 index 000000000..27c313ec9 --- /dev/null +++ b/packages/host-router/src/services/canvas-gen.service.ts @@ -0,0 +1,239 @@ +import { tmpdir } from "node:os"; +import type { ContentBlock } from "@agentclientprotocol/sdk"; +import { + applySpecStreamPatch, + createMixedStreamParser, + type MixedStreamParser, +} from "@json-render/core"; +import type { AuthService } from "@posthog/core/auth/auth"; +import { AUTH_SERVICE } from "@posthog/core/auth/auth.module"; +import { + CanvasGenEvent, + type CanvasGenEvents, + type CanvasGenerateInput, + type CanvasStreamEvent, + type CanvasThreadInput, +} from "@posthog/core/canvas/genSchemas"; +import { + ROOT_LOGGER, + type RootLogger, + type ScopedLogger, +} from "@posthog/di/logger"; +import { type AcpMessage, TypedEventEmitter } from "@posthog/shared"; +import type { AgentService } from "@posthog/workspace-server/services/agent/agent"; +import { AGENT_SERVICE } from "@posthog/workspace-server/services/agent/identifiers"; +import { + AgentServiceEvent, + type AgentSessionEventPayload, +} from "@posthog/workspace-server/services/agent/schemas"; +import { inject, injectable } from "inversify"; + +const TASK_RUN_PREFIX = "canvas:"; + +// File-writing, shell, and network tools the canvas agent must never use. It +// builds dashboards from PostHog MCP data only; everything else is denied so the +// turn can't write files, run commands, or fetch arbitrary URLs. +const CANVAS_DISALLOWED_TOOLS = [ + "Bash", + "Write", + "Edit", + "MultiEdit", + "NotebookEdit", + "WebFetch", + "WebSearch", +]; + +interface ThreadState { + /** The json-render Spec assembled from streamed JSONL patches. */ + spec: Record; + /** Splits the agent's mixed prose + JSONL stream into text and patches. */ + parser: MixedStreamParser; +} + +/** + * Drives an ephemeral PostHog agent turn for the canvas generation surface. + * + * Reuses {@link AgentService} (which auto-enables the PostHog MCP server) to run + * a `__preview__` session per thread with a json-render system prompt, then + * forwards the agent's ACP session updates — splitting prose from json-render + * JSONL patches and assembling the Spec — as typed events for the renderer. + */ +@injectable() +export class CanvasGenService extends TypedEventEmitter { + private readonly threads = new Map(); + private readonly startedSessions = new Set(); + private forwarding = false; + + private readonly log: ScopedLogger; + + constructor( + @inject(AGENT_SERVICE) + private readonly agentService: AgentService, + @inject(AUTH_SERVICE) + private readonly authService: AuthService, + @inject(ROOT_LOGGER) + rootLogger: RootLogger, + ) { + super(); + this.log = rootLogger.scope("canvas-gen"); + } + + async generate(input: CanvasGenerateInput): Promise { + const { threadId, prompt, systemPrompt, model } = input; + const taskRunId = `${TASK_RUN_PREFIX}${threadId}`; + + this.ensureForwarding(); + + try { + await this.ensureSession(threadId, taskRunId, systemPrompt, model); + } catch (err) { + this.emitEvent(threadId, { + type: "error", + message: err instanceof Error ? err.message : String(err), + }); + return; + } + + this.emitEvent(threadId, { type: "started" }); + + const promptBlocks: ContentBlock[] = [{ type: "text", text: prompt }]; + try { + await this.agentService.prompt(taskRunId, promptBlocks); + this.threads.get(threadId)?.parser.flush(); + this.emitEvent(threadId, { type: "done" }); + } catch (err) { + this.log.warn("Canvas prompt failed", { threadId, err }); + this.emitEvent(threadId, { + type: "error", + message: err instanceof Error ? err.message : String(err), + }); + } + } + + async reset(input: CanvasThreadInput): Promise { + const { threadId } = input; + const taskRunId = `${TASK_RUN_PREFIX}${threadId}`; + this.startedSessions.delete(threadId); + this.threads.delete(threadId); + await this.agentService.cancelSession(taskRunId).catch(() => {}); + } + + private async ensureSession( + threadId: string, + taskRunId: string, + systemPrompt: string, + model?: string, + ): Promise { + if (this.startedSessions.has(threadId)) return; + + const { apiHost } = await this.authService.getValidAccessToken(); + const projectId = this.authService.getState().currentProjectId; + if (projectId == null) { + throw new Error("No PostHog project selected"); + } + + await this.agentService.startSession({ + taskId: "__preview__", + taskRunId, + repoPath: tmpdir(), + apiHost, + projectId, + permissionMode: "bypassPermissions", + systemPromptOverride: systemPrompt, + // The canvas agent only needs PostHog MCP (read) tools. Deny file/shell/ + // network tools so a misbehaving or prompt-injected turn can't write + // files, run commands, or exfiltrate — a hard guard, not just the prompt. + disallowedTools: CANVAS_DISALLOWED_TOOLS, + ...(model ? { model } : {}), + }); + + this.threads.set(threadId, this.createThreadState(threadId)); + this.startedSessions.add(threadId); + } + + private createThreadState(threadId: string): ThreadState { + const state: ThreadState = { + spec: {}, + parser: createMixedStreamParser({ + onText: (text) => { + if (text.trim().length === 0) return; + this.emitEvent(threadId, { type: "prose", text }); + }, + onPatch: (patch) => { + state.spec = applySpecStreamPatch(state.spec, patch); + // Only emit once the spec is renderable: the root must exist AND its + // element must be present. Emitting earlier ships partial/invalid + // snapshots that can crash the renderer mid-stream. + const root = state.spec.root; + const elements = state.spec.elements as + | Record + | undefined; + if (typeof root === "string" && root && elements?.[root]) { + this.emitEvent(threadId, { type: "spec", spec: { ...state.spec } }); + } + }, + }), + }; + return state; + } + + /** Lazily start the single loop forwarding agent session updates for all + * canvas threads. The service is a singleton, so this runs for app lifetime. */ + private ensureForwarding(): void { + if (this.forwarding) return; + this.forwarding = true; + void this.forwardLoop(); + } + + private async forwardLoop(): Promise { + const iterable = this.agentService.toIterable( + AgentServiceEvent.SessionEvent, + ); + for await (const event of iterable as AsyncIterable) { + if (!event.taskRunId.startsWith(TASK_RUN_PREFIX)) continue; + const threadId = event.taskRunId.slice(TASK_RUN_PREFIX.length); + try { + this.handleAcp(threadId, event.payload); + } catch (err) { + this.log.warn("Failed to handle canvas ACP frame", { threadId, err }); + } + } + } + + private handleAcp(threadId: string, payload: unknown): void { + const state = this.threads.get(threadId); + if (!state) return; + + const message = (payload as AcpMessage | undefined)?.message as + | { method?: string; params?: { update?: Record } } + | undefined; + if (!message || message.method !== "session/update") return; + + const update = message.params?.update; + if (!update) return; + + switch (update.sessionUpdate) { + case "agent_message_chunk": { + const content = update.content as { text?: string } | undefined; + if (content?.text) state.parser.push(content.text); + break; + } + case "tool_call": + case "tool_call_update": { + const toolName = + (update.title as string | undefined) ?? + (update.toolCallId as string | undefined) ?? + "tool"; + const status = (update.status as string | undefined) ?? "pending"; + this.emitEvent(threadId, { type: "tool", toolName, status }); + break; + } + default: + break; + } + } + + private emitEvent(threadId: string, event: CanvasStreamEvent): void { + this.emit(CanvasGenEvent.Event, { threadId, event }); + } +} diff --git a/packages/shared/src/flags.ts b/packages/shared/src/flags.ts index a851855be..b42d0979f 100644 --- a/packages/shared/src/flags.ts +++ b/packages/shared/src/flags.ts @@ -4,3 +4,6 @@ export const EXPERIMENT_SUGGESTIONS_FLAG = export const SYNC_CLOUD_TASKS_FLAG = "posthog-code-sync-cloud-tasks"; export const HOME_TAB_FLAG = "posthog-code-home-tab"; export const DISCOVERY_RUN_FLAG = "posthog-code-discovery-run"; +// Gates the entire canvas feature: the app rail's Channels space, the /website +// routes, channels and dashboards. +export const PROJECT_BLUEBIRD_FLAG = "project-bluebird"; diff --git a/packages/ui/package.json b/packages/ui/package.json index 116f86a6e..d97408ae6 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -46,6 +46,8 @@ "@dnd-kit/dom": "^0.1.21", "@dnd-kit/react": "^0.1.21", "@joplin/turndown-plugin-gfm": "^1.0.67", + "@json-render/core": "^0.19.0", + "@json-render/react": "^0.19.0", "@lezer/common": "^1.5.1", "@lezer/highlight": "^1.2.3", "@modelcontextprotocol/ext-apps": "^1.1.2", diff --git a/packages/ui/src/features/canvas/AGENTS.md b/packages/ui/src/features/canvas/AGENTS.md new file mode 100644 index 000000000..9f11dbfad --- /dev/null +++ b/packages/ui/src/features/canvas/AGENTS.md @@ -0,0 +1,55 @@ +# Canvas (Website space) — patterns + +Conventions for the channel-scoped Website space: channels, dashboards, and the +gen-UI canvas. Read this before changing breadcrumbs, dashboard naming, or the +canvas generation harness. The root `AGENTS.md` architecture rules still apply. + +## Spaces & chrome + +- Channels is a **top-level space** reached through the app rail (`AppNav`), + gated behind `project-bluebird` and wired in `routes/__root.tsx`. The rail's + spaces are Code (`/code`), Inbox (`/inbox`), and Channels (`/website`). +- The Channels space has **its own chrome**: rail + a persistent channel-list + sidebar (`ChannelsList`, rendered in `__root`) + the `WebsiteLayout` outlet. It + does NOT use the code `HeaderRow`/`MainSidebar`, so breadcrumbs render in + `WebsiteLayout`'s own top bar (below). + +## Breadcrumbs + +- **`WebsiteLayout` renders its own top bar.** The Channels space has no code + `HeaderRow`, so breadcrumbs (and the dashboard controls) are a local bar inside + `WebsiteLayout`, not pushed through the header store. +- **A page does not get its own crumb — its H1 is the title.** A view that + renders its own `

` is NOT repeated as a breadcrumb segment for itself. The + dashboards grid's h1 is "Dashboards"; a single dashboard's h1 is its name. +- **A parent index IS a crumb when you're on a child, but not when you're on it.** + - On the grid (`/website/$channelId`): trail is `#channel` only — no + "Dashboards" crumb (its own h1 covers it, and `#channel` already links here). + - On a single dashboard (`/website/$channelId/dashboards/$id`): trail is + `#channel / Dashboards`, where `Dashboards` links back to the grid. The + dashboard's name is the h1 below, not a crumb. +- Crumbs reflect navigable parents above the current page; the current page is + the H1, never a crumb of itself. + +## Dashboard naming + +- **The dashboard's H1 is its name.** The canvas harness always emits a top-level + `Heading` (level 1) as the first child of the root `Page` + (see `CANVAS_SYSTEM_PROMPT` in `genui/catalog.ts`). `dashboardTitleFromSpec` + (`genui/dashboardTitle.ts`) reads that H1. +- **Editing the H1 renames the dashboard.** On save, the derived title is passed + as the dashboard `name`; there is no separate name field or rename UI. + +## Storage + +- Dashboards are **backed by the PostHog desktop file system**, not local files. + A dashboard is a `dashboard`-typed row nested under its channel folder; its + name is the last path segment (the H1) and the json-render spec rides in + `meta.spec`. See `@posthog/core/canvas/dashboardsService.ts`; the `meta` payload + is typed + documented as `DashboardFileMeta` in `dashboardSchemas.ts`. This + keeps dashboard and channel names in sync with the backend — the same surface + that owns channels (top-level `folder` rows, see `hooks/useChannels.ts`). +- `meta.spec` is **last-write-wins, unversioned**. A polling refresh and a + concurrent edit elsewhere can clobber each other (no `base_version` on `meta`). + Acceptable for now; revisit with optimistic concurrency / versioning if + multi-client editing becomes real. diff --git a/packages/ui/src/features/canvas/components/AppNav.tsx b/packages/ui/src/features/canvas/components/AppNav.tsx new file mode 100644 index 000000000..7b29903f3 --- /dev/null +++ b/packages/ui/src/features/canvas/components/AppNav.tsx @@ -0,0 +1,67 @@ +import { CodeIcon, HashIcon } from "@phosphor-icons/react"; +import { Button } from "@posthog/quill"; +import { Box, Flex } from "@radix-ui/themes"; +import { useNavigate, useRouterState } from "@tanstack/react-router"; + +type AppNavItem = { + id: "code" | "channels"; + label: string; + icon: typeof CodeIcon; + to: "/code" | "/website"; + isActive: (pathname: string) => boolean; +}; + +// Slack-like app rail switching between top-level "spaces": Code (the existing +// task app) and Channels (the website space with its channel list + dashboards). +// Gated behind project-bluebird in __root. +const NAV_ITEMS: AppNavItem[] = [ + { + id: "code", + label: "Code", + icon: CodeIcon, + to: "/code", + isActive: (pathname) => + pathname === "/code" || pathname.startsWith("/code/"), + }, + { + id: "channels", + label: "Channels", + icon: HashIcon, + to: "/website", + isActive: (pathname) => + pathname === "/website" || pathname.startsWith("/website/"), + }, +]; + +export function AppNav() { + const navigate = useNavigate(); + const pathname = useRouterState({ select: (s) => s.location.pathname }); + + return ( + + {NAV_ITEMS.map((item) => { + const active = item.isActive(pathname); + const Icon = item.icon; + return ( + + + + ); + })} + + ); +} diff --git a/packages/ui/src/features/canvas/components/CanvasChat.tsx b/packages/ui/src/features/canvas/components/CanvasChat.tsx new file mode 100644 index 000000000..313829747 --- /dev/null +++ b/packages/ui/src/features/canvas/components/CanvasChat.tsx @@ -0,0 +1,129 @@ +import { PaperPlaneRightIcon, SpinnerGapIcon } from "@phosphor-icons/react"; +import { Button } from "@posthog/quill"; +import { + useCanvasChatStore, + useCanvasThread, +} from "@posthog/ui/features/canvas/stores/canvasChatStore"; +import { Box, Flex, ScrollArea, Text, TextArea } from "@radix-ui/themes"; +import { useEffect, useRef, useState } from "react"; + +// Chat panel hugging the right of the canvas: a thread plus a composer that +// drives the canvas generation agent. +export function CanvasChat({ threadId }: { threadId: string }) { + const { messages, isStreaming, lastTool, error } = useCanvasThread(threadId); + const send = useCanvasChatStore((s) => s.send); + + const [draft, setDraft] = useState(""); + const threadRef = useRef(null); + + // biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new content + useEffect(() => { + const el = threadRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [messages, lastTool]); + + const submit = () => { + const text = draft.trim(); + if (!text || isStreaming) return; + setDraft(""); + void send(threadId, text); + }; + + return ( + + + + Build with data + + + + + + {messages.length === 0 && ( + + Describe the dashboard or app you want. The agent queries your + PostHog project and builds it live on the canvas. + + )} + {messages.map((message) => ( + + {message.text ? ( + + {message.text} + + ) : ( + message.role === "assistant" && + isStreaming && ( + + Thinking… + + ) + )} + + ))} + {lastTool && ( + + + {lastTool} + + )} + {error && ( + + {error} + + )} + + + + + +