From cd97a1f7bc35e049b73ba414756d2ef6555e90bc Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Thu, 4 Jun 2026 05:37:35 +0100 Subject: [PATCH 01/24] feat(code): add Slack-like canvas nav rail with Home and Code spaces Wrap the app in a left app rail (square Quill icon-lg buttons) switching between a new Home space (/ hello-world scene + its own sidenav) and the existing Code app (/code). Rail reserves macOS traffic-light space and is a titlebar drag region. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../features/canvas/components/CanvasNav.tsx | 74 +++++++++++++++++++ .../canvas/components/HomeSidebar.tsx | 22 ++++++ apps/code/src/renderer/routes/__root.tsx | 55 ++++++++++---- apps/code/src/renderer/routes/index.tsx | 22 +++++- 4 files changed, 154 insertions(+), 19 deletions(-) create mode 100644 apps/code/src/renderer/features/canvas/components/CanvasNav.tsx create mode 100644 apps/code/src/renderer/features/canvas/components/HomeSidebar.tsx diff --git a/apps/code/src/renderer/features/canvas/components/CanvasNav.tsx b/apps/code/src/renderer/features/canvas/components/CanvasNav.tsx new file mode 100644 index 000000000..601d9357c --- /dev/null +++ b/apps/code/src/renderer/features/canvas/components/CanvasNav.tsx @@ -0,0 +1,74 @@ +import { CodeIcon, HouseIcon } from "@phosphor-icons/react"; +import { Button } from "@posthog/quill"; +import { Flex } from "@radix-ui/themes"; +import { useNavigate, useRouterState } from "@tanstack/react-router"; +import { isMac } from "@utils/platform"; + +// macOS draws the traffic lights over the top-left of the window +// (titleBarStyle: hiddenInset). Reserve space so the first rail button clears +// them. +const MAC_TRAFFIC_LIGHT_INSET = 28; + +type CanvasNavItem = { + id: "home" | "code"; + label: string; + icon: typeof HouseIcon; + to: "/" | "/code"; + isActive: (pathname: string) => boolean; +}; + +// Slack-like app rail. Each entry switches between top-level "spaces": Home +// (the / hello-world scene with its own sidenav) and Code (the existing +// /code app). New spaces register here. +const NAV_ITEMS: CanvasNavItem[] = [ + { + id: "home", + label: "Home", + icon: HouseIcon, + to: "/", + isActive: (pathname) => pathname === "/", + }, + { + id: "code", + label: "Code", + icon: CodeIcon, + to: "/code", + isActive: (pathname) => + pathname === "/code" || pathname.startsWith("/code/"), + }, +]; + +export function CanvasNav() { + 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/components/HomeSidebar.tsx b/apps/code/src/renderer/features/canvas/components/HomeSidebar.tsx new file mode 100644 index 000000000..8ede0dbfe --- /dev/null +++ b/apps/code/src/renderer/features/canvas/components/HomeSidebar.tsx @@ -0,0 +1,22 @@ +import { Box, Flex, Text } from "@radix-ui/themes"; + +// Home space sidenav. Mirrors the code app's MainSidebar slot but is tied to +// the / route. Intentionally minimal for now — fills out as the Home space +// grows. +export function HomeSidebar() { + return ( + + + + Home + + + Your space overview + + + + ); +} diff --git a/apps/code/src/renderer/routes/__root.tsx b/apps/code/src/renderer/routes/__root.tsx index bf8750166..d8f199372 100644 --- a/apps/code/src/renderer/routes/__root.tsx +++ b/apps/code/src/renderer/routes/__root.tsx @@ -3,6 +3,8 @@ import { HedgehogMode } from "@components/HedgehogMode"; import { KeyboardShortcutsSheet } from "@components/KeyboardShortcutsSheet"; import { SpaceSwitcher } from "@components/SpaceSwitcher"; import { UsageLimitModal } from "@features/billing/components/UsageLimitModal"; +import { CanvasNav } from "@features/canvas/components/CanvasNav"; +import { HomeSidebar } from "@features/canvas/components/HomeSidebar"; import { CommandMenu } from "@features/command/components/CommandMenu"; import { useInboxDeepLink } from "@features/inbox/hooks/useInboxDeepLink"; import { useSetupDiscovery } from "@features/setup/hooks/useSetupDiscovery"; @@ -140,6 +142,12 @@ function RootLayout() { select: (s) => s.matches.some((m) => m.routeId.startsWith("/settings")), }); + // Home space (the / route) gets its own sidenav + hello-world scene instead + // of the code app chrome (header/sidebar/space-switcher). + const isHomeRoute = useRouterState({ + select: (s) => s.matches.some((m) => m.routeId === "/"), + }); + if (isSettingsRoute) { return ( @@ -164,23 +172,40 @@ function RootLayout() { } return ( - - - - - - - + + + + {isHomeRoute ? ( + + + + + + + ) : ( + <> + + + + + + + + + + + )} - { - throw redirect({ to: "/code" }); - }, + component: HomeRoute, }); + +function HomeRoute() { + return ( + + Hello world + Welcome to your Home space. + + ); +} From 93969b43afa39810825174dd47be151d7df22fd5 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Thu, 4 Jun 2026 05:54:42 +0100 Subject: [PATCH 02/24] feat(code): add Home space canvas nav and blank /website canvas Give the Home sidenav Quill folder collapsibles with a placeholder nav (Features, Resources). The Features > Website item opens a new blank /website canvas route. Centralize Home-space detection in canvas/spaces.ts so the whole space shares its chrome. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../canvas/components/BlankCanvas.tsx | 7 ++ .../features/canvas/components/CanvasNav.tsx | 3 +- .../canvas/components/HomeSidebar.tsx | 84 +++++++++++++++++-- .../src/renderer/features/canvas/spaces.ts | 7 ++ apps/code/src/renderer/routeTree.gen.ts | 21 +++++ apps/code/src/renderer/routes/__root.tsx | 7 +- apps/code/src/renderer/routes/website.tsx | 6 ++ 7 files changed, 123 insertions(+), 12 deletions(-) create mode 100644 apps/code/src/renderer/features/canvas/components/BlankCanvas.tsx create mode 100644 apps/code/src/renderer/features/canvas/spaces.ts create mode 100644 apps/code/src/renderer/routes/website.tsx diff --git a/apps/code/src/renderer/features/canvas/components/BlankCanvas.tsx b/apps/code/src/renderer/features/canvas/components/BlankCanvas.tsx new file mode 100644 index 000000000..fd4850fa2 --- /dev/null +++ b/apps/code/src/renderer/features/canvas/components/BlankCanvas.tsx @@ -0,0 +1,7 @@ +import { Box } from "@radix-ui/themes"; + +// A blank canvas surface. Intentionally empty — the starting point for canvas +// content to be built on top of. +export function BlankCanvas() { + return ; +} diff --git a/apps/code/src/renderer/features/canvas/components/CanvasNav.tsx b/apps/code/src/renderer/features/canvas/components/CanvasNav.tsx index 601d9357c..583a71da0 100644 --- a/apps/code/src/renderer/features/canvas/components/CanvasNav.tsx +++ b/apps/code/src/renderer/features/canvas/components/CanvasNav.tsx @@ -1,3 +1,4 @@ +import { isHomeSpacePath } from "@features/canvas/spaces"; import { CodeIcon, HouseIcon } from "@phosphor-icons/react"; import { Button } from "@posthog/quill"; import { Flex } from "@radix-ui/themes"; @@ -26,7 +27,7 @@ const NAV_ITEMS: CanvasNavItem[] = [ label: "Home", icon: HouseIcon, to: "/", - isActive: (pathname) => pathname === "/", + isActive: (pathname) => isHomeSpacePath(pathname), }, { id: "code", diff --git a/apps/code/src/renderer/features/canvas/components/HomeSidebar.tsx b/apps/code/src/renderer/features/canvas/components/HomeSidebar.tsx index 8ede0dbfe..bf745ad32 100644 --- a/apps/code/src/renderer/features/canvas/components/HomeSidebar.tsx +++ b/apps/code/src/renderer/features/canvas/components/HomeSidebar.tsx @@ -1,21 +1,89 @@ +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@posthog/quill"; import { Box, Flex, Text } from "@radix-ui/themes"; +import { useNavigate, useRouterState } from "@tanstack/react-router"; + +// `to` is a literal union of real canvas routes so navigate() stays type-safe. +// Items without `to` are placeholders (fake nav) and don't navigate yet. Widen +// the union as canvas routes are added. +type HomeNavRoute = "/website"; + +type HomeNavItem = { + id: string; + label: string; + to?: HomeNavRoute; +}; + +type HomeNavGroup = { + id: string; + label: string; + items: HomeNavItem[]; +}; + +const HOME_NAV: HomeNavGroup[] = [ + { + id: "features", + label: "Features", + items: [ + { id: "website", label: "Website", to: "/website" }, + { id: "app", label: "App" }, + { id: "mobile", label: "Mobile" }, + ], + }, + { + id: "resources", + label: "Resources", + items: [ + { id: "docs", label: "Docs" }, + { id: "changelog", label: "Changelog" }, + ], + }, +]; -// Home space sidenav. Mirrors the code app's MainSidebar slot but is tied to -// the / route. Intentionally minimal for now — fills out as the Home space -// grows. export function HomeSidebar() { + const navigate = useNavigate(); + const pathname = useRouterState({ select: (s) => s.location.pathname }); + return ( - - + + Home - - Your space overview - + + {HOME_NAV.map((group) => ( + + {group.label} + + + {group.items.map((item) => { + const active = item.to != null && pathname === item.to; + return ( + + ); + })} + + + + ))} ); diff --git a/apps/code/src/renderer/features/canvas/spaces.ts b/apps/code/src/renderer/features/canvas/spaces.ts new file mode 100644 index 000000000..0d7057a22 --- /dev/null +++ b/apps/code/src/renderer/features/canvas/spaces.ts @@ -0,0 +1,7 @@ +// Home space route paths. The canvas nav's Home button and the root layout's +// "render the Home sidenav" branch both key off this so the whole Home space +// (the / scene plus its canvas routes) shares one chrome. Extend as canvas +// routes are added. +export function isHomeSpacePath(pathname: string): boolean { + return pathname === "/" || pathname.startsWith("/website"); +} diff --git a/apps/code/src/renderer/routeTree.gen.ts b/apps/code/src/renderer/routeTree.gen.ts index e6d4d9846..ffa855766 100644 --- a/apps/code/src/renderer/routeTree.gen.ts +++ b/apps/code/src/renderer/routeTree.gen.ts @@ -9,6 +9,7 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as WebsiteRouteImport } from './routes/website' import { Route as SkillsRouteImport } from './routes/skills' import { Route as McpServersRouteImport } from './routes/mcp-servers' import { Route as CommandCenterRouteImport } from './routes/command-center' @@ -22,6 +23,11 @@ import { Route as CodeArchivedRouteImport } from './routes/code/archived' import { Route as CodeTasksTaskIdRouteImport } from './routes/code/tasks/$taskId' import { Route as CodeTasksPendingKeyRouteImport } from './routes/code/tasks/pending.$key' +const WebsiteRoute = WebsiteRouteImport.update({ + id: '/website', + path: '/website', + getParentRoute: () => rootRouteImport, +} as any) const SkillsRoute = SkillsRouteImport.update({ id: '/skills', path: '/skills', @@ -88,6 +94,7 @@ export interface FileRoutesByFullPath { '/command-center': typeof CommandCenterRoute '/mcp-servers': typeof McpServersRoute '/skills': typeof SkillsRoute + '/website': typeof WebsiteRoute '/code/archived': typeof CodeArchivedRoute '/code/inbox': typeof CodeInboxRoute '/folders/$folderId': typeof FoldersFolderIdRoute @@ -102,6 +109,7 @@ export interface FileRoutesByTo { '/command-center': typeof CommandCenterRoute '/mcp-servers': typeof McpServersRoute '/skills': typeof SkillsRoute + '/website': typeof WebsiteRoute '/code/archived': typeof CodeArchivedRoute '/code/inbox': typeof CodeInboxRoute '/folders/$folderId': typeof FoldersFolderIdRoute @@ -117,6 +125,7 @@ export interface FileRoutesById { '/command-center': typeof CommandCenterRoute '/mcp-servers': typeof McpServersRoute '/skills': typeof SkillsRoute + '/website': typeof WebsiteRoute '/code/archived': typeof CodeArchivedRoute '/code/inbox': typeof CodeInboxRoute '/folders/$folderId': typeof FoldersFolderIdRoute @@ -133,6 +142,7 @@ export interface FileRouteTypes { | '/command-center' | '/mcp-servers' | '/skills' + | '/website' | '/code/archived' | '/code/inbox' | '/folders/$folderId' @@ -147,6 +157,7 @@ export interface FileRouteTypes { | '/command-center' | '/mcp-servers' | '/skills' + | '/website' | '/code/archived' | '/code/inbox' | '/folders/$folderId' @@ -161,6 +172,7 @@ export interface FileRouteTypes { | '/command-center' | '/mcp-servers' | '/skills' + | '/website' | '/code/archived' | '/code/inbox' | '/folders/$folderId' @@ -176,6 +188,7 @@ export interface RootRouteChildren { CommandCenterRoute: typeof CommandCenterRoute McpServersRoute: typeof McpServersRoute SkillsRoute: typeof SkillsRoute + WebsiteRoute: typeof WebsiteRoute CodeArchivedRoute: typeof CodeArchivedRoute CodeInboxRoute: typeof CodeInboxRoute FoldersFolderIdRoute: typeof FoldersFolderIdRoute @@ -188,6 +201,13 @@ export interface RootRouteChildren { declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/website': { + id: '/website' + path: '/website' + fullPath: '/website' + preLoaderRoute: typeof WebsiteRouteImport + parentRoute: typeof rootRouteImport + } '/skills': { id: '/skills' path: '/skills' @@ -280,6 +300,7 @@ const rootRouteChildren: RootRouteChildren = { CommandCenterRoute: CommandCenterRoute, McpServersRoute: McpServersRoute, SkillsRoute: SkillsRoute, + WebsiteRoute: WebsiteRoute, CodeArchivedRoute: CodeArchivedRoute, CodeInboxRoute: CodeInboxRoute, FoldersFolderIdRoute: FoldersFolderIdRoute, diff --git a/apps/code/src/renderer/routes/__root.tsx b/apps/code/src/renderer/routes/__root.tsx index d8f199372..5c4712676 100644 --- a/apps/code/src/renderer/routes/__root.tsx +++ b/apps/code/src/renderer/routes/__root.tsx @@ -5,6 +5,7 @@ import { SpaceSwitcher } from "@components/SpaceSwitcher"; import { UsageLimitModal } from "@features/billing/components/UsageLimitModal"; import { CanvasNav } from "@features/canvas/components/CanvasNav"; import { HomeSidebar } from "@features/canvas/components/HomeSidebar"; +import { isHomeSpacePath } from "@features/canvas/spaces"; import { CommandMenu } from "@features/command/components/CommandMenu"; import { useInboxDeepLink } from "@features/inbox/hooks/useInboxDeepLink"; import { useSetupDiscovery } from "@features/setup/hooks/useSetupDiscovery"; @@ -142,10 +143,10 @@ function RootLayout() { select: (s) => s.matches.some((m) => m.routeId.startsWith("/settings")), }); - // Home space (the / route) gets its own sidenav + hello-world scene instead - // of the code app chrome (header/sidebar/space-switcher). + // Home space routes get their own sidenav + canvas scenes instead of the code + // app chrome (header/sidebar/space-switcher). const isHomeRoute = useRouterState({ - select: (s) => s.matches.some((m) => m.routeId === "/"), + select: (s) => isHomeSpacePath(s.location.pathname), }); if (isSettingsRoute) { diff --git a/apps/code/src/renderer/routes/website.tsx b/apps/code/src/renderer/routes/website.tsx new file mode 100644 index 000000000..e7349cf68 --- /dev/null +++ b/apps/code/src/renderer/routes/website.tsx @@ -0,0 +1,6 @@ +import { BlankCanvas } from "@features/canvas/components/BlankCanvas"; +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/website")({ + component: BlankCanvas, +}); From 8044626af17d02e651c35e02e3cca33fd5de0875 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Thu, 4 Jun 2026 06:18:18 +0100 Subject: [PATCH 03/24] feat(code): generative UI canvas with PostHog data agent on /website MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add @json-render/core + @json-render/react. The /website canvas now renders an agent-built, data-driven UI on the left with a chat panel hugging the right. - Main CanvasGenService reuses AgentService (PostHog MCP auto-enabled) to run an ephemeral __preview__ session per thread with a json-render system prompt and bypassPermissions, forwards ACP session updates through @json-render/core's mixed-stream parser to assemble the Spec, and streams typed events over tRPC. - AgentService gains systemPromptOverride to replace the coding-agent prompt for non-coding surfaces (keeps only project scoping). - Renderer: shared json-render catalog (Page/Grid/Card/Stat/Table/BarList/…) + Radix registry, a thin canvasChatStore, a scoped subscription registrar, and the chat/composer UI. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/code/package.json | 2 + apps/code/src/main/di/container.ts | 2 + apps/code/src/main/di/tokens.ts | 1 + apps/code/src/main/services/agent/schemas.ts | 6 + apps/code/src/main/services/agent/service.ts | 20 +- .../src/main/services/canvas-gen/schemas.ts | 48 ++++ .../src/main/services/canvas-gen/service.ts | 208 ++++++++++++++++++ apps/code/src/main/trpc/router.ts | 2 + apps/code/src/main/trpc/routers/canvas-gen.ts | 34 +++ .../canvas/components/BlankCanvas.tsx | 7 - .../features/canvas/components/CanvasChat.tsx | 129 +++++++++++ .../canvas/components/WebsiteCanvas.tsx | 46 ++++ .../renderer/features/canvas/genui/catalog.ts | 83 +++++++ .../features/canvas/genui/registry.tsx | 127 +++++++++++ .../features/canvas/stores/canvasChatStore.ts | 127 +++++++++++ .../renderer/features/canvas/subscriptions.ts | 43 ++++ apps/code/src/renderer/routes/website.tsx | 4 +- pnpm-lock.yaml | 27 +++ 18 files changed, 906 insertions(+), 10 deletions(-) create mode 100644 apps/code/src/main/services/canvas-gen/schemas.ts create mode 100644 apps/code/src/main/services/canvas-gen/service.ts create mode 100644 apps/code/src/main/trpc/routers/canvas-gen.ts delete mode 100644 apps/code/src/renderer/features/canvas/components/BlankCanvas.tsx create mode 100644 apps/code/src/renderer/features/canvas/components/CanvasChat.tsx create mode 100644 apps/code/src/renderer/features/canvas/components/WebsiteCanvas.tsx create mode 100644 apps/code/src/renderer/features/canvas/genui/catalog.ts create mode 100644 apps/code/src/renderer/features/canvas/genui/registry.tsx create mode 100644 apps/code/src/renderer/features/canvas/stores/canvasChatStore.ts create mode 100644 apps/code/src/renderer/features/canvas/subscriptions.ts diff --git a/apps/code/package.json b/apps/code/package.json index 44fe62ace..f93d2992b 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 b2e237941..6f371c8eb 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -31,6 +31,7 @@ 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"; @@ -114,6 +115,7 @@ 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.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 69ea894b3..f5b388afb 100644 --- a/apps/code/src/main/di/tokens.ts +++ b/apps/code/src/main/di/tokens.ts @@ -48,6 +48,7 @@ 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"), 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 410d77ea5..30c4ab0ed 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 ab5f371bc..00707d42e 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 000000000..4e630cc78 --- /dev/null +++ b/apps/code/src/main/services/canvas-gen/schemas.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; + +// Input for generating / extending a canvas from a chat prompt. +export const canvasGenerateInput = z.object({ + threadId: z.string().min(1), + prompt: z.string().min(1), + /** + * The json-render system prompt describing the component catalog. Computed in + * the renderer from the shared catalog and applied once when the ephemeral + * agent session for this thread is created. + */ + systemPrompt: z.string().min(1), + model: z.string().optional(), +}); +export type CanvasGenerateInput = z.infer; + +export const canvasThreadInput = z.object({ threadId: z.string().min(1) }); +export type CanvasThreadInput = z.infer; + +// Events streamed to the renderer as the agent responds. `spec` carries the +// full assembled json-render Spec snapshot after each applied JSONL patch. +export const canvasStreamEventSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("started") }), + z.object({ type: z.literal("prose"), text: z.string() }), + z.object({ + type: z.literal("spec"), + spec: z.record(z.string(), z.unknown()), + }), + z.object({ + type: z.literal("tool"), + toolName: z.string(), + status: z.string(), + }), + z.object({ type: z.literal("done") }), + z.object({ type: z.literal("error"), message: z.string() }), +]); +export type CanvasStreamEvent = z.infer; + +export const CanvasGenEvent = { Event: "canvas-event" } as const; + +export interface CanvasGenEventPayload { + threadId: string; + event: CanvasStreamEvent; +} + +export interface CanvasGenEvents { + [CanvasGenEvent.Event]: CanvasGenEventPayload; +} diff --git a/apps/code/src/main/services/canvas-gen/service.ts b/apps/code/src/main/services/canvas-gen/service.ts new file mode 100644 index 000000000..ded00e430 --- /dev/null +++ b/apps/code/src/main/services/canvas-gen/service.ts @@ -0,0 +1,208 @@ +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); + if (typeof state.spec.root === "string" && state.spec.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/trpc/router.ts b/apps/code/src/main/trpc/router.ts index f0f8dd9eb..14d8be1b8 100644 --- a/apps/code/src/main/trpc/router.ts +++ b/apps/code/src/main/trpc/router.ts @@ -3,6 +3,7 @@ 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"; @@ -46,6 +47,7 @@ export const trpcRouter = router({ analytics: analyticsRouter, archive: archiveRouter, auth: authRouter, + canvasGen: canvasGenRouter, cloudTask: cloudTaskRouter, connectivity: connectivityRouter, contextMenu: contextMenuRouter, diff --git a/apps/code/src/main/trpc/routers/canvas-gen.ts b/apps/code/src/main/trpc/routers/canvas-gen.ts new file mode 100644 index 000000000..aaca2210c --- /dev/null +++ b/apps/code/src/main/trpc/routers/canvas-gen.ts @@ -0,0 +1,34 @@ +import { container } from "../../di/container"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { + CanvasGenEvent, + canvasGenerateInput, + canvasThreadInput, +} from "../../services/canvas-gen/schemas"; +import type { CanvasGenService } from "../../services/canvas-gen/service"; +import { publicProcedure, router } from "../trpc"; + +const getService = () => + container.get(MAIN_TOKENS.CanvasGenService); + +export const canvasGenRouter = router({ + generate: publicProcedure + .input(canvasGenerateInput) + .mutation(({ input }) => getService().generate(input)), + reset: publicProcedure + .input(canvasThreadInput) + .mutation(({ input }) => getService().reset(input)), + onEvent: publicProcedure + .input(canvasThreadInput) + .subscription(async function* (opts) { + const service = getService(); + const iterable = service.toIterable(CanvasGenEvent.Event, { + signal: opts.signal, + }); + for await (const payload of iterable) { + if (payload.threadId === opts.input.threadId) { + yield payload.event; + } + } + }), +}); diff --git a/apps/code/src/renderer/features/canvas/components/BlankCanvas.tsx b/apps/code/src/renderer/features/canvas/components/BlankCanvas.tsx deleted file mode 100644 index fd4850fa2..000000000 --- a/apps/code/src/renderer/features/canvas/components/BlankCanvas.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import { Box } from "@radix-ui/themes"; - -// A blank canvas surface. Intentionally empty — the starting point for canvas -// content to be built on top of. -export function BlankCanvas() { - return ; -} 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 000000000..98627d9f1 --- /dev/null +++ b/apps/code/src/renderer/features/canvas/components/CanvasChat.tsx @@ -0,0 +1,129 @@ +import { useCanvasChatStore } 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() { + const messages = useCanvasChatStore((s) => s.messages); + const isStreaming = useCanvasChatStore((s) => s.isStreaming); + const lastTool = useCanvasChatStore((s) => s.lastTool); + const error = useCanvasChatStore((s) => s.error); + 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(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} + + )} + + + + + +