From f9beec2c3325275c8d9afcfb11a1eb7fd0036f8c Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Wed, 10 Jun 2026 11:17:43 +0100 Subject: [PATCH 1/9] feat(canvas): port canvas main services onto refactored package graph Stage 1 of landing feat/canvas on the post-#2442 (package-split) main: - canvas-gen, dashboards, dashboard-query services kept in apps/code/src/main/ services, imports rewired to the new homes (@posthog/core/auth, @posthog/shared for TypedEventEmitter+AcpMessage, @posthog/workspace-server agent service+token). - DI: tokens + strongly-typed MainBindings + container singleton binds. - trpc: canvasGen + dashboards routers registered in the root router. - Re-add @json-render/core dependency (dropped on reset). Extend @posthog/workspace-server AgentService with systemPromptOverride (replaces the default coding prompt for constrained surfaces) and disallowedTools (passed to the Claude SDK), which the canvas generator needs to inject its json-render catalog prompt and sandbox the agent to PostHog-MCP-only. The new shared agent API had dropped both. Code app + workspace-server + agent packages all typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/code/package.json | 1 + apps/code/src/main/di/bindings.ts | 9 + apps/code/src/main/di/container.ts | 18 + apps/code/src/main/di/tokens.ts | 12 + .../src/main/services/canvas-gen/schemas.ts | 48 +++ .../src/main/services/canvas-gen/service.ts | 232 ++++++++++++ .../main/services/dashboard-query/schemas.ts | 35 ++ .../main/services/dashboard-query/service.ts | 118 ++++++ .../src/main/services/dashboards/schemas.ts | 69 ++++ .../src/main/services/dashboards/service.ts | 350 ++++++++++++++++++ apps/code/src/main/trpc/router.ts | 4 + apps/code/src/main/trpc/routers/canvas-gen.ts | 34 ++ apps/code/src/main/trpc/routers/dashboards.ts | 42 +++ .../src/services/agent/agent.ts | 25 ++ .../src/services/agent/schemas.ts | 12 + pnpm-lock.yaml | 12 + 16 files changed, 1021 insertions(+) create mode 100644 apps/code/src/main/services/canvas-gen/schemas.ts create mode 100644 apps/code/src/main/services/canvas-gen/service.ts create mode 100644 apps/code/src/main/services/dashboard-query/schemas.ts create mode 100644 apps/code/src/main/services/dashboard-query/service.ts create mode 100644 apps/code/src/main/services/dashboards/schemas.ts create mode 100644 apps/code/src/main/services/dashboards/service.ts create mode 100644 apps/code/src/main/trpc/routers/canvas-gen.ts create mode 100644 apps/code/src/main/trpc/routers/dashboards.ts 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..7db882832 100644 --- a/apps/code/src/main/di/bindings.ts +++ b/apps/code/src/main/di/bindings.ts @@ -222,6 +222,9 @@ import type { OAuthFlowPortAdapter, TokenCipherPortAdapter, } from "../services/auth/port-adapters"; +import type { CanvasGenService } from "../services/canvas-gen/service"; +import type { DashboardQueryService } from "../services/dashboard-query/service"; +import type { DashboardsService } from "../services/dashboards/service"; import type { DeepLinkService } from "../services/deep-link/service"; import type { EncryptionService } from "../services/encryption/service"; import type { SecureStoreService } from "../services/secure-store/service"; @@ -234,8 +237,11 @@ import type { AUTH_PREFERENCE_REPOSITORY as MAIN_AUTH_PREFERENCE_REPOSITORY, AUTH_SERVICE as MAIN_AUTH_SERVICE, AUTH_SESSION_REPOSITORY as MAIN_AUTH_SESSION_REPOSITORY, + CANVAS_GEN_SERVICE as MAIN_CANVAS_GEN_SERVICE, CLOUD_TASK_SERVICE as MAIN_CLOUD_TASK_SERVICE, CONTEXT_MENU_SERVICE as MAIN_CONTEXT_MENU_SERVICE, + DASHBOARD_QUERY_SERVICE as MAIN_DASHBOARD_QUERY_SERVICE, + DASHBOARDS_SERVICE as MAIN_DASHBOARDS_SERVICE, DATABASE_SERVICE as MAIN_DATABASE_SERVICE, DEEP_LINK_SERVICE as MAIN_DEEP_LINK_SERVICE, DEFAULT_ADDITIONAL_DIRECTORY_REPOSITORY as MAIN_DEFAULT_ADDITIONAL_DIRECTORY_REPOSITORY, @@ -417,6 +423,9 @@ export interface MainBindings { [SECURE_STORE_SERVICE]: ISecureStoreService; [LOGS_SERVICE]: ILogsService; [MAIN_ENCRYPTION_SERVICE]: EncryptionService; + [MAIN_CANVAS_GEN_SERVICE]: CanvasGenService; + [MAIN_DASHBOARDS_SERVICE]: DashboardsService; + [MAIN_DASHBOARD_QUERY_SERVICE]: DashboardQueryService; // 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..50e36dba0 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -229,6 +229,9 @@ import { OAuthFlowPortAdapter, TokenCipherPortAdapter, } from "../services/auth/port-adapters"; +import { CanvasGenService } from "../services/canvas-gen/service"; +import { DashboardQueryService } from "../services/dashboard-query/service"; +import { DashboardsService } from "../services/dashboards/service"; import { DeepLinkService } from "../services/deep-link/service"; import { EncryptionService } from "../services/encryption/service"; import { SecureStoreService } from "../services/secure-store/service"; @@ -690,3 +693,18 @@ container.bind(LOGS_SERVICE).toDynamicValue((ctx) => { }; }); container.bind(MAIN_ENCRYPTION_SERVICE).to(EncryptionService); + +// Canvas / dashboards (project-bluebird). Singletons: CanvasGenService holds +// per-thread agent state + a forwarding loop for app lifetime. +container + .bind(MAIN_TOKENS.DashboardQueryService) + .to(DashboardQueryService) + .inSingletonScope(); +container + .bind(MAIN_TOKENS.DashboardsService) + .to(DashboardsService) + .inSingletonScope(); +container + .bind(MAIN_TOKENS.CanvasGenService) + .to(CanvasGenService) + .inSingletonScope(); diff --git a/apps/code/src/main/di/tokens.ts b/apps/code/src/main/di/tokens.ts index e71f19f83..7e9a42858 100644 --- a/apps/code/src/main/di/tokens.ts +++ b/apps/code/src/main/di/tokens.ts @@ -53,6 +53,15 @@ export const DEFAULT_ADDITIONAL_DIRECTORY_REPOSITORY = Symbol.for( // Services export const AUTH_SERVICE = Symbol.for("posthog.host.main.auth.service"); +export const CANVAS_GEN_SERVICE = Symbol.for( + "posthog.host.main.canvas-gen.service", +); +export const DASHBOARDS_SERVICE = Symbol.for( + "posthog.host.main.dashboards.service", +); +export const DASHBOARD_QUERY_SERVICE = Symbol.for( + "posthog.host.main.dashboard-query.service", +); export const SUSPENSION_SERVICE = Symbol.for( "posthog.host.main.suspension.service", ); @@ -132,6 +141,9 @@ export const MAIN_TOKENS = Object.freeze({ DefaultAdditionalDirectoryRepository: DEFAULT_ADDITIONAL_DIRECTORY_REPOSITORY, AuthService: AUTH_SERVICE, + CanvasGenService: CANVAS_GEN_SERVICE, + DashboardsService: DASHBOARDS_SERVICE, + DashboardQueryService: DASHBOARD_QUERY_SERVICE, SuspensionService: SUSPENSION_SERVICE, AppLifecycleService: APP_LIFECYCLE_SERVICE, CloudTaskService: CLOUD_TASK_SERVICE, diff --git a/apps/code/src/main/services/canvas-gen/schemas.ts b/apps/code/src/main/services/canvas-gen/schemas.ts new file mode 100644 index 000000000..4e630cc78 --- /dev/null +++ b/apps/code/src/main/services/canvas-gen/schemas.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/apps/code/src/main/services/canvas-gen/service.ts b/apps/code/src/main/services/canvas-gen/service.ts new file mode 100644 index 000000000..7e72b53f8 --- /dev/null +++ b/apps/code/src/main/services/canvas-gen/service.ts @@ -0,0 +1,232 @@ +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 { 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"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { logger } from "../../utils/logger"; +import { + CanvasGenEvent, + type CanvasGenEvents, + type CanvasGenerateInput, + type CanvasStreamEvent, + type CanvasThreadInput, +} from "./schemas"; + +const log = logger.scope("canvas-gen"); + +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; + + constructor( + @inject(AGENT_SERVICE) + private readonly agentService: AgentService, + @inject(MAIN_TOKENS.AuthService) + private readonly authService: AuthService, + ) { + super(); + } + + 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) { + 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) { + 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/apps/code/src/main/services/dashboard-query/schemas.ts b/apps/code/src/main/services/dashboard-query/schemas.ts new file mode 100644 index 000000000..4744d8e85 --- /dev/null +++ b/apps/code/src/main/services/dashboard-query/schemas.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/apps/code/src/main/services/dashboard-query/service.ts b/apps/code/src/main/services/dashboard-query/service.ts new file mode 100644 index 000000000..cc74c8ac2 --- /dev/null +++ b/apps/code/src/main/services/dashboard-query/service.ts @@ -0,0 +1,118 @@ +import type { AuthService } from "@posthog/core/auth/auth"; +import { inject, injectable } from "inversify"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { logger } from "../../utils/logger"; +import type { + DashboardQuery, + DashboardQueryResult, + DashboardQueryRunInput, +} from "./schemas"; + +const log = logger.scope("dashboard-query"); + +// 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 { + constructor( + @inject(MAIN_TOKENS.AuthService) + private readonly authService: AuthService, + ) {} + + 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) => 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 + : 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 fail(q, `Query failed (${response.status})`); + } + + const body = (await response.json()) as HogQLResponse; + if (body.error) return fail(q, body.error); + + const rows = body.results; + if (!Array.isArray(rows) || rows.length === 0) { + return fail(q, "Query returned no rows"); + } + + const firstRow = rows[0]; + if (!Array.isArray(firstRow)) { + return 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 fail(q, "Unsupported value type"); + } +} + +function fail(q: DashboardQuery, error: string): DashboardQueryResult { + 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/apps/code/src/main/services/dashboards/schemas.ts b/apps/code/src/main/services/dashboards/schemas.ts new file mode 100644 index 000000000..a2824b3c1 --- /dev/null +++ b/apps/code/src/main/services/dashboards/schemas.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/apps/code/src/main/services/dashboards/service.ts b/apps/code/src/main/services/dashboards/service.ts new file mode 100644 index 000000000..468482218 --- /dev/null +++ b/apps/code/src/main/services/dashboards/service.ts @@ -0,0 +1,350 @@ +import type { AuthService } from "@posthog/core/auth/auth"; +import { inject, injectable } from "inversify"; +import { MAIN_TOKENS } from "../../di/tokens"; +import type { DashboardQuery } from "../dashboard-query/schemas"; +import type { DashboardQueryService } from "../dashboard-query/service"; +import type { + DashboardFileMeta, + DashboardRecord, + DashboardSummary, +} from "./schemas"; + +// 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(MAIN_TOKENS.AuthService) + private readonly authService: AuthService, + @inject(MAIN_TOKENS.DashboardQueryService) + 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/apps/code/src/main/trpc/router.ts b/apps/code/src/main/trpc/router.ts index ee14eb33b..cbe9ec238 100644 --- a/apps/code/src/main/trpc/router.ts +++ b/apps/code/src/main/trpc/router.ts @@ -37,6 +37,8 @@ import { uiRouter } from "@posthog/host-router/routers/ui.router"; import { updatesRouter } from "@posthog/host-router/routers/updates.router"; import { usageMonitorRouter } from "@posthog/host-router/routers/usage-monitor.router"; import { workspaceRouter } from "@posthog/host-router/routers/workspace.router"; +import { canvasGenRouter } from "./routers/canvas-gen"; +import { dashboardsRouter } from "./routers/dashboards"; import { encryptionRouter } from "./routers/encryption"; import { workspaceServerRouter } from "./routers/workspace-server"; import { router } from "./trpc"; @@ -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/apps/code/src/main/trpc/routers/canvas-gen.ts b/apps/code/src/main/trpc/routers/canvas-gen.ts new file mode 100644 index 000000000..aaca2210c --- /dev/null +++ b/apps/code/src/main/trpc/routers/canvas-gen.ts @@ -0,0 +1,34 @@ +import { container } from "../../di/container"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { + CanvasGenEvent, + canvasGenerateInput, + canvasThreadInput, +} from "../../services/canvas-gen/schemas"; +import type { CanvasGenService } from "../../services/canvas-gen/service"; +import { publicProcedure, router } from "../trpc"; + +const getService = () => + container.get(MAIN_TOKENS.CanvasGenService); + +export const canvasGenRouter = router({ + generate: publicProcedure + .input(canvasGenerateInput) + .mutation(({ input }) => getService().generate(input)), + reset: publicProcedure + .input(canvasThreadInput) + .mutation(({ input }) => getService().reset(input)), + onEvent: publicProcedure + .input(canvasThreadInput) + .subscription(async function* (opts) { + const service = getService(); + 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/apps/code/src/main/trpc/routers/dashboards.ts b/apps/code/src/main/trpc/routers/dashboards.ts new file mode 100644 index 000000000..5ca1b11ad --- /dev/null +++ b/apps/code/src/main/trpc/routers/dashboards.ts @@ -0,0 +1,42 @@ +import { z } from "zod"; +import { container } from "../../di/container"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { + createDashboardInput, + dashboardIdInput, + dashboardRecordSchema, + dashboardSummarySchema, + listDashboardsInput, + refreshDashboardInput, + updateDashboardInput, +} from "../../services/dashboards/schemas"; +import type { DashboardsService } from "../../services/dashboards/service"; +import { publicProcedure, router } from "../trpc"; + +const getService = () => + container.get(MAIN_TOKENS.DashboardsService); + +export const dashboardsRouter = router({ + list: publicProcedure + .input(listDashboardsInput) + .output(z.array(dashboardSummarySchema)) + .query(({ input }) => getService().list(input.channelId)), + get: publicProcedure + .input(dashboardIdInput) + .output(dashboardRecordSchema.nullable()) + .query(({ input }) => getService().get(input.id)), + create: publicProcedure + .input(createDashboardInput) + .output(dashboardRecordSchema) + .mutation(({ input }) => getService().create(input)), + update: publicProcedure + .input(updateDashboardInput) + .output(dashboardRecordSchema) + .mutation(({ input }) => getService().update(input)), + delete: publicProcedure + .input(dashboardIdInput) + .mutation(({ input }) => getService().delete(input.id)), + refresh: publicProcedure + .input(refreshDashboardInput) + .mutation(({ input }) => getService().refresh(input)), +}); diff --git a/packages/workspace-server/src/services/agent/agent.ts b/packages/workspace-server/src/services/agent/agent.ts index 064aea451..aff188a94 100644 --- a/packages/workspace-server/src/services/agent/agent.ts +++ b/packages/workspace-server/src/services/agent/agent.ts @@ -225,12 +225,16 @@ function buildClaudeCodeOptions(args: { additionalDirectories?: string[]; effort?: EffortLevel; plugins: { type: "local"; path: string }[]; + disallowedTools?: string[]; }) { return { ...(args.additionalDirectories?.length && { additionalDirectories: args.additionalDirectories, }), ...(args.effort && { effort: args.effort }), + ...(args.disallowedTools?.length && { + disallowedTools: args.disallowedTools, + }), plugins: args.plugins, }; } @@ -248,6 +252,10 @@ interface SessionConfig { permissionMode?: string; /** Custom instructions injected into the system prompt */ customInstructions?: string; + /** Replaces the PostHog system prompt entirely (constrained surfaces). */ + systemPromptOverride?: string; + /** Tool names denied for this session (passed to the Claude SDK). */ + disallowedTools?: string[]; /** Effort level for Claude sessions */ effort?: EffortLevel; /** Model to use for the session (e.g. "claude-sonnet-4-6") */ @@ -511,9 +519,16 @@ export class AgentService extends TypedEventEmitter { taskId: string, customInstructions?: string, additionalDirectories?: string[], + systemPromptOverride?: string, ): { append: string; } { + // A constrained surface (e.g. the canvas generator) supplies its own prompt + // and does NOT want the default coding/attribution guidance. + if (systemPromptOverride) { + return { append: systemPromptOverride }; + } + let prompt = `PostHog context: use project ${credentials.projectId} on ${credentials.apiHost}. When using PostHog MCP tools, operate only on this project.`; prompt += ` @@ -599,6 +614,8 @@ When creating pull requests, add the following footer at the end of the PR descr adapter, permissionMode, customInstructions, + systemPromptOverride, + disallowedTools, effort, model, jsonSchema, @@ -662,6 +679,7 @@ When creating pull requests, add the following footer at the end of the PR descr taskId, customInstructions, additionalDirectories, + systemPromptOverride, ); const acpConnection = await agent.run(taskId, taskRunId, { @@ -768,6 +786,7 @@ When creating pull requests, add the following footer at the end of the PR descr additionalDirectories, effort, plugins, + disallowedTools, }); let configOptions: SessionConfigOption[] | undefined; @@ -1595,6 +1614,12 @@ For git operations while detached: "permissionMode" in params ? params.permissionMode : undefined, customInstructions: "customInstructions" in params ? params.customInstructions : undefined, + systemPromptOverride: + "systemPromptOverride" in params + ? params.systemPromptOverride + : undefined, + disallowedTools: + "disallowedTools" in params ? params.disallowedTools : undefined, effort: "effort" in params ? params.effort : undefined, model: "model" in params ? params.model : undefined, jsonSchema: "jsonSchema" in params ? params.jsonSchema : undefined, diff --git a/packages/workspace-server/src/services/agent/schemas.ts b/packages/workspace-server/src/services/agent/schemas.ts index d070f167f..e4fd54989 100644 --- a/packages/workspace-server/src/services/agent/schemas.ts +++ b/packages/workspace-server/src/services/agent/schemas.ts @@ -48,6 +48,18 @@ export const startSessionInput = z.object({ adapter: z.enum(["claude", "codex"]).optional(), additionalDirectories: z.array(z.string()).optional(), customInstructions: z.string().max(2000).optional(), + /** + * Replaces the PostHog system prompt entirely for this session. Used by + * constrained, single-purpose surfaces (e.g. the canvas generator) that drive + * the agent with their own prompt rather than the default coding prompt. + * Uncapped, unlike `customInstructions`. + */ + systemPromptOverride: z.string().optional(), + /** + * Tool names the agent must not use this session (passed to the Claude SDK). + * Lets a sandboxed surface deny file/shell/network tools. + */ + disallowedTools: z.array(z.string()).optional(), effort: effortLevelSchema.optional(), model: z.string().optional(), jsonSchema: z.record(z.string(), z.unknown()).nullish(), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69c8c1ac0..393f4c0d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -127,6 +127,9 @@ importers: '@inversifyjs/strongly-typed': specifier: 2.2.0 version: 2.2.0(inversify@7.11.0(reflect-metadata@0.2.2)) + '@json-render/core': + specifier: ^0.19.0 + version: 0.19.0(zod@4.3.6) '@modelcontextprotocol/sdk': specifier: ^1.12.1 version: 1.27.1(zod@4.3.6) @@ -3632,6 +3635,11 @@ packages: '@jsdevtools/ono@7.1.3': resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} + '@json-render/core@0.19.0': + resolution: {integrity: sha512-vvcyZ+10EDZKbEyB1J2kXOGfDaiZR2LurZGSqi2r5STHyKr+Te85DWaBxTwRGgM7U1LtIvNx85BzzjElRKoAIg==} + peerDependencies: + zod: 4.3.6 + '@jsonjoy.com/base64@1.1.2': resolution: {integrity: sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==} engines: {node: '>=10.0'} @@ -16118,6 +16126,10 @@ snapshots: '@jsdevtools/ono@7.1.3': {} + '@json-render/core@0.19.0(zod@4.3.6)': + dependencies: + zod: 4.3.6 + '@jsonjoy.com/base64@1.1.2(tslib@2.8.1)': dependencies: tslib: 2.8.1 From af4012c7ccc438e0d8d9a9228f2d5e36ec2d5939 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Wed, 10 Jun 2026 11:44:01 +0100 Subject: [PATCH 2/9] feat(canvas): port renderer + routes + tRPC onto the refactored package graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2-3 of landing feat/canvas on post-#2442 main. Everything typechecks across all packages (22/22 turbo tasks). Renderer (→ packages/ui/src/features/canvas): - 27 files relocated; imports rewired to @posthog/ui/* + @posthog/* aliases. - Stores/subscriptions get the host tRPC client via resolveService(HOST_TRPC_CLIENT) (non-React DI accessor); hooks use useHostTRPC. - Website routes → packages/ui/src/router/routes/website*, route tree regenerated. tRPC visibility fix (the renderer types against HostRouter): - Canvas schemas + DI tokens + service interfaces → @posthog/core/canvas. - canvas-gen + dashboards routers → packages/host-router, registered in hostRouter. - Service classes stay in apps/code/main, bound to the core tokens in the container. Channels API: add desktop_file_system methods (get/create/rename/delete) to @posthog/api-client's PostHogAPIClient (the routes aren't in the generated OpenAPI). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/code/src/main/di/bindings.ts | 14 +- apps/code/src/main/di/container.ts | 19 +- apps/code/src/main/di/tokens.ts | 12 - .../src/main/services/canvas-gen/schemas.ts | 50 +-- .../main/services/dashboard-query/schemas.ts | 37 +- .../src/main/services/dashboards/schemas.ts | 72 +--- .../src/main/services/dashboards/service.ts | 3 +- apps/code/src/main/trpc/router.ts | 4 +- apps/code/src/main/trpc/routers/dashboards.ts | 42 --- packages/api-client/src/posthog-client.ts | 98 ++++++ packages/core/src/canvas/dashboardSchemas.ts | 69 ++++ packages/core/src/canvas/genSchemas.ts | 48 +++ packages/core/src/canvas/identifiers.ts | 11 + packages/core/src/canvas/querySchemas.ts | 35 ++ packages/core/src/canvas/services.ts | 52 +++ packages/host-router/src/router.ts | 4 + .../src/routers/canvas-gen.router.ts | 23 +- .../src/routers/dashboards.router.ts | 54 +++ packages/ui/package.json | 2 + packages/ui/src/features/canvas/AGENTS.md | 55 +++ .../features/canvas/components/CanvasChat.tsx | 129 +++++++ .../canvas/components/ChannelsList.tsx | 308 ++++++++++++++++ .../canvas/components/CreateChannelModal.tsx | 125 +++++++ .../components/DashboardRefreshControl.tsx | 112 ++++++ .../canvas/components/RenameChannelModal.tsx | 119 +++++++ .../canvas/components/WebsiteCanvas.tsx | 65 ++++ .../components/WebsiteChannelsIndex.tsx | 51 +++ .../canvas/components/WebsiteDashboard.tsx | 60 ++++ .../components/WebsiteDashboardsIndex.tsx | 195 +++++++++++ .../canvas/components/WebsiteLayout.tsx | 329 ++++++++++++++++++ .../canvas/components/WebsiteNewTask.tsx | 31 ++ .../canvas/components/WebsiteSettings.tsx | 25 ++ .../features/canvas/genui/EditRenderer.tsx | 188 ++++++++++ .../features/canvas/genui/ViewRenderer.tsx | 103 ++++++ .../ui/src/features/canvas/genui/bodies.tsx | 279 +++++++++++++++ .../ui/src/features/canvas/genui/catalog.ts | 87 +++++ .../features/canvas/genui/dashboardTitle.ts | 32 ++ .../ui/src/features/canvas/genui/editable.ts | 28 ++ .../ui/src/features/canvas/genui/registry.tsx | 48 +++ .../src/features/canvas/hooks/useChannels.ts | 88 +++++ .../features/canvas/hooks/useDashboards.ts | 114 ++++++ .../canvas/hooks/useRefreshDashboard.ts | 54 +++ packages/ui/src/features/canvas/hostClient.ts | 13 + .../features/canvas/stores/canvasChatStore.ts | 215 ++++++++++++ .../canvas/stores/dashboardEditStore.ts | 23 ++ .../canvas/stores/websiteTasksStore.ts | 58 +++ .../ui/src/features/canvas/subscriptions.ts | 53 +++ packages/ui/src/router/routeTree.gen.ts | 158 +++++++++ packages/ui/src/router/routes/website.tsx | 6 + .../$channelId/dashboards/$dashboardId.tsx | 13 + .../routes/website/$channelId/index.tsx | 11 + .../router/routes/website/$channelId/new.tsx | 11 + .../routes/website/$channelId/settings.tsx | 11 + .../website/$channelId/tasks/$taskId.tsx | 39 +++ .../ui/src/router/routes/website/index.tsx | 6 + pnpm-lock.yaml | 30 +- 56 files changed, 3683 insertions(+), 238 deletions(-) delete mode 100644 apps/code/src/main/trpc/routers/dashboards.ts create mode 100644 packages/core/src/canvas/dashboardSchemas.ts create mode 100644 packages/core/src/canvas/genSchemas.ts create mode 100644 packages/core/src/canvas/identifiers.ts create mode 100644 packages/core/src/canvas/querySchemas.ts create mode 100644 packages/core/src/canvas/services.ts rename apps/code/src/main/trpc/routers/canvas-gen.ts => packages/host-router/src/routers/canvas-gen.router.ts (50%) create mode 100644 packages/host-router/src/routers/dashboards.router.ts create mode 100644 packages/ui/src/features/canvas/AGENTS.md create mode 100644 packages/ui/src/features/canvas/components/CanvasChat.tsx create mode 100644 packages/ui/src/features/canvas/components/ChannelsList.tsx create mode 100644 packages/ui/src/features/canvas/components/CreateChannelModal.tsx create mode 100644 packages/ui/src/features/canvas/components/DashboardRefreshControl.tsx create mode 100644 packages/ui/src/features/canvas/components/RenameChannelModal.tsx create mode 100644 packages/ui/src/features/canvas/components/WebsiteCanvas.tsx create mode 100644 packages/ui/src/features/canvas/components/WebsiteChannelsIndex.tsx create mode 100644 packages/ui/src/features/canvas/components/WebsiteDashboard.tsx create mode 100644 packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx create mode 100644 packages/ui/src/features/canvas/components/WebsiteLayout.tsx create mode 100644 packages/ui/src/features/canvas/components/WebsiteNewTask.tsx create mode 100644 packages/ui/src/features/canvas/components/WebsiteSettings.tsx create mode 100644 packages/ui/src/features/canvas/genui/EditRenderer.tsx create mode 100644 packages/ui/src/features/canvas/genui/ViewRenderer.tsx create mode 100644 packages/ui/src/features/canvas/genui/bodies.tsx create mode 100644 packages/ui/src/features/canvas/genui/catalog.ts create mode 100644 packages/ui/src/features/canvas/genui/dashboardTitle.ts create mode 100644 packages/ui/src/features/canvas/genui/editable.ts create mode 100644 packages/ui/src/features/canvas/genui/registry.tsx create mode 100644 packages/ui/src/features/canvas/hooks/useChannels.ts create mode 100644 packages/ui/src/features/canvas/hooks/useDashboards.ts create mode 100644 packages/ui/src/features/canvas/hooks/useRefreshDashboard.ts create mode 100644 packages/ui/src/features/canvas/hostClient.ts create mode 100644 packages/ui/src/features/canvas/stores/canvasChatStore.ts create mode 100644 packages/ui/src/features/canvas/stores/dashboardEditStore.ts create mode 100644 packages/ui/src/features/canvas/stores/websiteTasksStore.ts create mode 100644 packages/ui/src/features/canvas/subscriptions.ts create mode 100644 packages/ui/src/router/routes/website.tsx create mode 100644 packages/ui/src/router/routes/website/$channelId/dashboards/$dashboardId.tsx create mode 100644 packages/ui/src/router/routes/website/$channelId/index.tsx create mode 100644 packages/ui/src/router/routes/website/$channelId/new.tsx create mode 100644 packages/ui/src/router/routes/website/$channelId/settings.tsx create mode 100644 packages/ui/src/router/routes/website/$channelId/tasks/$taskId.tsx create mode 100644 packages/ui/src/router/routes/website/index.tsx diff --git a/apps/code/src/main/di/bindings.ts b/apps/code/src/main/di/bindings.ts index 7db882832..9568d5f5a 100644 --- a/apps/code/src/main/di/bindings.ts +++ b/apps/code/src/main/di/bindings.ts @@ -8,6 +8,11 @@ import type { AUTH_TOKEN_CIPHER, AUTH_TOKEN_OVERRIDE, } from "@posthog/core/auth/identifiers"; +import type { + DASHBOARD_QUERY_SERVICE as CANVAS_DASHBOARD_QUERY_SERVICE, + DASHBOARDS_SERVICE as CANVAS_DASHBOARDS_SERVICE, + CANVAS_GEN_SERVICE, +} from "@posthog/core/canvas/identifiers"; import type { CLOUD_TASK_AUTH, ICloudTaskAuth, @@ -237,11 +242,8 @@ import type { AUTH_PREFERENCE_REPOSITORY as MAIN_AUTH_PREFERENCE_REPOSITORY, AUTH_SERVICE as MAIN_AUTH_SERVICE, AUTH_SESSION_REPOSITORY as MAIN_AUTH_SESSION_REPOSITORY, - CANVAS_GEN_SERVICE as MAIN_CANVAS_GEN_SERVICE, CLOUD_TASK_SERVICE as MAIN_CLOUD_TASK_SERVICE, CONTEXT_MENU_SERVICE as MAIN_CONTEXT_MENU_SERVICE, - DASHBOARD_QUERY_SERVICE as MAIN_DASHBOARD_QUERY_SERVICE, - DASHBOARDS_SERVICE as MAIN_DASHBOARDS_SERVICE, DATABASE_SERVICE as MAIN_DATABASE_SERVICE, DEEP_LINK_SERVICE as MAIN_DEEP_LINK_SERVICE, DEFAULT_ADDITIONAL_DIRECTORY_REPOSITORY as MAIN_DEFAULT_ADDITIONAL_DIRECTORY_REPOSITORY, @@ -423,9 +425,9 @@ export interface MainBindings { [SECURE_STORE_SERVICE]: ISecureStoreService; [LOGS_SERVICE]: ILogsService; [MAIN_ENCRYPTION_SERVICE]: EncryptionService; - [MAIN_CANVAS_GEN_SERVICE]: CanvasGenService; - [MAIN_DASHBOARDS_SERVICE]: DashboardsService; - [MAIN_DASHBOARD_QUERY_SERVICE]: DashboardQueryService; + [CANVAS_GEN_SERVICE]: CanvasGenService; + [CANVAS_DASHBOARDS_SERVICE]: DashboardsService; + [CANVAS_DASHBOARD_QUERY_SERVICE]: DashboardQueryService; // 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 50e36dba0..b7fbea6f8 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -18,6 +18,11 @@ import { AUTH_TOKEN_CIPHER, AUTH_TOKEN_OVERRIDE, } from "@posthog/core/auth/identifiers"; +import { + DASHBOARD_QUERY_SERVICE as CANVAS_DASHBOARD_QUERY_SERVICE, + DASHBOARDS_SERVICE as CANVAS_DASHBOARDS_SERVICE, + CANVAS_GEN_SERVICE, +} from "@posthog/core/canvas/identifiers"; import { cloudTaskModule } from "@posthog/core/cloud-task/cloud-task.module"; import { CLOUD_TASK_AUTH, @@ -694,17 +699,15 @@ container.bind(LOGS_SERVICE).toDynamicValue((ctx) => { }); container.bind(MAIN_ENCRYPTION_SERVICE).to(EncryptionService); -// Canvas / dashboards (project-bluebird). Singletons: CanvasGenService holds -// per-thread agent state + a forwarding loop for app lifetime. +// Canvas / dashboards (project-bluebird). Bound to the @posthog/core tokens so +// the host-router routers resolve them via ctx.container. Singletons: +// CanvasGenService holds per-thread agent state + a forwarding loop for app life. container - .bind(MAIN_TOKENS.DashboardQueryService) + .bind(CANVAS_DASHBOARD_QUERY_SERVICE) .to(DashboardQueryService) .inSingletonScope(); container - .bind(MAIN_TOKENS.DashboardsService) + .bind(CANVAS_DASHBOARDS_SERVICE) .to(DashboardsService) .inSingletonScope(); -container - .bind(MAIN_TOKENS.CanvasGenService) - .to(CanvasGenService) - .inSingletonScope(); +container.bind(CANVAS_GEN_SERVICE).to(CanvasGenService).inSingletonScope(); diff --git a/apps/code/src/main/di/tokens.ts b/apps/code/src/main/di/tokens.ts index 7e9a42858..e71f19f83 100644 --- a/apps/code/src/main/di/tokens.ts +++ b/apps/code/src/main/di/tokens.ts @@ -53,15 +53,6 @@ export const DEFAULT_ADDITIONAL_DIRECTORY_REPOSITORY = Symbol.for( // Services export const AUTH_SERVICE = Symbol.for("posthog.host.main.auth.service"); -export const CANVAS_GEN_SERVICE = Symbol.for( - "posthog.host.main.canvas-gen.service", -); -export const DASHBOARDS_SERVICE = Symbol.for( - "posthog.host.main.dashboards.service", -); -export const DASHBOARD_QUERY_SERVICE = Symbol.for( - "posthog.host.main.dashboard-query.service", -); export const SUSPENSION_SERVICE = Symbol.for( "posthog.host.main.suspension.service", ); @@ -141,9 +132,6 @@ export const MAIN_TOKENS = Object.freeze({ DefaultAdditionalDirectoryRepository: DEFAULT_ADDITIONAL_DIRECTORY_REPOSITORY, AuthService: AUTH_SERVICE, - CanvasGenService: CANVAS_GEN_SERVICE, - DashboardsService: DASHBOARDS_SERVICE, - DashboardQueryService: DASHBOARD_QUERY_SERVICE, SuspensionService: SUSPENSION_SERVICE, AppLifecycleService: APP_LIFECYCLE_SERVICE, CloudTaskService: CLOUD_TASK_SERVICE, diff --git a/apps/code/src/main/services/canvas-gen/schemas.ts b/apps/code/src/main/services/canvas-gen/schemas.ts index 4e630cc78..799733be2 100644 --- a/apps/code/src/main/services/canvas-gen/schemas.ts +++ b/apps/code/src/main/services/canvas-gen/schemas.ts @@ -1,48 +1,2 @@ -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; -} +// Schemas live in @posthog/core so host-router + renderer can share the types. +export * from "@posthog/core/canvas/genSchemas"; diff --git a/apps/code/src/main/services/dashboard-query/schemas.ts b/apps/code/src/main/services/dashboard-query/schemas.ts index 4744d8e85..c2d668e36 100644 --- a/apps/code/src/main/services/dashboard-query/schemas.ts +++ b/apps/code/src/main/services/dashboard-query/schemas.ts @@ -1,35 +1,2 @@ -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; +// Schemas live in @posthog/core so host-router + renderer can share the types. +export * from "@posthog/core/canvas/querySchemas"; diff --git a/apps/code/src/main/services/dashboards/schemas.ts b/apps/code/src/main/services/dashboards/schemas.ts index a2824b3c1..2844ec2f6 100644 --- a/apps/code/src/main/services/dashboards/schemas.ts +++ b/apps/code/src/main/services/dashboards/schemas.ts @@ -1,69 +1,3 @@ -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(), -}); +// Dashboard schemas live in @posthog/core so the renderer (packages/ui) can +// import the shared types without depending on the desktop app's main process. +export * from "@posthog/core/canvas/dashboardSchemas"; diff --git a/apps/code/src/main/services/dashboards/service.ts b/apps/code/src/main/services/dashboards/service.ts index 468482218..5fdcbca6c 100644 --- a/apps/code/src/main/services/dashboards/service.ts +++ b/apps/code/src/main/services/dashboards/service.ts @@ -1,4 +1,5 @@ import type { AuthService } from "@posthog/core/auth/auth"; +import { DASHBOARD_QUERY_SERVICE } from "@posthog/core/canvas/identifiers"; import { inject, injectable } from "inversify"; import { MAIN_TOKENS } from "../../di/tokens"; import type { DashboardQuery } from "../dashboard-query/schemas"; @@ -36,7 +37,7 @@ export class DashboardsService { constructor( @inject(MAIN_TOKENS.AuthService) private readonly authService: AuthService, - @inject(MAIN_TOKENS.DashboardQueryService) + @inject(DASHBOARD_QUERY_SERVICE) private readonly dashboardQuery: DashboardQueryService, ) {} diff --git a/apps/code/src/main/trpc/router.ts b/apps/code/src/main/trpc/router.ts index cbe9ec238..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"; @@ -37,8 +39,6 @@ import { uiRouter } from "@posthog/host-router/routers/ui.router"; import { updatesRouter } from "@posthog/host-router/routers/updates.router"; import { usageMonitorRouter } from "@posthog/host-router/routers/usage-monitor.router"; import { workspaceRouter } from "@posthog/host-router/routers/workspace.router"; -import { canvasGenRouter } from "./routers/canvas-gen"; -import { dashboardsRouter } from "./routers/dashboards"; import { encryptionRouter } from "./routers/encryption"; import { workspaceServerRouter } from "./routers/workspace-server"; import { router } from "./trpc"; diff --git a/apps/code/src/main/trpc/routers/dashboards.ts b/apps/code/src/main/trpc/routers/dashboards.ts deleted file mode 100644 index 5ca1b11ad..000000000 --- a/apps/code/src/main/trpc/routers/dashboards.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { z } from "zod"; -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { - createDashboardInput, - dashboardIdInput, - dashboardRecordSchema, - dashboardSummarySchema, - listDashboardsInput, - refreshDashboardInput, - updateDashboardInput, -} from "../../services/dashboards/schemas"; -import type { DashboardsService } from "../../services/dashboards/service"; -import { publicProcedure, router } from "../trpc"; - -const getService = () => - container.get(MAIN_TOKENS.DashboardsService); - -export const dashboardsRouter = router({ - list: publicProcedure - .input(listDashboardsInput) - .output(z.array(dashboardSummarySchema)) - .query(({ input }) => getService().list(input.channelId)), - get: publicProcedure - .input(dashboardIdInput) - .output(dashboardRecordSchema.nullable()) - .query(({ input }) => getService().get(input.id)), - create: publicProcedure - .input(createDashboardInput) - .output(dashboardRecordSchema) - .mutation(({ input }) => getService().create(input)), - update: publicProcedure - .input(updateDashboardInput) - .output(dashboardRecordSchema) - .mutation(({ input }) => getService().update(input)), - delete: publicProcedure - .input(dashboardIdInput) - .mutation(({ input }) => getService().delete(input.id)), - refresh: publicProcedure - .input(refreshDashboardInput) - .mutation(({ input }) => getService().refresh(input)), -}); diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index bf8f65fe7..e339a69cb 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -664,6 +664,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/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/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/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/apps/code/src/main/trpc/routers/canvas-gen.ts b/packages/host-router/src/routers/canvas-gen.router.ts similarity index 50% rename from apps/code/src/main/trpc/routers/canvas-gen.ts rename to packages/host-router/src/routers/canvas-gen.router.ts index aaca2210c..6b2a134fa 100644 --- a/apps/code/src/main/trpc/routers/canvas-gen.ts +++ b/packages/host-router/src/routers/canvas-gen.router.ts @@ -1,27 +1,28 @@ -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; import { CanvasGenEvent, canvasGenerateInput, canvasThreadInput, -} from "../../services/canvas-gen/schemas"; -import type { CanvasGenService } from "../../services/canvas-gen/service"; -import { publicProcedure, router } from "../trpc"; - -const getService = () => - container.get(MAIN_TOKENS.CanvasGenService); +} 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(({ input }) => getService().generate(input)), + .mutation(({ ctx, input }) => + ctx.container.get(CANVAS_GEN_SERVICE).generate(input), + ), reset: publicProcedure .input(canvasThreadInput) - .mutation(({ input }) => getService().reset(input)), + .mutation(({ ctx, input }) => + ctx.container.get(CANVAS_GEN_SERVICE).reset(input), + ), onEvent: publicProcedure .input(canvasThreadInput) .subscription(async function* (opts) { - const service = getService(); + const service = + opts.ctx.container.get(CANVAS_GEN_SERVICE); const iterable = service.toIterable(CanvasGenEvent.Event, { signal: opts.signal, }); 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/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..97d0364d3 --- /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 `@main/services/dashboards/service.ts`; the `meta` payload is + typed + documented as `DashboardFileMeta` in that service's `schemas.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/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} + + )} + + + + + +