diff --git a/CANVAS_MVP.md b/CANVAS_MVP.md new file mode 100644 index 0000000000..cba41f1195 --- /dev/null +++ b/CANVAS_MVP.md @@ -0,0 +1,95 @@ +# Canvas / Dashboards — Progress & MVP gaps + +Branch: `feat/canvas`. Generative-UI dashboards built from real PostHog data, +wrapped in a Slack-like multi-space shell. + +## What's built + +### Shell / navigation + +- **Canvas nav rail** (`features/canvas/components/CanvasNav.tsx`) — Slack-like + left rail of square Quill buttons: **Home**, **Inbox**, **Code**. Reserves + macOS traffic-light space; draggable titlebar region. +- **Inbox** — top-level `/inbox` renders `InboxView` full-screen (no code + chrome); rail button shows a live count badge. +- **Home space** (`/`) — own `HomeSidebar` with Quill folder collapsibles and + Quill nav buttons. Website section: Dashboards (with count badge), New + dashboard, New task, Settings, and the list of created tasks. +- Root layout (`routes/__root.tsx`) branches: settings (full-screen), + inbox (full-screen), home space (rail + HomeSidebar), code (existing chrome). + Home-space detection centralized in `features/canvas/spaces.ts`. + +### Website space (`/website/*`, layout in `WebsiteLayout.tsx`) + +- Breadcrumb topbar: `Website > [crumb]` + right-aligned controls. +- **New task** — reuses `/code`'s `TaskInput` via its `onTaskCreated` seam; + created tasks route to `/website/tasks/$id` (tracked in + `websiteTasksStore`, persisted) and render with the reused `TaskDetail`. +- **Settings** — inert placeholder. + +### Dashboards (file-backed json-render) + +- **Main `DashboardsService`** (`main/services/dashboards/`) — each dashboard is + a JSON file (`{id, name, spec, createdAt, updatedAt}`) under + `/dashboards/`. tRPC `dashboards.list | get | create | update`. +- Dashboard route renders the saved json-render **spec read-only** via + `CanvasRenderer` (ErrorBoundary-guarded). Empty state when no spec. +- **Combobox switcher** in the breadcrumb (filtering disabled so all show). +- **Edit mode** (per-dashboard toggle) swaps the view for the **gen-UI canvas + + chat** for that dashboard's thread. +- **Save** (enabled only when the live spec differs from saved) writes the spec; + **Save as fork** copies the current spec into a new dashboard; **New + dashboard** / empty-state create a blank one and open it in edit mode. +- **Refresh + polling control** (`DashboardRefreshControl.tsx`) — Quill button + group `Refresh | ⚙`. Gear dropdown: Static / Polling (10s, 10min). Polling + counts down in the button ("Refreshing in XX"), pauses in edit mode, and the + icon spins (`motion-safe:animate-spin`) while fetching. + +### Gen-UI engine + +- `@json-render/core` + `@json-render/react`. Shared catalog + (`genui/catalog.ts`: Page/Grid/Card/Heading/Text/Stat/Table/BarList/Badge/ + Divider) → `CANVAS_SYSTEM_PROMPT`. Radix registry in `genui/registry.tsx`. +- **Main `CanvasGenService`** reuses `AgentService` (PostHog MCP auto-enabled) + via a new `systemPromptOverride`, runs an ephemeral `__preview__` session per + thread with `bypassPermissions`, forwards ACP updates through a mixed-stream + parser to assemble the spec, and streams typed events over a tRPC + subscription. Multi-thread (one per dashboard). +- Renderer: thin multi-thread `canvasChatStore`, scoped subscription registrar, + `CanvasChat` panel. + +## What's left for a real MVP + +1. **Live data (biggest gap).** Dashboards store *static* specs — the agent + bakes numbers in at generation time. Refresh/polling currently just re-reads + the same file, so it's a visual no-op for saved dashboards. Real MVP needs + one of: + - re-run the agent on refresh to regenerate against fresh PostHog data, or + - json-render **data bindings** + a data-fetch layer the spec references + (preferred — cheap refresh, no re-generation). + The refresh/polling UI is already wired for whichever path. +2. **Verify the gen-UI agent end-to-end, live.** Not yet confirmed against a + real authed project: that the agent reliably emits valid json-render JSONL, + that PostHog MCP tools auto-approve under `bypassPermissions`, that the + prose/JSONL split is robust, and that it doesn't flood. May need + system-prompt tuning or gating the Claude Code file/bash tools off. +3. **Dashboard lifecycle.** No delete. Rename was reverted (dropdown only) — + add back if needed. Editing starts from a blank canvas rather than seeding + from the saved spec, so iterating on an existing dashboard restarts. +4. **Website task detail** lacks the code `HeaderRow` actions (branch selector, + handoff, skill buttons) since that chrome is intentionally absent — add a + website-space task toolbar if those are needed. +5. **Persistence niceties.** Polling choice is per-mount local state (resets on + reload); canvas chat threads aren't persisted (lost on reload). Dashboard + storage path isn't surfaced/configurable. +6. **Tests.** None yet for `DashboardsService`, `CanvasGenService`, + `canvasChatStore`, or the refresh control. +7. **States & polish.** Loading/error states for the dashboards list and gen-UI + stream; optional minimum spin duration so instant local refreshes still read + as deliberate. + +## Dev caveat + +Main-process changes (new services/routers: `dashboards`, `canvas-gen`, +`AgentService` edits) require a **full dev restart** — renderer HMR won't load +them. Symptom when stale: `No "mutation"-procedure on path "dashboards.create"`. diff --git a/apps/code/package.json b/apps/code/package.json index 44fe62acef..f93d2992bb 100644 --- a/apps/code/package.json +++ b/apps/code/package.json @@ -121,6 +121,8 @@ "@dnd-kit/react": "^0.1.21", "@fontsource-variable/inter": "^5.2.8", "@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/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index b2e2379419..bdd30bbd0d 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -31,9 +31,11 @@ import { AppLifecycleService } from "../services/app-lifecycle/service"; import { ArchiveService } from "../services/archive/service"; import { AuthService } from "../services/auth/service"; import { AuthProxyService } from "../services/auth-proxy/service"; +import { CanvasGenService } from "../services/canvas-gen/service"; import { CloudTaskService } from "../services/cloud-task/service"; import { ConnectivityService } from "../services/connectivity/service"; import { ContextMenuService } from "../services/context-menu/service"; +import { DashboardsService } from "../services/dashboards/service"; import { DeepLinkService } from "../services/deep-link/service"; import { EnrichmentService } from "../services/enrichment/service"; import { EnvironmentService } from "../services/environment/service"; @@ -114,6 +116,8 @@ container.bind(MAIN_TOKENS.ArchiveService).to(ArchiveService); container.bind(MAIN_TOKENS.SuspensionService).to(SuspensionService); container.bind(MAIN_TOKENS.AppLifecycleService).to(AppLifecycleService); container.bind(MAIN_TOKENS.CloudTaskService).to(CloudTaskService); +container.bind(MAIN_TOKENS.CanvasGenService).to(CanvasGenService); +container.bind(MAIN_TOKENS.DashboardsService).to(DashboardsService); container.bind(MAIN_TOKENS.ConnectivityService).to(ConnectivityService); container.bind(MAIN_TOKENS.ContextMenuService).to(ContextMenuService); container.bind(MAIN_TOKENS.DeepLinkService).to(DeepLinkService); diff --git a/apps/code/src/main/di/tokens.ts b/apps/code/src/main/di/tokens.ts index 69ea894b37..bdb187db60 100644 --- a/apps/code/src/main/di/tokens.ts +++ b/apps/code/src/main/di/tokens.ts @@ -48,6 +48,8 @@ export const MAIN_TOKENS = Object.freeze({ SuspensionService: Symbol.for("Main.SuspensionService"), AppLifecycleService: Symbol.for("Main.AppLifecycleService"), CloudTaskService: Symbol.for("Main.CloudTaskService"), + CanvasGenService: Symbol.for("Main.CanvasGenService"), + DashboardsService: Symbol.for("Main.DashboardsService"), ConnectivityService: Symbol.for("Main.ConnectivityService"), ContextMenuService: Symbol.for("Main.ContextMenuService"), diff --git a/apps/code/src/main/services/agent/schemas.ts b/apps/code/src/main/services/agent/schemas.ts index 410d77ea59..30c4ab0ed5 100644 --- a/apps/code/src/main/services/agent/schemas.ts +++ b/apps/code/src/main/services/agent/schemas.ts @@ -51,6 +51,12 @@ export const startSessionInput = z.object({ effort: effortLevelSchema.optional(), model: z.string().optional(), jsonSchema: z.record(z.string(), z.unknown()).nullish(), + /** + * When set, fully replaces the built system prompt (attribution / PR / branch + * conventions) with this text, keeping only the PostHog project-scoping line. + * Used by non-coding agent surfaces (e.g. the canvas generation agent). + */ + systemPromptOverride: z.string().optional(), }); export type StartSessionInput = z.infer; diff --git a/apps/code/src/main/services/agent/service.ts b/apps/code/src/main/services/agent/service.ts index ab5f371bca..00707d42ec 100644 --- a/apps/code/src/main/services/agent/service.ts +++ b/apps/code/src/main/services/agent/service.ts @@ -226,6 +226,8 @@ interface SessionConfig { model?: string; /** JSON Schema for structured task output — when set, the agent gets a create_output tool */ jsonSchema?: Record | null; + /** When set, replaces the default system prompt (keeps only project scoping) */ + systemPromptOverride?: string; } interface ManagedSession { @@ -474,10 +476,20 @@ export class AgentService extends TypedEventEmitter { taskId: string, customInstructions?: string, additionalDirectories?: string[], + systemPromptOverride?: string, ): { append: string; } { - let prompt = `PostHog context: use project ${credentials.projectId} on ${credentials.apiHost}. When using PostHog MCP tools, operate only on this project.`; + const projectContext = `PostHog context: use project ${credentials.projectId} on ${credentials.apiHost}. When using PostHog MCP tools, operate only on this project.`; + + // Override mode: non-coding surfaces (e.g. canvas generation) get only the + // project-scoping line plus their own instructions — the attribution / PR / + // branch conventions below are irrelevant and would mislead the agent. + if (systemPromptOverride) { + return { append: `${projectContext}\n\n${systemPromptOverride}` }; + } + + let prompt = projectContext; prompt += ` @@ -565,6 +577,7 @@ When creating pull requests, add the following footer at the end of the PR descr effort, model, jsonSchema, + systemPromptOverride, } = config; // Preview config doesn't need a real repo — use a temp directory @@ -625,6 +638,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, { @@ -1546,6 +1560,10 @@ For git operations while detached: effort: "effort" in params ? params.effort : undefined, model: "model" in params ? params.model : undefined, jsonSchema: "jsonSchema" in params ? params.jsonSchema : undefined, + systemPromptOverride: + "systemPromptOverride" in params + ? params.systemPromptOverride + : undefined, }; } 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 0000000000..4e630cc788 --- /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 0000000000..d2030bffb0 --- /dev/null +++ b/apps/code/src/main/services/canvas-gen/service.ts @@ -0,0 +1,215 @@ +import { tmpdir } from "node:os"; +import type { ContentBlock } from "@agentclientprotocol/sdk"; +import { + applySpecStreamPatch, + createMixedStreamParser, + type MixedStreamParser, +} from "@json-render/core"; +import type { AcpMessage } from "@shared/types/session-events"; +import { inject, injectable } from "inversify"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { logger } from "../../utils/logger"; +import { TypedEventEmitter } from "../../utils/typed-event-emitter"; +import { + AgentServiceEvent, + type AgentSessionEventPayload, +} from "../agent/schemas"; +import type { AgentService } from "../agent/service"; +import type { AuthService } from "../auth/service"; +import { + CanvasGenEvent, + type CanvasGenEvents, + type CanvasGenerateInput, + type CanvasStreamEvent, + type CanvasThreadInput, +} from "./schemas"; + +const log = logger.scope("canvas-gen"); + +const TASK_RUN_PREFIX = "canvas:"; + +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(MAIN_TOKENS.AgentService) + 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, + ...(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/dashboards/schemas.ts b/apps/code/src/main/services/dashboards/schemas.ts new file mode 100644 index 0000000000..3130ee0302 --- /dev/null +++ b/apps/code/src/main/services/dashboards/schemas.ts @@ -0,0 +1,41 @@ +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; + +export const dashboardSummarySchema = z.object({ + id: z.string(), + channelId: z.string(), + name: z.string(), + updatedAt: z.number(), +}); +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) }); 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 0000000000..bf0ade6154 --- /dev/null +++ b/apps/code/src/main/services/dashboards/service.ts @@ -0,0 +1,141 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { IStoragePaths } from "@posthog/platform/storage-paths"; +import { inject, injectable } from "inversify"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { logger } from "../../utils/logger"; +import { + type DashboardRecord, + type DashboardSummary, + dashboardRecordSchema, +} from "./schemas"; + +const log = logger.scope("dashboards"); + +// File-backed dashboard store (MVP): each dashboard is a JSON file holding a +// json-render spec under /dashboards/.json. +@injectable() +export class DashboardsService { + constructor( + @inject(MAIN_TOKENS.StoragePaths) + private readonly storagePaths: IStoragePaths, + ) {} + + private get dir(): string { + return join(this.storagePaths.appDataPath, "dashboards"); + } + + private filePath(id: string): string { + return join(this.dir, `${id}.json`); + } + + private async ensureDir(): Promise { + await mkdir(this.dir, { recursive: true }); + } + + async list(channelId: string): Promise { + await this.ensureDir(); + const entries = await readdir(this.dir); + const records: DashboardRecord[] = []; + for (const entry of entries) { + if (!entry.endsWith(".json")) continue; + const record = await this.readFileRecord(join(this.dir, entry)); + if (record && record.channelId === channelId) records.push(record); + } + return records + .sort((a, b) => b.updatedAt - a.updatedAt) + .map(({ id, channelId: cid, name, updatedAt }) => ({ + id, + channelId: cid, + name, + updatedAt, + })); + } + + async get(id: string): Promise { + return this.readFileRecord(this.filePath(id)); + } + + // One-time backfill: assign any channel-less dashboard (saved before channel + // scoping) to the given default channel. Idempotent — returns how many were + // adopted, 0 once none remain. + async adoptOrphans(channelId: string): Promise { + await this.ensureDir(); + const entries = await readdir(this.dir); + let adopted = 0; + for (const entry of entries) { + if (!entry.endsWith(".json")) continue; + const record = await this.readFileRecord(join(this.dir, entry)); + if (record && !record.channelId) { + await this.write({ ...record, channelId, updatedAt: Date.now() }); + adopted++; + } + } + return adopted; + } + + async create(input: { + channelId: string; + name: string; + spec: Record | null; + }): Promise { + const now = Date.now(); + const record: DashboardRecord = { + id: randomUUID(), + channelId: input.channelId, + name: input.name, + spec: input.spec, + createdAt: now, + updatedAt: now, + }; + await this.write(record); + return record; + } + + async update(input: { + id: string; + name?: string; + spec: Record | null; + }): Promise { + const existing = await this.get(input.id); + const now = Date.now(); + const record: DashboardRecord = { + id: input.id, + // Preserve channel ownership; only spec/name change on update. + channelId: existing?.channelId ?? "", + name: input.name ?? existing?.name ?? "Untitled dashboard", + spec: input.spec, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }; + await this.write(record); + return record; + } + + async delete(id: string): Promise { + try { + await unlink(this.filePath(id)); + } catch (err) { + // Already gone is a successful delete; surface anything else. + if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err; + } + } + + private async write(record: DashboardRecord): Promise { + await this.ensureDir(); + await writeFile(this.filePath(record.id), JSON.stringify(record, null, 2)); + } + + private async readFileRecord(path: string): Promise { + try { + const parsed = dashboardRecordSchema.safeParse( + JSON.parse(await readFile(path, "utf8")), + ); + return parsed.success ? parsed.data : null; + } catch (err) { + log.warn("Failed to read dashboard file", { path, err }); + return null; + } + } +} diff --git a/apps/code/src/main/trpc/router.ts b/apps/code/src/main/trpc/router.ts index f0f8dd9eb5..8834dbf46b 100644 --- a/apps/code/src/main/trpc/router.ts +++ b/apps/code/src/main/trpc/router.ts @@ -3,9 +3,11 @@ import { agentRouter } from "./routers/agent"; import { analyticsRouter } from "./routers/analytics"; import { archiveRouter } from "./routers/archive"; import { authRouter } from "./routers/auth"; +import { canvasGenRouter } from "./routers/canvas-gen"; import { cloudTaskRouter } from "./routers/cloud-task"; import { connectivityRouter } from "./routers/connectivity"; import { contextMenuRouter } from "./routers/context-menu"; +import { dashboardsRouter } from "./routers/dashboards"; import { deepLinkRouter } from "./routers/deep-link"; import { encryptionRouter } from "./routers/encryption"; import { enrichmentRouter } from "./routers/enrichment"; @@ -46,6 +48,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 0000000000..aaca2210c1 --- /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 0000000000..591a9ba699 --- /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, + 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)), + adoptOrphans: publicProcedure + .input(listDashboardsInput) + .output(z.number()) + .mutation(({ input }) => getService().adoptOrphans(input.channelId)), +}); diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index e778fc451f..531dc66db3 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -610,6 +610,79 @@ export class PostHogAPIClient { return data; } + // Desktop file system "channels": top-level folder rows (depth 1) on the + // project's desktop_file_system surface. 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; + } + + // 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/apps/code/src/renderer/features/canvas/components/CanvasChat.tsx b/apps/code/src/renderer/features/canvas/components/CanvasChat.tsx new file mode 100644 index 0000000000..4dbcc1724f --- /dev/null +++ b/apps/code/src/renderer/features/canvas/components/CanvasChat.tsx @@ -0,0 +1,129 @@ +import { + useCanvasChatStore, + useCanvasThread, +} from "@features/canvas/stores/canvasChatStore"; +import { PaperPlaneRightIcon, SpinnerGapIcon } from "@phosphor-icons/react"; +import { Button } from "@posthog/quill"; +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} + + )} + + + + + +