Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
79e539d
feat(code): add Slack-like canvas nav rail with Home and Code spaces
adamleithp Jun 4, 2026
e7347ee
feat(code): add Home space canvas nav and blank /website canvas
adamleithp Jun 4, 2026
18da1da
feat(code): generative UI canvas with PostHog data agent on /website
adamleithp Jun 4, 2026
e7d2572
fix(code): harden canvas rendering against mid-stream crashes
adamleithp Jun 4, 2026
514be0c
feat(code): add top-level Inbox space in canvas nav
adamleithp Jun 4, 2026
5db48b0
feat(code): show inbox count badge on canvas nav Inbox item
adamleithp Jun 4, 2026
606c6cb
feat(code): add Website space sub-nav, new task, and task views
adamleithp Jun 4, 2026
d5184ab
refactor(code): use Quill buttons for Home sidebar nav items
adamleithp Jun 4, 2026
2e4f15f
feat(code): website dashboards with breadcrumb dashboard picker
adamleithp Jun 4, 2026
641ed20
feat(code): per-dashboard edit mode with gen-UI chat
adamleithp Jun 4, 2026
7d461b1
style(code): indent Website sub-nav items
adamleithp Jun 4, 2026
a199131
fix(code): always show all dashboards in the picker
adamleithp Jun 4, 2026
1a45cfe
feat(code): show dashboard count badge on Dashboards nav item
adamleithp Jun 4, 2026
87b1bff
feat(code): file-backed json-render dashboards with save/fork
adamleithp Jun 4, 2026
8b8202e
fix(code): surface dashboard creation errors instead of silent no-op
adamleithp Jun 4, 2026
7c60e82
feat(code): name dashboards via save dialog or inline rename
adamleithp Jun 4, 2026
c9f4e15
revert(code): drop dashboard name dialog + inline rename
adamleithp Jun 4, 2026
b82ac18
feat(code): dashboard refresh + polling control
adamleithp Jun 4, 2026
56edb85
fix(code): valid menu group for polling label + spin refresh while fe…
adamleithp Jun 4, 2026
084fe2b
docs(code): add canvas/dashboards progress + MVP gaps
adamleithp Jun 4, 2026
5e88867
feat(code): gate canvas feature behind project-bluebird flag
adamleithp Jun 4, 2026
aa83361
feat(code): dashboard grid index with previews, delete, and nav polish
adamleithp Jun 4, 2026
9396b62
fix(code): seed canvas thread with saved spec on edit
adamleithp Jun 4, 2026
75165e7
feat(code): channel-scoped Home space (channels, dashboards, tasks, s…
adamleithp Jun 4, 2026
01a78d0
feat(code): dashboard direct-manipulation, refreshable data, hardening
adamleithp Jun 5, 2026
01dea17
refactor(code): fold channels into the code sidebar; drop the nav rail
adamleithp Jun 5, 2026
177a1e2
refactor(code): pin sidebar tabs above scroll, tighten nav gap
adamleithp Jun 8, 2026
d6f554c
feat(canvas): h1 title names the dashboard, backed by desktop file sy…
adamleithp Jun 8, 2026
77b851b
feat(canvas): move dashboard breadcrumbs into the global title bar
adamleithp Jun 8, 2026
fea7b90
style(sidebar): tidy channel tab list spacing and background
adamleithp Jun 8, 2026
cf0e66c
feat(canvas): drop dashboard-name crumb, restore header padding, add …
adamleithp Jun 8, 2026
878d6b1
fix(canvas): show Dashboards crumb on a board, not on the grid
adamleithp Jun 8, 2026
cd63f59
refactor(dashboards): drop dead adoptOrphans, type the FS meta blob
adamleithp Jun 8, 2026
7a00f15
feat(code): restore app nav rail (Code/Inbox/Channels), drop sidebar …
adamleithp Jun 8, 2026
6635b5d
fix(code): add a top bar above the channels nav to align with the outlet
adamleithp Jun 8, 2026
56b4ae2
feat(canvas): move new-channel action to a pinned bottom button
adamleithp Jun 8, 2026
d466fbf
refactor(canvas): remove dashboard drag-and-drop editing
adamleithp Jun 8, 2026
d47eba1
feat(code): remove Inbox from the app nav rail
adamleithp Jun 8, 2026
1e05d62
feat(canvas): channel rename modal, faded # in breadcrumb, fit-conten…
adamleithp Jun 8, 2026
3aae0a7
feat(canvas): clickable breadcrumbs as quill buttons for hover state
adamleithp Jun 8, 2026
b4dc63a
feat(canvas): split channel chrome into crumb bar + toolbar
adamleithp Jun 8, 2026
cf5a98b
feat(canvas): rebalance channel toolbars; Dashboards becomes a crumb
adamleithp Jun 8, 2026
ba18cc4
fix(canvas): show the Filter toolbar only on dashboards and sub-routes
adamleithp Jun 8, 2026
6c578d8
feat(canvas): nest channel sessions under a Sessions collapsible
adamleithp Jun 8, 2026
51b4508
feat(canvas): add + icon to the Sessions "New task" button
adamleithp Jun 8, 2026
0cfbef1
feat(canvas): rename the channel "Sessions" group to "Tasks"
adamleithp Jun 8, 2026
488a582
feat(canvas): Quill charts + dashboard-aware gen-UI agent
adamleithp Jun 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions CANVAS_MVP.md
Original file line number Diff line number Diff line change
@@ -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
`<appData>/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`): `<channel> › Dashboards [› <name>]` /
`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: "<HogQL>" }` (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"`.
5 changes: 4 additions & 1 deletion apps/code/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -142,7 +144,8 @@
"@posthog/git": "workspace:*",
"@posthog/hedgehog-mode": "^0.0.48",
"@posthog/platform": "workspace:*",
"@posthog/quill": "0.3.0-beta.1",
"@posthog/quill": "0.3.0-beta.14",
"@posthog/quill-charts": "0.3.0-beta.14",
"@posthog/shared": "workspace:*",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-icons": "^1.3.2",
Expand Down
6 changes: 6 additions & 0 deletions apps/code/src/main/di/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,12 @@ 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 { 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";
Expand Down Expand Up @@ -114,6 +117,9 @@ 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.ConnectivityService).to(ConnectivityService);
container.bind(MAIN_TOKENS.ContextMenuService).to(ContextMenuService);
container.bind(MAIN_TOKENS.DeepLinkService).to(DeepLinkService);
Expand Down
3 changes: 3 additions & 0 deletions apps/code/src/main/di/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ 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"),
ConnectivityService: Symbol.for("Main.ConnectivityService"),
ContextMenuService: Symbol.for("Main.ContextMenuService"),

Expand Down
12 changes: 12 additions & 0 deletions apps/code/src/main/services/agent/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof startSessionInput>;
Expand Down
29 changes: 28 additions & 1 deletion apps/code/src/main/services/agent/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
Expand All @@ -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<string, unknown> | 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 {
Expand Down Expand Up @@ -474,10 +482,20 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
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 += `

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
};
}

Expand Down
48 changes: 48 additions & 0 deletions apps/code/src/main/services/canvas-gen/schemas.ts
Original file line number Diff line number Diff line change
@@ -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<typeof canvasGenerateInput>;

export const canvasThreadInput = z.object({ threadId: z.string().min(1) });
export type CanvasThreadInput = z.infer<typeof canvasThreadInput>;

// 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<typeof canvasStreamEventSchema>;

export const CanvasGenEvent = { Event: "canvas-event" } as const;

export interface CanvasGenEventPayload {
threadId: string;
event: CanvasStreamEvent;
}

export interface CanvasGenEvents {
[CanvasGenEvent.Event]: CanvasGenEventPayload;
}
Loading
Loading