diff --git a/CANVAS_MVP.md b/CANVAS_MVP.md new file mode 100644 index 0000000000..76f5311d5c --- /dev/null +++ b/CANVAS_MVP.md @@ -0,0 +1,145 @@ +# Canvas / Dashboards — Progress & MVP gaps + +Generative-UI dashboards built from real PostHog data, wrapped in a Slack-like +multi-space shell. **Everything is gated behind the `project-bluebird` feature +flag** (default-on in dev, off for all prod users → app is byte-for-byte the +current code-only shell). Not enabled for real users. + +## Branches / PRs + +- **`feat/canvas`** — the full feature (this doc). Draft **PR #2492**. +- **`code/top-nav`** — minimal extraction of just the nav rail (Home/Inbox/Code, + Home empty), for a clean first landing. **PR #2491**. + +## Shell / navigation + +- **App nav rail** — Slack-like left rail (Home / Inbox / Code). Reserves macOS + traffic-light space (2.5rem top padding); draggable titlebar region. Inbox + button shows a live actionable-report count badge (`useInboxSignalCount`). + - On `feat/canvas`: `features/canvas/components/CanvasNav.tsx`. + - On `code/top-nav`: `components/AppNav.tsx` (Home routes to `/code` for now). +- **Inbox** — top-level `/inbox` renders `InboxView` full-screen. +- **Home space** (`/website/*`) — its own `HomeSidebar` listing **channels**. +- Root layout (`routes/__root.tsx`) gates the rail + branches on the flag: + settings (full-screen), inbox (full-screen), home (rail + HomeSidebar), code + (existing chrome). When the flag is off, `/` and `/inbox` redirect to `/code` + once flags resolve (`useFeatureFlagsLoaded`). + +## Channels (server-backed) + +Replaces the old placeholder Website/Features/Resources nav. A "channel" is a +top-level folder on PostHog's **desktop file-system** surface. + +- `posthogClient.ts` — `getDesktopFileSystem` / `createDesktopFileSystemChannel` + / `deleteDesktopFileSystem`. `hooks/useChannels.ts` — list + create/delete. +- `HomeSidebar.tsx` — a "Channels" list with a Slack-style **create modal** + (`CreateChannelModal.tsx`) and a per-channel hover `…` menu → destructive + **delete**. Each channel is a collapsible section with active-route + highlighting. +- **Each channel gets its own** dashboards, tasks, and settings, routed under + **`/website/$channelId/...`** (`index` = dashboards grid, `dashboards/$id`, + `new`, `tasks/$taskId`, `settings`). `/website` redirects to the first channel + or an empty "create a channel" state. +- Channels require auth; logged-out shows an empty state. + +## Dashboards (file-backed, channel-scoped) + +- **Main `DashboardsService`** (`main/services/dashboards/`) — each dashboard is + a JSON file `{id, channelId, name, spec, createdAt, updatedAt}` under + `/dashboards/`. tRPC `dashboards.list(channelId) | get | create | + update | delete | adoptOrphans | refresh`. `list` filters by `channelId`; + `adoptOrphans` backfills pre-scoping dashboards into the first channel. +- **Index grid** (`WebsiteDashboardsIndex.tsx`) — 3-wide responsive card grid; + each card shows a **live scaled-down preview** (`CanvasRenderer` at + `scale(0.4)`), name, "updated" time, and a hover `…` menu → destructive + **delete**. "New dashboard" creates a blank board and opens it in edit mode. +- Breadcrumbs (`WebsiteLayout.tsx`): ` › Dashboards [› ]` / + `New task` / `Settings`. No hardcoded "Website" root. + +## 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`. +- **Shared presentational bodies** (`genui/bodies.tsx`) — the JSX for every + component lives once; `renderBody` dispatches by type. Both the view and edit + renderers use them, so the surfaces are pixel-identical. `StatBody` formats raw + numbers (`34980058 → 34,980,058`) at render. +- **View renderer** — `genui/registry.tsx` (`CanvasRenderer`, used for the grid + thumbnails) and `genui/ViewRenderer.tsx` (key-aware walk used for the saved + board, so each Card can carry a per-card refresh button). +- **Main `CanvasGenService`** reuses `AgentService` (PostHog MCP auto-enabled) + via `systemPromptOverride`, runs an ephemeral `__preview__` session per thread + with `bypassPermissions`, splits prose / json-render JSONL, assembles the spec, + and streams typed events over a tRPC subscription. Renderer: multi-thread + `canvasChatStore`, scoped subscription registrar, `CanvasChat` panel. + +## Edit mode — direct manipulation + +Entering Edit (`WebsiteDashboard.tsx`) seeds the canvas thread from the saved +spec (`ensureSpec`) and swaps to the gen-UI canvas + chat (`WebsiteCanvas.tsx`). + +- **`genui/EditRenderer.tsx`** — recursive, key-aware walk (the map key is the + element id, which `createRenderer` doesn't expose): + - **Inline edit** of static text (titles/labels) via a contentEditable + `InlineText` (commit on blur/Enter, revert on Escape). + - **Drag-and-drop reorder** via `@dnd-kit/react` (`useSortable` grouped by + parent); drop → `moveChild`. + - **Locked data hint** — query-derived values show a "Data — from query" + tooltip, not editable. + - All affordances gated on `!isStreaming` so edits can't race agent snapshots. +- **`genui/editable.ts`** — the "interpreter": a prop is inline-editable iff it's + an allow-listed static-text prop **and** a string literal (binding objects are + auto-excluded). +- Spec edits mutate the live thread spec via `canvasChatStore` + (`setElementProp`, `moveChild`); the existing dirty-diff drives **Save**. +- **Save** persists; **Save as fork** copies into a new dashboard; **Cancel** + (the Edit button when active) resets the thread → discards all unsaved edits + and the agent session; the file is untouched. + +## Refreshable data — stored queries + +Each data point's query lives **in the spec JSON** at +`spec.state.queries[elementKey][propPath] = { query: "" }` (values stay +literals in props, so rendering/editing are unchanged; forks stay refreshable). + +- **Agent contract** (`catalog.ts`) — the agent records the single-row/single-col + HogQL for every Stat value/delta alongside the literal it renders. +- **Main `DashboardQueryService`** (`main/services/dashboard-query/`) — runs each + HogQL via `POST /api/projects/:id/query/` (auth via + `authService.authenticatedFetch` → 401-refresh), capped parallelism, reduces to + row 0 / col 0, per-point ok/fail (one bad query never fails the batch). +- **`dashboards.refresh(id, elementKeys?, touchUpdatedAt?)`** — atomic main + read→run→patch→write: collects queries (subtree-filtered for per-card), runs + them, patches `ok` values into the spec, **persists to the file**. Returns + `{ updated, failures }`. +- **Renderer** — `hooks/useRefreshDashboard.ts` calls refresh + invalidates + `dashboards.get` + toasts failures. The `DashboardRefreshControl` button now + actually refreshes; polling passes `touchUpdatedAt:false` (no list reorder). + `ViewRenderer` adds a **per-card** hover ↻ → `refresh([cardKey])`. + +## What's left + +1. **Agent reliability of `state.queries`.** If the agent omits the patch, that + point silently stays unrefreshable (degrades to baked literal — safe). Needs + live verification + prompt tuning; optional post-stream coverage warning. +2. **Table / BarList refresh** (array data) — not yet; needs a `shape:"rows"` + query mode in `DashboardQueryService` + agent contract. +3. **Edit-mode live refresh** — refresh is view-mode only; edit mode renders the + live store. A future enhancement refreshes via `setElementProp`. +4. **Verify the gen-UI agent end-to-end, live** against a real authed project: + valid JSONL, MCP auto-approve under `bypassPermissions`, robust prose/JSONL + split, no flooding. +5. **Persistence niceties.** Polling choice is per-mount local state; canvas chat + threads aren't persisted (lost on reload); channel↔task membership is local + (`websiteTasksStore`), not backend-bound. +6. **Tests.** None yet for the dashboards / dashboard-query / canvas-gen services, + the stores, or the renderers. +7. **Settings** per channel is still an inert placeholder. + +## Dev caveat + +Main-process changes (new services/routers: `dashboards`, `dashboard-query`, +`canvas-gen`) require a **full dev restart** — renderer HMR won't load them. +Symptom when stale: a refresh/save no-op or `No "mutation"-procedure on path +"dashboards.refresh"`. diff --git a/apps/code/package.json b/apps/code/package.json index 7264fcdf4c..8b428e8317 100644 --- a/apps/code/package.json +++ b/apps/code/package.json @@ -124,6 +124,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..25ba082dc0 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -31,9 +31,13 @@ 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 { ChannelTasksService } from "../services/channel-tasks/service"; import { CloudTaskService } from "../services/cloud-task/service"; import { ConnectivityService } from "../services/connectivity/service"; import { ContextMenuService } from "../services/context-menu/service"; +import { DashboardQueryService } from "../services/dashboard-query/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 +118,10 @@ 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.DashboardQueryService).to(DashboardQueryService); +container.bind(MAIN_TOKENS.ChannelTasksService).to(ChannelTasksService); 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..cf0876ce7d 100644 --- a/apps/code/src/main/di/tokens.ts +++ b/apps/code/src/main/di/tokens.ts @@ -48,6 +48,10 @@ 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"), + DashboardQueryService: Symbol.for("Main.DashboardQueryService"), + ChannelTasksService: Symbol.for("Main.ChannelTasksService"), 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..7766b15bda 100644 --- a/apps/code/src/main/services/agent/schemas.ts +++ b/apps/code/src/main/services/agent/schemas.ts @@ -51,6 +51,18 @@ 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(), + /** + * Tool names the agent may NOT use (Claude Code SDK `disallowedTools`). Used by + * non-coding surfaces (e.g. the canvas agent) to deny file/shell tools so the + * agent can't write files or run commands regardless of permission mode. + */ + disallowedTools: z.array(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..b8722ae53f 100644 --- a/apps/code/src/main/services/agent/service.ts +++ b/apps/code/src/main/services/agent/service.ts @@ -197,12 +197,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, }; } @@ -226,6 +230,10 @@ 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; + /** Tool names the agent may NOT use (Claude Code SDK `disallowedTools`). */ + disallowedTools?: string[]; } interface ManagedSession { @@ -474,10 +482,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 +583,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 +644,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, { @@ -728,6 +748,7 @@ When creating pull requests, add the following footer at the end of the PR descr additionalDirectories, effort, plugins, + disallowedTools: config.disallowedTools, }); let configOptions: SessionConfigOption[] | undefined; @@ -1546,6 +1567,12 @@ 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, + disallowedTools: + "disallowedTools" in params ? params.disallowedTools : 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..f2a56f0ecd --- /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 { 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:"; + +// 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(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, + // 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/channel-tasks/schemas.ts b/apps/code/src/main/services/channel-tasks/schemas.ts new file mode 100644 index 0000000000..856300660c --- /dev/null +++ b/apps/code/src/main/services/channel-tasks/schemas.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; + +export const channelTaskRecordSchema = z.object({ + id: z.string(), + channelId: z.string(), + taskId: z.string(), + createdAt: z.number(), +}); +export type ChannelTaskRecord = z.infer; + +export const channelTaskFileMetaSchema = z.object({ + channelId: z.string().optional(), + taskId: z.string().optional(), + createdAt: z.number().optional(), +}); +export type ChannelTaskFileMeta = z.infer; + +export const listChannelTasksInput = z.object({ + channelId: z.string().min(1), +}); + +export const fileChannelTaskInput = z.object({ + channelId: z.string().min(1), + taskId: z.string().min(1), +}); + +export const channelTaskIdInput = z.object({ id: z.string().min(1) }); diff --git a/apps/code/src/main/services/channel-tasks/service.ts b/apps/code/src/main/services/channel-tasks/service.ts new file mode 100644 index 0000000000..0338d157d0 --- /dev/null +++ b/apps/code/src/main/services/channel-tasks/service.ts @@ -0,0 +1,146 @@ +import { inject, injectable } from "inversify"; +import { MAIN_TOKENS } from "../../di/tokens"; +import type { AuthService } from "../auth/service"; +import type { ChannelTaskFileMeta, ChannelTaskRecord } from "./schemas"; + +// FS row type for a task filed to a channel. Stored under the channel folder +// alongside dashboards on the same desktop_file_system surface. +const CHANNEL_TASK_TYPE = "channel-task"; +const MAX_PAGES = 50; + +interface FsEntry { + id: string; + path: string; + type?: string; + meta?: ChannelTaskFileMeta | null; + created_at?: string; +} + +/** + * Tracks which tasks have been filed to a channel by writing a `channel-task` + * row to the project's desktop_file_system, nested under the channel folder. + * The path's last segment is the taskId (stable); the title is resolved + * separately by the renderer via useTasks. + */ +@injectable() +export class ChannelTasksService { + constructor( + @inject(MAIN_TOKENS.AuthService) + private readonly authService: AuthService, + ) {} + + 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 channel tasks (${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; + } + 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 channel task (${res.status})`); + return (await res.json()) as FsEntry; + } + + async list(channelId: string): Promise { + const entries = await this.listAll(); + return entries + .filter( + (e) => e.type === CHANNEL_TASK_TYPE && e.meta?.channelId === channelId, + ) + .map((e) => toRecord(e)) + .sort((a, b) => b.createdAt - a.createdAt); + } + + async file(input: { + channelId: string; + taskId: string; + }): Promise { + const channelPath = await this.channelPath(input.channelId); + // Idempotent: if already filed, return the existing row. + const existing = (await this.list(input.channelId)).find( + (r) => r.taskId === input.taskId, + ); + if (existing) return existing; + + const now = Date.now(); + const meta: ChannelTaskFileMeta = { + channelId: input.channelId, + taskId: input.taskId, + createdAt: now, + }; + const res = await this.fsFetch("", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: `${channelPath}/${sanitizeSegment(input.taskId)}`, + type: CHANNEL_TASK_TYPE, + meta, + }), + }); + if (!res.ok) throw new Error(`Failed to file task (${res.status})`); + return toRecord((await res.json()) as FsEntry); + } + + async unfile(id: string): Promise { + const res = await this.fsFetch(`${encodeURIComponent(id)}/`, { + method: "DELETE", + }); + if (!res.ok && res.status !== 404) { + throw new Error(`Failed to unfile task (${res.status})`); + } + } + + private async channelPath(channelId: string): Promise { + const entry = await this.getEntry(channelId); + if (!entry) throw new Error("Channel not found"); + return entry.path; + } +} + +function toRecord(entry: FsEntry): ChannelTaskRecord { + const meta = entry.meta ?? {}; + const createdAt = meta.createdAt ?? toEpoch(entry.created_at); + return { + id: entry.id, + channelId: meta.channelId ?? "", + taskId: meta.taskId ?? lastSegment(entry.path), + createdAt, + }; +} + +function sanitizeSegment(name: string): string { + const cleaned = name.replace(/\//g, " ").replace(/\s+/g, " ").trim(); + return cleaned || "untitled-task"; +} + +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; +} diff --git a/apps/code/src/main/services/context-menu/schemas.ts b/apps/code/src/main/services/context-menu/schemas.ts index 9620d3ba87..fe97004033 100644 --- a/apps/code/src/main/services/context-menu/schemas.ts +++ b/apps/code/src/main/services/context-menu/schemas.ts @@ -8,6 +8,9 @@ export const taskContextMenuInput = z.object({ isSuspended: z.boolean().optional(), isInCommandCenter: z.boolean().optional(), hasEmptyCommandCenterCell: z.boolean().optional(), + // Top-level desktop_file_system channels available as "File to…" targets. + // Omit (or pass empty) to hide the submenu entirely. + channels: z.array(z.object({ id: z.string(), name: z.string() })).optional(), }); export const bulkTaskContextMenuInput = z.object({ @@ -47,6 +50,7 @@ const taskAction = z.discriminatedUnion("type", [ z.object({ type: z.literal("delete") }), z.object({ type: z.literal("add-to-command-center") }), z.object({ type: z.literal("external-app"), action: externalAppAction }), + z.object({ type: z.literal("file-to-channel"), channelId: z.string() }), ]); const bulkTaskAction = z.discriminatedUnion("type", [ diff --git a/apps/code/src/main/services/context-menu/service.ts b/apps/code/src/main/services/context-menu/service.ts index 93376654c7..9d4fe82ec7 100644 --- a/apps/code/src/main/services/context-menu/service.ts +++ b/apps/code/src/main/services/context-menu/service.ts @@ -113,9 +113,27 @@ export class ContextMenuService { isSuspended, isInCommandCenter, hasEmptyCommandCenterCell, + channels, } = input; const { apps, lastUsedAppId } = await this.getExternalAppsData(); const hasPath = worktreePath || folderPath; + const fileToItems: MenuItemDef[] = + channels && channels.length > 0 + ? [ + this.separator(), + { + type: "submenu", + label: "File to…", + items: channels.map((c) => ({ + label: c.name, + action: { + type: "file-to-channel" as const, + channelId: c.id, + }, + })), + }, + ] + : []; return this.showMenu([ this.item(isPinned ? "Unpin" : "Pin", { type: "pin" }), @@ -144,6 +162,7 @@ export class ContextMenuService { ), ] : []), + ...fileToItems, this.separator(), this.item("Archive", { type: "archive" }), this.item( 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 0000000000..4744d8e855 --- /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 0000000000..0e757f37b4 --- /dev/null +++ b/apps/code/src/main/services/dashboard-query/service.ts @@ -0,0 +1,118 @@ +import { inject, injectable } from "inversify"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { logger } from "../../utils/logger"; +import type { AuthService } from "../auth/service"; +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 0000000000..a2824b3c15 --- /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 0000000000..fff450cc16 --- /dev/null +++ b/apps/code/src/main/services/dashboards/service.ts @@ -0,0 +1,350 @@ +import { inject, injectable } from "inversify"; +import { MAIN_TOKENS } from "../../di/tokens"; +import type { AuthService } from "../auth/service"; +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 f0f8dd9eb5..e041e2d3c3 100644 --- a/apps/code/src/main/trpc/router.ts +++ b/apps/code/src/main/trpc/router.ts @@ -3,9 +3,12 @@ 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 { channelTasksRouter } from "./routers/channel-tasks"; 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 +49,9 @@ export const trpcRouter = router({ analytics: analyticsRouter, archive: archiveRouter, auth: authRouter, + canvasGen: canvasGenRouter, + channelTasks: channelTasksRouter, + 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/channel-tasks.ts b/apps/code/src/main/trpc/routers/channel-tasks.ts new file mode 100644 index 0000000000..203e526c08 --- /dev/null +++ b/apps/code/src/main/trpc/routers/channel-tasks.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; +import { container } from "../../di/container"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { + channelTaskIdInput, + channelTaskRecordSchema, + fileChannelTaskInput, + listChannelTasksInput, +} from "../../services/channel-tasks/schemas"; +import type { ChannelTasksService } from "../../services/channel-tasks/service"; +import { publicProcedure, router } from "../trpc"; + +const getService = () => + container.get(MAIN_TOKENS.ChannelTasksService); + +export const channelTasksRouter = router({ + list: publicProcedure + .input(listChannelTasksInput) + .output(z.array(channelTaskRecordSchema)) + .query(({ input }) => getService().list(input.channelId)), + file: publicProcedure + .input(fileChannelTaskInput) + .output(channelTaskRecordSchema) + .mutation(({ input }) => getService().file(input)), + unfile: publicProcedure + .input(channelTaskIdInput) + .mutation(({ input }) => getService().unfile(input.id)), +}); 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..5ca1b11ad0 --- /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/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index b564aafda4..02ae703ca2 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -612,6 +612,104 @@ 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; + } + + // 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/apps/code/src/renderer/components/AppNav.tsx b/apps/code/src/renderer/components/AppNav.tsx new file mode 100644 index 0000000000..7b29903f32 --- /dev/null +++ b/apps/code/src/renderer/components/AppNav.tsx @@ -0,0 +1,67 @@ +import { CodeIcon, HashIcon } from "@phosphor-icons/react"; +import { Button } from "@posthog/quill"; +import { Box, Flex } from "@radix-ui/themes"; +import { useNavigate, useRouterState } from "@tanstack/react-router"; + +type AppNavItem = { + id: "code" | "channels"; + label: string; + icon: typeof CodeIcon; + to: "/code" | "/website"; + isActive: (pathname: string) => boolean; +}; + +// Slack-like app rail switching between top-level "spaces": Code (the existing +// task app) and Channels (the website space with its channel list + dashboards). +// Gated behind project-bluebird in __root. +const NAV_ITEMS: AppNavItem[] = [ + { + id: "code", + label: "Code", + icon: CodeIcon, + to: "/code", + isActive: (pathname) => + pathname === "/code" || pathname.startsWith("/code/"), + }, + { + id: "channels", + label: "Channels", + icon: HashIcon, + to: "/website", + isActive: (pathname) => + pathname === "/website" || pathname.startsWith("/website/"), + }, +]; + +export function AppNav() { + const navigate = useNavigate(); + const pathname = useRouterState({ select: (s) => s.location.pathname }); + + return ( + + {NAV_ITEMS.map((item) => { + const active = item.isActive(pathname); + const Icon = item.icon; + return ( + + + + ); + })} + + ); +} diff --git a/apps/code/src/renderer/features/canvas/AGENTS.md b/apps/code/src/renderer/features/canvas/AGENTS.md new file mode 100644 index 0000000000..97d0364d3b --- /dev/null +++ b/apps/code/src/renderer/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/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} + + )} + + + + + +