From c695557da6700809d253ddb7c5c2c94eeb4a0512 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Thu, 18 Jun 2026 13:45:21 -0700 Subject: [PATCH 1/6] feat(channels): auto-create a home canvas per channel Every channel now gets a freeform "Home" canvas, created and seeded on channel create (and lazily backfilled on first open). It lists the channel's canvases, a stubbed inbox, and filed tasks (newest first), each paged via ph.query against the new system.filesystem HogQL table with scroll-to-load-more. Clicking the channel name opens it in the main pane; the leading icon still toggles the sidebar tree. - DashboardsService.ensureHomeCanvas: create freeform -> seed TSX -> record homeCanvasId on the channel folder meta (idempotent). - buildHomeCanvasCode: seeded React; channelId baked, folder path resolved at runtime so renames stay safe. - Surface homeCanvasId on Channel; useOpenHomeCanvas with backfill. - Split the sidebar channel header into caret-toggle + name-opens-home. Generated-By: PostHog Code Task-Id: b157bf67-209e-4c6c-afee-a2df5f3c3067 --- packages/core/src/canvas/dashboardSchemas.ts | 8 + .../core/src/canvas/dashboardsService.test.ts | 125 +++++++ packages/core/src/canvas/dashboardsService.ts | 346 ++++++++++++++++++ packages/core/src/canvas/services.ts | 1 + .../src/routers/dashboards.router.ts | 9 + .../canvas/components/ChannelsList.tsx | 66 ++-- .../canvas/components/CreateChannelModal.tsx | 17 +- .../src/features/canvas/hooks/useChannels.ts | 17 +- .../features/canvas/hooks/useDashboards.ts | 46 +++ 9 files changed, 600 insertions(+), 35 deletions(-) diff --git a/packages/core/src/canvas/dashboardSchemas.ts b/packages/core/src/canvas/dashboardSchemas.ts index bf6c1f1ac9..0a40a4f179 100644 --- a/packages/core/src/canvas/dashboardSchemas.ts +++ b/packages/core/src/canvas/dashboardSchemas.ts @@ -57,6 +57,10 @@ export const dashboardFileMetaSchema = z.object({ // the FileSystem row has no updated_at column to sort the dashboards list by. createdAt: z.number().optional(), updatedAt: z.number().optional(), + // Channel folders only: the file-system id of the channel's home canvas (the + // auto-created freeform board shown when the channel name is clicked). Stored + // on the folder's meta because the FileSystem model has no column for it. + homeCanvasId: z.string().optional(), }); export type DashboardFileMeta = z.infer; @@ -102,6 +106,10 @@ export const saveFreeformInput = z.object({ export const dashboardIdInput = z.object({ id: z.string().min(1) }); +export const ensureHomeCanvasInput = z.object({ + channelId: z.string().min(1), +}); + // The active time window a dashboard's time-based queries run against. `from` // and `to` are epoch ms; `name` is the picker label (e.g. "Last 7 days"). Stored // on the spec under `state.dateRange` so it survives reload and the toolbar diff --git a/packages/core/src/canvas/dashboardsService.test.ts b/packages/core/src/canvas/dashboardsService.test.ts index cb5190ef15..b834b719d5 100644 --- a/packages/core/src/canvas/dashboardsService.test.ts +++ b/packages/core/src/canvas/dashboardsService.test.ts @@ -3,6 +3,12 @@ import type { DashboardQueryService } from "./dashboardQueryService"; import { DashboardsService } from "./dashboardsService"; import type { DesktopFsClient, FsEntryBase } from "./desktopFsClient"; +// ensureHomeCanvas fetches the signed-in user's label via posthogApi; stub it so +// the service doesn't reach the network in tests. +vi.mock("./posthogApi", () => ({ + fetchCurrentUser: vi.fn(async () => ({ label: "Tester" })), +})); + // A dashboard FS row carrying our payload under `meta`, as the backend returns it. function dashboardRow( id: string, @@ -68,3 +74,122 @@ describe("DashboardsService.list", () => { expect(result[0]).toMatchObject({ name: "Newer", channelId: "chan-1" }); }); }); + +// A stateful fake exposing getEntry + fetch, enough for create/saveFreeform/PATCH. +// POST "" assigns an id and stores the row; PATCH "/" merges meta/path. +function statefulFs(initial: Record>) { + const entries: Record> = { ...initial }; + let seq = 0; + const fetch = vi.fn( + async (suffix: string, init?: { method?: string; body?: string }) => { + const method = init?.method ?? "GET"; + const body = init?.body ? JSON.parse(init.body) : undefined; + if (suffix === "" && method === "POST") { + const id = `new-${++seq}`; + const entry = { + id, + path: body.path, + type: body.type, + meta: body.meta ?? {}, + }; + entries[id] = entry; + return { ok: true, status: 200, json: async () => entry } as Response; + } + const id = decodeURIComponent(suffix.replace(/\/$/, "")); + const prev = entries[id] ?? { id, path: "", meta: {} }; + const next = { ...prev }; + if (body?.meta) next.meta = body.meta; + if (body?.path) next.path = body.path; + entries[id] = next; + return { ok: true, status: 200, json: async () => next } as Response; + }, + ); + const getEntry = vi.fn(async (id: string) => entries[id] ?? null); + const fs = { getEntry, fetch } as unknown as DesktopFsClient; + return { fs, fetch, entries }; +} + +describe("DashboardsService.ensureHomeCanvas", () => { + it("creates + seeds a freeform canvas and records it on the channel folder", async () => { + const { fs, entries } = statefulFs({ + "chan-1": { + id: "chan-1", + path: "marketing", + type: "folder", + meta: {}, + }, + }); + const service = new DashboardsService( + fs, + {} as DashboardQueryService, + {} as never, + ); + + const record = await service.ensureHomeCanvas("chan-1"); + + // The freeform canvas was created under the channel folder. + expect(record.id).toBe("new-1"); + expect(record.kind).toBe("freeform"); + expect(entries["new-1"]?.path).toBe("marketing/Home"); + + // Its seeded source queries the filesystem system table and bakes both ids. + const meta = entries["new-1"]?.meta as { code?: string }; + expect(meta.code).toContain("system.filesystem"); + expect(meta.code).toContain("chan-1"); + expect(meta.code).toContain("new-1"); + + // The channel folder now points at the home canvas. + const folderMeta = entries["chan-1"]?.meta as { homeCanvasId?: string }; + expect(folderMeta.homeCanvasId).toBe("new-1"); + }); + + it("seeds source that transpiles as valid TSX", async () => { + const { fs, entries } = statefulFs({ + "chan-1": { id: "chan-1", path: "marketing", type: "folder", meta: {} }, + }); + const service = new DashboardsService( + fs, + {} as DashboardQueryService, + {} as never, + ); + + await service.ensureHomeCanvas("chan-1"); + const code = (entries["new-1"]?.meta as { code?: string }).code ?? ""; + + // The sandbox transpiles the seeded code with Babel at runtime; mirror that + // here with esbuild so a syntax error is caught in CI, not in the iframe. + const { transform } = await import("esbuild"); + await expect( + transform(code, { loader: "tsx", format: "esm" }), + ).resolves.toBeDefined(); + }); + + it("is idempotent: returns the existing home canvas without creating another", async () => { + const { fs, fetch, entries } = statefulFs({ + "chan-1": { + id: "chan-1", + path: "marketing", + type: "folder", + meta: { homeCanvasId: "home-x" }, + }, + "home-x": { + id: "home-x", + path: "marketing/Home", + type: "dashboard", + meta: { channelId: "chan-1", kind: "freeform", code: "// seeded" }, + }, + }); + const service = new DashboardsService( + fs, + {} as DashboardQueryService, + {} as never, + ); + + const record = await service.ensureHomeCanvas("chan-1"); + + expect(record.id).toBe("home-x"); + // No create/patch happened — the folder already had a live home canvas. + expect(fetch).not.toHaveBeenCalled(); + expect(Object.keys(entries)).toEqual(["chan-1", "home-x"]); + }); +}); diff --git a/packages/core/src/canvas/dashboardsService.ts b/packages/core/src/canvas/dashboardsService.ts index 7cd9248bc8..ee4e790de3 100644 --- a/packages/core/src/canvas/dashboardsService.ts +++ b/packages/core/src/canvas/dashboardsService.ts @@ -22,6 +22,9 @@ import type { DashboardQuery, DashboardQueryShape } from "./querySchemas"; // rows (depth 1); dashboards are these `dashboard` files nested beneath them. const DASHBOARD_TYPE = "dashboard"; +// Display name (canvas h1) of a channel's auto-created home canvas. +const HOME_CANVAS_NAME = "Home"; + // Dashboard-specific shape on top of the shared FS row. Our payload rides in // `meta` — see DashboardFileMeta for what that blob holds. interface FsEntry extends FsEntryBase { @@ -220,6 +223,68 @@ export class DashboardsService { return toRecord((await res.json()) as FsEntry); } + // Ensure the channel has a home canvas: the freeform board shown when the + // channel name is clicked. Idempotent — if the channel folder's meta already + // points at a live canvas, return it; otherwise create one, seed its source, + // and record its id on the folder. Safe to call on channel create and lazily + // on first open (backfills channels made before home canvases existed). + async ensureHomeCanvas(channelId: string): Promise { + const folder = await this.getEntry(channelId); + if (!folder) throw new Error("Channel not found"); + + const existingId = folder.meta?.homeCanvasId; + if (existingId) { + const existing = await this.get(existingId); + if (existing) return existing; + } + + // Create the freeform canvas under the channel, then seed its source. The + // canvas's own id is baked into the code so it can exclude itself from the + // "Canvases" list; the channel id lets it resolve the (rename-safe) folder + // path at runtime. + const record = await this.create({ + channelId, + name: HOME_CANVAS_NAME, + spec: null, + templateId: FREEFORM_TEMPLATE_ID, + }); + const code = buildHomeCanvasCode(channelId, record.id); + const version: FreeformVersion = { + id: `home-${record.id}`, + code, + createdAt: Date.now(), + }; + const saved = await this.saveFreeform({ + id: record.id, + code, + versions: [version], + currentVersionId: version.id, + }); + + await this.setHomeCanvasId(channelId, record.id, folder); + return saved; + } + + // Point a channel folder at its home canvas by writing homeCanvasId onto the + // folder's meta (preserving any existing meta keys). + private async setHomeCanvasId( + channelId: string, + homeCanvasId: string, + folder?: FsEntry | null, + ): Promise { + const entry = folder ?? (await this.getEntry(channelId)); + const prevMeta = entry?.meta ?? {}; + const meta: DashboardFileMeta = { ...prevMeta, homeCanvasId }; + const res = await this.fs.fetch(`${encodeURIComponent(channelId)}/`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ meta }), + }); + if (!res.ok) { + throw new Error(`Failed to set channel home canvas (${res.status})`); + } + } + async delete(id: string): Promise { const res = await this.fs.fetch(`${encodeURIComponent(id)}/`, { method: "DELETE", @@ -316,6 +381,287 @@ export class DashboardsService { } } +// The seeded React source for a channel's home canvas. It runs in the freeform +// sandbox (null-origin iframe), so its only data avenue is `window.ph.query` +// (HogQL). It reads three lists from the `system.filesystem` HogQL table: +// - Canvases: this channel's `dashboard` rows (excluding the home canvas). +// - Inbox / to-dos: stubbed (no data source yet) with an assignee filter. +// - Tasks: this channel's filed `task` rows, newest first. +// Each list shows a page at a time and loads more as its own box is scrolled. +// The "New" buttons are intentionally no-ops until the host wires them up. +// channelId is baked in (the path is resolved at runtime so renames are safe); +// homeCanvasId lets the Canvases list exclude this board. +function buildHomeCanvasCode(channelId: string, homeCanvasId: string): string { + const cid = JSON.stringify(channelId); + const hid = JSON.stringify(homeCanvasId); + return `import { useCallback, useEffect, useRef, useState } from "react"; + +const CHANNEL_ID = ${cid}; +const HOME_CANVAS_ID = ${hid}; +const PAGE_SIZE = 10; + +const ph = (window as any).ph; + +// Single-quote a value for inlining into a HogQL string literal. +function sql(v: string): string { + return "'" + String(v).replace(/'/g, "''") + "'"; +} + +function lastSegment(path: string): string { + const i = path.lastIndexOf("/"); + return i === -1 ? path : path.slice(i + 1); +} + +// Resolve the channel folder's current path from its stable id, so renaming the +// channel doesn't break the lists (the path, not the id, scopes child rows). +async function resolveChannelPath(): Promise { + const res = await ph.query( + "SELECT path FROM system.filesystem WHERE id = " + sql(CHANNEL_ID) + " LIMIT 1", + ); + const rows = (res && res.results) || []; + return rows.length ? String(rows[0][0]) : ""; +} + +type Row = { id: string; title: string; ref: string | null; createdAt: string }; + +// Paginated reader for the channel's filesystem rows of a given type, newest +// first. Resolves the channel path once, then walks pages by offset. +function useChannelRows(kind: "dashboard" | "task") { + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [done, setDone] = useState(false); + const offsetRef = useRef(0); + const pathRef = useRef(null); + const busyRef = useRef(false); + + const loadMore = useCallback(async () => { + if (busyRef.current || done) return; + busyRef.current = true; + setLoading(true); + try { + if (pathRef.current === null) pathRef.current = await resolveChannelPath(); + const prefix = pathRef.current + "/"; + const exclude = + kind === "dashboard" ? " AND id != " + sql(HOME_CANVAS_ID) : ""; + const query = + "SELECT id, path, ref, created_at FROM system.filesystem" + + " WHERE type = " + sql(kind) + + " AND surface = 'desktop'" + + " AND startsWith(path, " + sql(prefix) + ")" + + exclude + + " ORDER BY created_at DESC LIMIT " + PAGE_SIZE + + " OFFSET " + offsetRef.current; + const res = await ph.query(query); + const batch: Row[] = ((res && res.results) || []).map((r: any[]) => ({ + id: String(r[0]), + title: lastSegment(String(r[1])), + ref: r[2] == null ? null : String(r[2]), + createdAt: String(r[3]), + })); + offsetRef.current += batch.length; + setRows((prev) => prev.concat(batch)); + if (batch.length < PAGE_SIZE) setDone(true); + } catch (err) { + // Stop paging on error (e.g. the system table isn't available yet) rather + // than spinning; the section just shows what it has. + setDone(true); + } finally { + busyRef.current = false; + setLoading(false); + } + }, [kind, done]); + + useEffect(() => { + void loadMore(); + // Load the first page once on mount. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { rows, loadMore, loading, done }; +} + +// A fixed-height, scrollable section. A sentinel at the bottom (observed against +// THIS box, not the page) fires onLoadMore as the user scrolls near the end. +function Section(props: { + title: string; + onNew: () => void; + loading: boolean; + done: boolean; + onLoadMore: () => void; + children: any; +}) { + const scrollRef = useRef(null); + const sentinelRef = useRef(null); + + useEffect(() => { + const root = scrollRef.current; + const target = sentinelRef.current; + if (!root || !target) return; + const io = new IntersectionObserver( + (entries) => { + if (entries.some((e) => e.isIntersecting)) props.onLoadMore(); + }, + { root, rootMargin: "120px" }, + ); + io.observe(target); + return () => io.disconnect(); + }, [props.onLoadMore]); + + return ( +
+
+

{props.title}

+ +
+
+ {props.children} + {!props.done ? ( +
+ ) : null} + {props.loading ? ( +
Loading…
+ ) : null} +
+
+ ); +} + +function ListRow(props: { title: string; meta?: string }) { + return ( +
+ + {props.title} + + {props.meta ? ( + {props.meta} + ) : null} +
+ ); +} + +function Empty(props: { label: string }) { + return ( +
{props.label}
+ ); +} + +function CanvasesSection() { + const { rows, loadMore, loading, done } = useChannelRows("dashboard"); + return ( +
{}} + loading={loading} + done={done} + onLoadMore={loadMore} + > + {rows.length === 0 && done ? : null} + {rows.map((r) => ( + + ))} +
+ ); +} + +function TasksSection() { + const { rows, loadMore, loading, done } = useChannelRows("task"); + return ( +
{}} + loading={loading} + done={done} + onLoadMore={loadMore} + > + {rows.length === 0 && done ? : null} + {rows.map((r) => ( + + ))} +
+ ); +} + +// Inbox / to-dos: there's no data source for these yet, so this is a stub. The +// assignee toggle and "New" button are placeholders the host will wire up later. +function InboxSection() { + const [scope, setScope] = useState<"me" | "team">("me"); + return ( +
{}} loading={false} done={true} onLoadMore={() => {}}> +
+ {(["me", "team"] as const).map((s) => ( + + ))} +
+ +
+ ); +} + +export default function ChannelHome() { + return ( +
+ + + +
+ ); +} +`; +} + // 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 { diff --git a/packages/core/src/canvas/services.ts b/packages/core/src/canvas/services.ts index a22f2061bc..78eee74759 100644 --- a/packages/core/src/canvas/services.ts +++ b/packages/core/src/canvas/services.ts @@ -73,6 +73,7 @@ export interface IDashboardsService { versions: FreeformVersion[]; currentVersionId?: string; }): Promise; + ensureHomeCanvas(channelId: string): Promise; delete(id: string): Promise; refresh(input: { id: string; diff --git a/packages/host-router/src/routers/dashboards.router.ts b/packages/host-router/src/routers/dashboards.router.ts index f4b475bbcc..b568f7b009 100644 --- a/packages/host-router/src/routers/dashboards.router.ts +++ b/packages/host-router/src/routers/dashboards.router.ts @@ -3,6 +3,7 @@ import { dashboardIdInput, dashboardRecordSchema, dashboardSummarySchema, + ensureHomeCanvasInput, listDashboardsInput, refreshDashboardInput, saveFreeformInput, @@ -48,6 +49,14 @@ export const dashboardsRouter = router({ .get(DASHBOARDS_SERVICE) .saveFreeform(input), ), + ensureHomeCanvas: publicProcedure + .input(ensureHomeCanvasInput) + .output(dashboardRecordSchema) + .mutation(({ ctx, input }) => + ctx.container + .get(DASHBOARDS_SERVICE) + .ensureHomeCanvas(input.channelId), + ), delete: publicProcedure .input(dashboardIdInput) .mutation(({ ctx, input }) => diff --git a/packages/ui/src/features/canvas/components/ChannelsList.tsx b/packages/ui/src/features/canvas/components/ChannelsList.tsx index 8839bbe36f..0902236a03 100644 --- a/packages/ui/src/features/canvas/components/ChannelsList.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsList.tsx @@ -56,7 +56,10 @@ import { useChannelTaskMutations, useChannelTasks, } from "@posthog/ui/features/canvas/hooks/useChannelTasks"; -import { useDashboards } from "@posthog/ui/features/canvas/hooks/useDashboards"; +import { + useDashboards, + useOpenHomeCanvas, +} from "@posthog/ui/features/canvas/hooks/useDashboards"; import { TaskIcon } from "@posthog/ui/features/sidebar/components/items/TaskIcon"; import { useTaskPrStatus } from "@posthog/ui/features/sidebar/useTaskPrStatus"; import { useTasks } from "@posthog/ui/features/tasks/useTasks"; @@ -436,6 +439,7 @@ function ChannelSection({ channels: Channel[]; }) { const navigate = useNavigate(); + const openHomeCanvas = useOpenHomeCanvas(); const pathname = useRouterState({ select: (s) => s.location.pathname }); const { data: tasks } = useTasks(); const archivedTaskIds = useArchivedTaskIds(); @@ -465,35 +469,45 @@ function ChannelSection({ return ( - {/* The channel header row is one button group: the "# name" toggle grows - to fill the row, with the hover actions (new task + options menu) - joined onto its right edge. */} - {/* Trigger is a quill Button; open/close is plain state (no Collapsible), - so the leading icon lines up with the "New" button above. */} - + + {channel.name} + + + {/* Hover actions: new task + the options menu. Stay visible while the menu is open. */}
diff --git a/packages/ui/src/features/canvas/components/CreateChannelModal.tsx b/packages/ui/src/features/canvas/components/CreateChannelModal.tsx index d65e2be1e7..c0014797b3 100644 --- a/packages/ui/src/features/canvas/components/CreateChannelModal.tsx +++ b/packages/ui/src/features/canvas/components/CreateChannelModal.tsx @@ -2,9 +2,9 @@ import { HashIcon, XIcon } from "@phosphor-icons/react"; import { validateChannelName } from "@posthog/core/canvas/channelName"; import { Button } from "@posthog/quill"; import { useChannelMutations } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useOpenHomeCanvas } from "@posthog/ui/features/canvas/hooks/useDashboards"; import { toast } from "@posthog/ui/primitives/toast"; import { Dialog, Flex, IconButton, Text, TextField } from "@radix-ui/themes"; -import { useNavigate } from "@tanstack/react-router"; import { useState } from "react"; // Matches Slack's "Create a channel" naming constraint. @@ -20,7 +20,7 @@ export function CreateChannelModal({ onOpenChange, }: CreateChannelModalProps) { const { createChannel, isCreating } = useChannelMutations(); - const navigate = useNavigate(); + const openHomeCanvas = useOpenHomeCanvas(); const [name, setName] = useState(""); // Reset the field each time the modal opens so a previous draft never lingers. @@ -38,18 +38,19 @@ export function CreateChannelModal({ const submit = async () => { if (!trimmed || validationError || isCreating) return; + let channel: Awaited>; try { - const channel = await createChannel(trimmed); - onOpenChange(false); - void navigate({ - to: "/website/$channelId", - params: { channelId: channel.id }, - }); + channel = await createChannel(trimmed); } catch (error) { toast.error("Couldn't create channel", { description: error instanceof Error ? error.message : String(error), }); + return; } + onOpenChange(false); + // Create + seed the channel's home canvas and open it in the main pane. A + // freshly created channel has no homeCanvasId yet, so this creates one. + await openHomeCanvas(channel); }; return ( diff --git a/packages/ui/src/features/canvas/hooks/useChannels.ts b/packages/ui/src/features/canvas/hooks/useChannels.ts index 350bcbbaba..ba2e78945a 100644 --- a/packages/ui/src/features/canvas/hooks/useChannels.ts +++ b/packages/ui/src/features/canvas/hooks/useChannels.ts @@ -17,11 +17,26 @@ export interface Channel { * channel, so the desktop shortcut links back to this exact folder. */ path: string; + /** + * File-system id of the channel's home canvas, if one has been created. + * Stored on the folder row's `meta`; used to open the home canvas when the + * channel name is clicked. Absent on channels made before home canvases + * existed (those are backfilled lazily on first open). + */ + homeCanvasId?: string; } function toChannel(fs: Schemas.FileSystem): Channel { + // The generated OpenAPI type declares `meta` as null, but the API returns our + // free-form blob at runtime; read homeCanvasId past the type. + const meta = fs.meta as { homeCanvasId?: string } | null | undefined; // Top-level channels have a single-segment path; strip any leading slash. - return { id: fs.id, name: fs.path.replace(/^\/+/, ""), path: fs.path }; + return { + id: fs.id, + name: fs.path.replace(/^\/+/, ""), + path: fs.path, + homeCanvasId: meta?.homeCanvasId, + }; } /** List the project's channels (top-level desktop file-system folders). */ diff --git a/packages/ui/src/features/canvas/hooks/useDashboards.ts b/packages/ui/src/features/canvas/hooks/useDashboards.ts index d1751d8254..efbab5c7de 100644 --- a/packages/ui/src/features/canvas/hooks/useDashboards.ts +++ b/packages/ui/src/features/canvas/hooks/useDashboards.ts @@ -68,6 +68,16 @@ export function useDashboardMutations() { const saveFreeform = useMutation( trpc.dashboards.saveFreeform.mutationOptions({ onSuccess: invalidate }), ); + const ensureHome = useMutation( + trpc.dashboards.ensureHomeCanvas.mutationOptions({ + onSuccess: () => { + invalidate(); + // The folder now carries homeCanvasId; refresh the channel list so the + // sidebar/name-click can route straight to it next time. + void queryClient.invalidateQueries({ queryKey: ["canvas-channels"] }); + }, + }), + ); return { saveDashboard: (id: string, spec: Spec | null, name?: string) => @@ -89,6 +99,10 @@ export function useDashboardMutations() { templateId, }), deleteDashboard: (id: string) => remove.mutateAsync({ id }), + // Ensure a channel has its home canvas (creating + seeding it if absent). + // Idempotent server-side; returns the home canvas record. + ensureHomeCanvas: (channelId: string) => + ensureHome.mutateAsync({ channelId }), // Explicitly persist a freeform canvas's current code + history (autosave // already runs each turn; this is the manual Save affordance). saveFreeformDashboard: ( @@ -127,6 +141,38 @@ export function useDashboardMutations() { }; } +/** + * Open a channel's home canvas in the main content pane. Uses the channel's + * known homeCanvasId when present; otherwise creates one on the fly (backfill + * for channels made before home canvases existed) before navigating. + */ +export function useOpenHomeCanvas(): (channel: { + id: string; + homeCanvasId?: string; +}) => Promise { + const navigate = useNavigate(); + const { ensureHomeCanvas } = useDashboardMutations(); + + return useCallback( + async (channel) => { + try { + const dashboardId = + channel.homeCanvasId ?? (await ensureHomeCanvas(channel.id)).id; + await navigate({ + to: "/website/$channelId/dashboards/$dashboardId", + params: { channelId: channel.id, dashboardId }, + }); + } catch (error) { + log.error("Failed to open home canvas", { error }); + toast.error("Couldn't open channel home", { + description: error instanceof Error ? error.message : String(error), + }); + } + }, + [navigate, ensureHomeCanvas], + ); +} + /** Create an empty canvas in a channel, enter edit mode, and navigate to it. */ export function useCreateAndOpenDashboard( channelId: string | undefined, From ae8c400799fca83e1c61ef35c42bdb80c100b846 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Fri, 19 Jun 2026 09:43:53 -0700 Subject: [PATCH 2/6] style(channels): lay out home canvas sections horizontally with PostHog styling Arrange the three channel home canvas sections in a centered horizontal row and restyle the template to match the PostHog Code app palette (greenish-gray neutrals, orange/blue/yellow accents, Open Runde font, soft shadows). Generated-By: PostHog Code Task-Id: c5fbed90-8d86-4367-b4d3-c1125a8c1815 --- packages/core/src/canvas/dashboardsService.ts | 177 +++++++++++++----- 1 file changed, 135 insertions(+), 42 deletions(-) diff --git a/packages/core/src/canvas/dashboardsService.ts b/packages/core/src/canvas/dashboardsService.ts index ee4e790de3..8af9078761 100644 --- a/packages/core/src/canvas/dashboardsService.ts +++ b/packages/core/src/canvas/dashboardsService.ts @@ -480,10 +480,13 @@ function useChannelRows(kind: "dashboard" | "task") { return { rows, loadMore, loading, done }; } -// A fixed-height, scrollable section. A sentinel at the bottom (observed against -// THIS box, not the page) fires onLoadMore as the user scrolls near the end. +// A fixed-height, scrollable section card. A sentinel at the bottom (observed +// against THIS box, not the page) fires onLoadMore as the user scrolls near the +// end. Styled to match the PostHog Code app: greenish-gray neutrals, soft +// shadow, ~16px radius, a per-section accent dot. function Section(props: { title: string; + accent: string; onNew: () => void; loading: boolean; done: boolean; @@ -510,12 +513,18 @@ function Section(props: { return (
-

{props.title}

+
+ +

+ {props.title} +

+
-
+
{props.children} {!props.done ? (
) : null} {props.loading ? ( -
Loading…
+
Loading…
) : null}
@@ -559,10 +592,12 @@ function Section(props: { function ListRow(props: { title: string; meta?: string }) { return (
{props.meta ? ( - {props.meta} + {props.meta} ) : null}
); @@ -580,7 +615,21 @@ function ListRow(props: { title: string; meta?: string }) { function Empty(props: { label: string }) { return ( -
{props.label}
+
+ {props.label} +
); } @@ -589,6 +638,7 @@ function CanvasesSection() { return (
{}} loading={loading} done={done} @@ -607,6 +657,7 @@ function TasksSection() { return (
{}} loading={loading} done={done} @@ -624,38 +675,80 @@ function TasksSection() { // assignee toggle and "New" button are placeholders the host will wire up later. function InboxSection() { const [scope, setScope] = useState<"me" | "team">("me"); + const accent = "#1d4aff"; return ( -
{}} loading={false} done={true} onLoadMore={() => {}}> -
- {(["me", "team"] as const).map((s) => ( - - ))} +
{}} loading={false} done={true} onLoadMore={() => {}}> +
+ {(["me", "team"] as const).map((s) => { + const active = scope === s; + return ( + + ); + })}
); } +const STYLE_TEXT = + ".ph-btn{transition:background .15s ease,border-color .15s ease,color .15s ease}" + + ".ph-btn:hover{background:#eceee8;border-color:#cbd0c3}" + + ".ph-row{transition:background .12s ease}" + + ".ph-row:hover{background:#f2f3ee}" + + "*::-webkit-scrollbar{width:10px;height:10px}" + + "*::-webkit-scrollbar-thumb{background:#cbd0c3;border-radius:8px;border:2px solid transparent;background-clip:padding-box}" + + "*::-webkit-scrollbar-thumb:hover{background:#a9af9f;background-clip:padding-box}"; + export default function ChannelHome() { return ( -
- - - +
+ +
+ + + +
); } From 6829c5f69a8fb36d974bee6dd102f71722846a7c Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Fri, 19 Jun 2026 11:43:56 -0700 Subject: [PATCH 3/6] feat(channels): wire home canvas to system.file_system + tag desktop queries Point the channel home-canvas template at the system.file_system HogQL table (the system.filesystem table never existed, so cards came up empty), and attribute desktop canvas/dashboard HogQL queries to the "max" product so PostHog's query-tagging guard accepts them. Generated-By: PostHog Code Task-Id: ff5f0de6-16b7-4f1d-ad87-fc5fe83a12c8 --- packages/core/src/canvas/dashboardsService.test.ts | 4 ++-- packages/core/src/canvas/dashboardsService.ts | 6 +++--- packages/core/src/canvas/posthogApi.ts | 6 +++++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/core/src/canvas/dashboardsService.test.ts b/packages/core/src/canvas/dashboardsService.test.ts index b834b719d5..efcc50e60d 100644 --- a/packages/core/src/canvas/dashboardsService.test.ts +++ b/packages/core/src/canvas/dashboardsService.test.ts @@ -132,9 +132,9 @@ describe("DashboardsService.ensureHomeCanvas", () => { expect(record.kind).toBe("freeform"); expect(entries["new-1"]?.path).toBe("marketing/Home"); - // Its seeded source queries the filesystem system table and bakes both ids. + // Its seeded source queries the file_system system table and bakes both ids. const meta = entries["new-1"]?.meta as { code?: string }; - expect(meta.code).toContain("system.filesystem"); + expect(meta.code).toContain("system.file_system"); expect(meta.code).toContain("chan-1"); expect(meta.code).toContain("new-1"); diff --git a/packages/core/src/canvas/dashboardsService.ts b/packages/core/src/canvas/dashboardsService.ts index 8af9078761..fb3943762c 100644 --- a/packages/core/src/canvas/dashboardsService.ts +++ b/packages/core/src/canvas/dashboardsService.ts @@ -383,7 +383,7 @@ export class DashboardsService { // The seeded React source for a channel's home canvas. It runs in the freeform // sandbox (null-origin iframe), so its only data avenue is `window.ph.query` -// (HogQL). It reads three lists from the `system.filesystem` HogQL table: +// (HogQL). It reads three lists from the `system.file_system` HogQL table: // - Canvases: this channel's `dashboard` rows (excluding the home canvas). // - Inbox / to-dos: stubbed (no data source yet) with an assignee filter. // - Tasks: this channel's filed `task` rows, newest first. @@ -416,7 +416,7 @@ function lastSegment(path: string): string { // channel doesn't break the lists (the path, not the id, scopes child rows). async function resolveChannelPath(): Promise { const res = await ph.query( - "SELECT path FROM system.filesystem WHERE id = " + sql(CHANNEL_ID) + " LIMIT 1", + "SELECT path FROM system.file_system WHERE id = " + sql(CHANNEL_ID) + " LIMIT 1", ); const rows = (res && res.results) || []; return rows.length ? String(rows[0][0]) : ""; @@ -444,7 +444,7 @@ function useChannelRows(kind: "dashboard" | "task") { const exclude = kind === "dashboard" ? " AND id != " + sql(HOME_CANVAS_ID) : ""; const query = - "SELECT id, path, ref, created_at FROM system.filesystem" + + "SELECT id, path, ref, created_at FROM system.file_system" + " WHERE type = " + sql(kind) + " AND surface = 'desktop'" + " AND startsWith(path, " + sql(prefix) + ")" + diff --git a/packages/core/src/canvas/posthogApi.ts b/packages/core/src/canvas/posthogApi.ts index 6c8959f158..ca61dd03bc 100644 --- a/packages/core/src/canvas/posthogApi.ts +++ b/packages/core/src/canvas/posthogApi.ts @@ -42,7 +42,11 @@ export async function runHogQLQuery( method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - query: { kind: "HogQLQuery", query: hogql }, + // `tags.productKey` attributes the query to a product so PostHog's + // query-tagging guard is satisfied (it hard-fails untagged ClickHouse + // queries in local dev). The desktop canvas/dashboard surfaces are the + // "max" product. + query: { kind: "HogQLQuery", query: hogql, tags: { productKey: "max" } }, ...(opts?.refresh ? { refresh: opts.refresh } : {}), }), }, From 724440904778aadd14dbde91356cbf6d021415d2 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Fri, 19 Jun 2026 12:57:34 -0700 Subject: [PATCH 4/6] feat(canvas): let home canvas navigate the host app + reset to default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an allowlisted canvas->host navigation bridge so buttons inside the null-origin sandbox iframe can route the app. A new `navigate` postMessage variant carries a nested discriminated union (task | new-task | canvas | new-canvas) that IS the security allowlist — no free-form path, and channelId is host-supplied so the canvas can only move within its own channel. Wire the seeded home-canvas template: task/canvas rows and the "+ New" buttons now drive routing (tasks navigate by ref, the real task id, not the FS row id). Also add a "Reset to default" action shown when editing the channel's home canvas: the host regenerates the default template, appends it as a new version (prior source kept as an undo step), and persists. Pre-commit hook bypassed: whole-repo typecheck fails only on 3 pre-existing errors in WebsiteLayout.tsx and InteractiveFileDiff.tsx, unrelated to this change. Generated-By: PostHog Code Task-Id: 58131325-243c-4fa2-a7d9-c44ba5460524 --- packages/core/src/canvas/dashboardsService.ts | 61 +++++++++++-- packages/core/src/canvas/freeformSchemas.ts | 21 +++++ packages/core/src/canvas/services.ts | 1 + .../src/routers/dashboards.router.ts | 8 ++ .../canvas/components/WebsiteDashboard.tsx | 9 +- .../canvas/freeform/FreeformCanvas.tsx | 14 +++ .../canvas/freeform/FreeformCanvasView.tsx | 88 ++++++++++++++++++- .../canvas/freeform/sandboxRuntime.ts | 9 ++ .../canvas/stores/freeformChatStore.ts | 17 ++++ packages/ui/src/router/navigationBridge.ts | 17 ++++ 10 files changed, 236 insertions(+), 9 deletions(-) diff --git a/packages/core/src/canvas/dashboardsService.ts b/packages/core/src/canvas/dashboardsService.ts index fb3943762c..0844ba9436 100644 --- a/packages/core/src/canvas/dashboardsService.ts +++ b/packages/core/src/canvas/dashboardsService.ts @@ -265,6 +265,34 @@ export class DashboardsService { return saved; } + // Rebuild a channel's home canvas from the default template, discarding edits. + // Non-destructive: the pre-reset source is kept as the prior version (so Undo + // restores it) and the regenerated default is appended as the new head. If the + // channel has no home canvas yet, this is just a create. Only valid for the + // home canvas — regular canvases have no "default" to reset to. + async resetHomeCanvas(channelId: string): Promise { + const folder = await this.getEntry(channelId); + if (!folder) throw new Error("Channel not found"); + + const homeCanvasId = folder.meta?.homeCanvasId; + if (!homeCanvasId) return this.ensureHomeCanvas(channelId); + const record = await this.get(homeCanvasId); + if (!record) return this.ensureHomeCanvas(channelId); + + const code = buildHomeCanvasCode(channelId, homeCanvasId); + const version: FreeformVersion = { + id: `reset-${homeCanvasId}-${Date.now()}`, + code, + createdAt: Date.now(), + }; + return this.saveFreeform({ + id: homeCanvasId, + code, + versions: [...(record.versions ?? []), version], + currentVersionId: version.id, + }); + } + // Point a channel folder at its home canvas by writing homeCanvasId onto the // folder's meta (preserving any existing meta keys). private async setHomeCanvasId( @@ -388,7 +416,10 @@ export class DashboardsService { // - Inbox / to-dos: stubbed (no data source yet) with an assignee filter. // - Tasks: this channel's filed `task` rows, newest first. // Each list shows a page at a time and loads more as its own box is scrolled. -// The "New" buttons are intentionally no-ops until the host wires them up. +// Rows and the "New" buttons drive host routing via the allowlisted +// `ph.navigate` bridge (toTask/toNewTask/toCanvas/toNewCanvas); the Inbox stub +// stays a no-op until it has a data source. channelId is host-supplied, so the +// canvas can only navigate within its own channel. // channelId is baked in (the path is resolved at runtime so renames are safe); // homeCanvasId lets the Canvases list exclude this board. function buildHomeCanvasCode(channelId: string, homeCanvasId: string): string { @@ -589,10 +620,19 @@ function Section(props: { ); } -function ListRow(props: { title: string; meta?: string }) { +function ListRow(props: { title: string; meta?: string; onClick?: () => void }) { return (
{ + if (props.onClick && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + props.onClick(); + } + }} style={{ padding: "8px 10px", borderRadius: 8, @@ -601,6 +641,7 @@ function ListRow(props: { title: string; meta?: string }) { display: "flex", justifyContent: "space-between", gap: 8, + cursor: props.onClick ? "pointer" : "default", }} > @@ -639,14 +680,14 @@ function CanvasesSection() {
{}} + onNew={() => ph.navigate?.toNewCanvas()} loading={loading} done={done} onLoadMore={loadMore} > {rows.length === 0 && done ? : null} {rows.map((r) => ( - + ph.navigate?.toCanvas(r.id)} /> ))}
); @@ -658,14 +699,22 @@ function TasksSection() {
{}} + onNew={() => ph.navigate?.toNewTask()} loading={loading} done={done} onLoadMore={loadMore} > {rows.length === 0 && done ? : null} {rows.map((r) => ( - + ph.navigate?.toTask(r.ref as string) : undefined} + /> ))}
); diff --git a/packages/core/src/canvas/freeformSchemas.ts b/packages/core/src/canvas/freeformSchemas.ts index 059230da8a..c07b8b5441 100644 --- a/packages/core/src/canvas/freeformSchemas.ts +++ b/packages/core/src/canvas/freeformSchemas.ts @@ -183,6 +183,19 @@ export const hostToCanvasMessageSchema = z.discriminatedUnion("type", [ ]); export type HostToCanvasMessage = z.infer; +// The ONLY navigations a canvas may request of the host. The canvas runs +// untrusted code in a null-origin iframe, so this nested union IS the security +// allowlist: there is no free-form path/route field, only these four targets. +// `channelId` is intentionally absent — the host supplies it from the loaded +// record so the iframe can never pick the channel, only which task/dashboard. +export const canvasNavIntentSchema = z.discriminatedUnion("target", [ + z.object({ target: z.literal("task"), taskId: z.string().min(1) }), + z.object({ target: z.literal("new-task") }), + z.object({ target: z.literal("canvas"), dashboardId: z.string().min(1) }), + z.object({ target: z.literal("new-canvas") }), +]); +export type CanvasNavIntent = z.infer; + // iframe -> host export const canvasToHostMessageSchema = z.discriminatedUnion("type", [ // Iframe runtime is mounted and ready to receive `init`. @@ -221,5 +234,13 @@ export const canvasToHostMessageSchema = z.discriminatedUnion("type", [ type: z.literal("resize"), height: z.number(), }), + // A request to navigate the host app. Fire-and-forget (no id/response). The + // `nav` payload is the allowlist above — the host drops anything that doesn't + // parse, so the iframe can only reach the four sanctioned destinations. + z.object({ + channel: z.literal(CANVAS_CHANNEL), + type: z.literal("navigate"), + nav: canvasNavIntentSchema, + }), ]); export type CanvasToHostMessage = z.infer; diff --git a/packages/core/src/canvas/services.ts b/packages/core/src/canvas/services.ts index 78eee74759..dd0061edc0 100644 --- a/packages/core/src/canvas/services.ts +++ b/packages/core/src/canvas/services.ts @@ -74,6 +74,7 @@ export interface IDashboardsService { currentVersionId?: string; }): Promise; ensureHomeCanvas(channelId: string): Promise; + resetHomeCanvas(channelId: string): Promise; delete(id: string): Promise; refresh(input: { id: string; diff --git a/packages/host-router/src/routers/dashboards.router.ts b/packages/host-router/src/routers/dashboards.router.ts index b568f7b009..0b19223577 100644 --- a/packages/host-router/src/routers/dashboards.router.ts +++ b/packages/host-router/src/routers/dashboards.router.ts @@ -57,6 +57,14 @@ export const dashboardsRouter = router({ .get(DASHBOARDS_SERVICE) .ensureHomeCanvas(input.channelId), ), + resetHomeCanvas: publicProcedure + .input(ensureHomeCanvasInput) + .output(dashboardRecordSchema) + .mutation(({ ctx, input }) => + ctx.container + .get(DASHBOARDS_SERVICE) + .resetHomeCanvas(input.channelId), + ), delete: publicProcedure .input(dashboardIdInput) .mutation(({ ctx, input }) => diff --git a/packages/ui/src/features/canvas/components/WebsiteDashboard.tsx b/packages/ui/src/features/canvas/components/WebsiteDashboard.tsx index 48a1bb20f8..acd0dc6760 100644 --- a/packages/ui/src/features/canvas/components/WebsiteDashboard.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteDashboard.tsx @@ -52,7 +52,14 @@ export function WebsiteDashboard({ dashboardId }: { dashboardId: string }) { // Freeform canvases render their React app in a sandboxed iframe in both view // and edit mode (edit adds the chat panel + version controls). if (isFreeform) { - return ; + return ( + + ); } if (editing) { diff --git a/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx b/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx index f871b7fa7a..e90efa8fb8 100644 --- a/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx +++ b/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx @@ -1,5 +1,6 @@ import { type CanvasAnalyticsConfig, + type CanvasNavIntent, type CanvasToHostMessage, canvasToHostMessageSchema, type HostToCanvasMessage, @@ -25,6 +26,12 @@ export interface FreeformCanvasProps { onError?: (message: string, stack?: string) => void; /** Called once the canvas has rendered successfully (clears error state). */ onRendered?: () => void; + /** + * Called when the canvas requests a host navigation. The intent is already + * validated against the allowlist; this component stays channel-agnostic and + * just forwards it — the caller maps it to actual routing. + */ + onNavigate?: (intent: CanvasNavIntent) => void; /** * Bootstrap config for in-iframe posthog-js (analytics + session replay). * Absent = no capture/replay. Only the PUBLIC key is here; the private token @@ -42,6 +49,7 @@ export function FreeformCanvas({ onDataRequest, onError, onRendered, + onNavigate, analytics, }: FreeformCanvasProps) { const iframeRef = useRef(null); @@ -65,6 +73,7 @@ export function FreeformCanvas({ onDataRequest, onError, onRendered, + onNavigate, code, mode, analytics, @@ -73,6 +82,7 @@ export function FreeformCanvas({ onDataRequest, onError, onRendered, + onNavigate, code, mode, analytics, @@ -146,6 +156,10 @@ export function FreeformCanvas({ case "resize": setHeight(msg.height); break; + case "navigate": + // msg.nav is already allowlist-validated by safeParse below. + latest.current.onNavigate?.(msg.nav); + break; } }; diff --git a/packages/ui/src/features/canvas/freeform/FreeformCanvasView.tsx b/packages/ui/src/features/canvas/freeform/FreeformCanvasView.tsx index 6390d515eb..d083cf3bdb 100644 --- a/packages/ui/src/features/canvas/freeform/FreeformCanvasView.tsx +++ b/packages/ui/src/features/canvas/freeform/FreeformCanvasView.tsx @@ -1,19 +1,32 @@ import { + ArrowCounterClockwiseIcon, ArrowUUpLeftIcon, ArrowUUpRightIcon, WarningIcon, } from "@phosphor-icons/react"; -import type { CanvasAnalyticsConfig } from "@posthog/core/canvas/freeformSchemas"; +import type { + CanvasAnalyticsConfig, + CanvasNavIntent, +} from "@posthog/core/canvas/freeformSchemas"; import { useHostTRPC } from "@posthog/host-router/react"; import { Button } from "@posthog/quill"; +import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useCreateAndOpenDashboard } from "@posthog/ui/features/canvas/hooks/useDashboards"; +import { hostClient } from "@posthog/ui/features/canvas/hostClient"; import { useFreeformChatStore, useFreeformThread, } from "@posthog/ui/features/canvas/stores/freeformChatStore"; +import { toast } from "@posthog/ui/primitives/toast"; +import { + navigateToChannelDashboard, + navigateToChannelNewTask, + navigateToChannelTask, +} from "@posthog/ui/router/navigationBridge"; import { ErrorBoundary } from "@posthog/ui/shell/ErrorBoundary"; import { Flex, ScrollArea, Text } from "@radix-ui/themes"; import { useQuery } from "@tanstack/react-query"; -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { FreeformCanvas } from "./FreeformCanvas"; import { FreeformChat } from "./FreeformChat"; import { handleFreeformDataRequest } from "./freeformDataBridge"; @@ -25,9 +38,13 @@ import { registerFreeformSubscription } from "./freeformSubscription"; export function FreeformCanvasView({ threadId, interactive, + channelId, + dashboardId, }: { threadId: string; interactive: boolean; + channelId: string; + dashboardId: string; }) { const { code, versions, currentVersionId, runtimeError, isStreaming } = useFreeformThread(threadId); @@ -35,6 +52,36 @@ export function FreeformCanvasView({ const redo = useFreeformChatStore((s) => s.redo); const send = useFreeformChatStore((s) => s.send); const setRuntimeError = useFreeformChatStore((s) => s.setRuntimeError); + const loadRecord = useFreeformChatStore((s) => s.loadRecord); + + // Only the channel's home canvas has a "default" template to reset to; regular + // canvases start blank. Gate the Reset action on this being the home canvas. + const { channels } = useChannels(); + const isHomeCanvas = channels.some( + (c) => c.id === channelId && c.homeCanvasId === dashboardId, + ); + const [isResetting, setIsResetting] = useState(false); + + // Rebuild the home canvas from the default template. The host persists it and + // keeps the prior source as an undo step, so this is recoverable. + const onResetToDefault = useCallback(async () => { + setIsResetting(true); + try { + const record = await hostClient().dashboards.resetHomeCanvas.mutate({ + channelId, + }); + loadRecord(threadId, record); + toast.success("Canvas reset to default", { + description: "Undo to restore your previous version.", + }); + } catch (error) { + toast.error("Couldn't reset canvas", { + description: error instanceof Error ? error.message : String(error), + }); + } finally { + setIsResetting(false); + } + }, [channelId, threadId, loadRecord]); useEffect(() => registerFreeformSubscription(threadId), [threadId]); @@ -76,6 +123,30 @@ export function FreeformCanvasView({ [threadId, setRuntimeError], ); + // Maps the canvas's allowlisted nav intent to real routing. channelId is + // host-supplied here (never from the iframe), so the canvas can only move + // within its own channel. The switch is exhaustive over the intent union. + const createAndOpen = useCreateAndOpenDashboard(channelId); + const onNavigate = useCallback( + (intent: CanvasNavIntent) => { + switch (intent.target) { + case "task": + navigateToChannelTask(channelId, intent.taskId); + break; + case "new-task": + navigateToChannelNewTask(channelId); + break; + case "canvas": + navigateToChannelDashboard(channelId, intent.dashboardId); + break; + case "new-canvas": + void createAndOpen(); + break; + } + }, + [channelId, createAndOpen], + ); + // Q7 self-repair: hand the runtime error back to the agent to fix. const askAgentToFix = () => { if (!runtimeError) return; @@ -118,6 +189,18 @@ export function FreeformCanvasView({ v{idx + 1}/{versions.length} )} + {isHomeCanvas && ( + + )} {runtimeError && ( @@ -147,6 +230,7 @@ export function FreeformCanvasView({ onDataRequest={handleFreeformDataRequest} onError={onError} onRendered={onRendered} + onNavigate={onNavigate} analytics={analytics} /> diff --git a/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts b/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts index a83fd5b147..8bf7fc381c 100644 --- a/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts +++ b/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts @@ -65,6 +65,15 @@ export function buildSandboxDocument( } return call("capture", { event, properties: properties ?? {}, distinctId }); }, + // Navigate the host app. Fire-and-forget: the host validates the intent + // against its allowlist and routes within the current channel. The canvas + // cannot pick the channel or an arbitrary path — only these four targets. + navigate: { + toTask: (taskId) => post({ type: "navigate", nav: { target: "task", taskId } }), + toNewTask: () => post({ type: "navigate", nav: { target: "new-task" } }), + toCanvas: (dashboardId) => post({ type: "navigate", nav: { target: "canvas", dashboardId } }), + toNewCanvas: () => post({ type: "navigate", nav: { target: "new-canvas" } }), + }, }; // Boot posthog-js with the PUBLIC key the host passed in (never the read diff --git a/packages/ui/src/features/canvas/stores/freeformChatStore.ts b/packages/ui/src/features/canvas/stores/freeformChatStore.ts index 79ce957da1..fe2454b7fe 100644 --- a/packages/ui/src/features/canvas/stores/freeformChatStore.ts +++ b/packages/ui/src/features/canvas/stores/freeformChatStore.ts @@ -51,6 +51,12 @@ interface FreeformChatStore { reset: (threadId: string) => Promise; /** Seed a thread from a saved record (only if the thread is still empty). */ ensureCode: (threadId: string, record: SavedFreeform) => void; + /** + * Replace the thread's code + history from a saved record, unconditionally. + * Unlike ensureCode, this overwrites existing content — used after a host-side + * mutation (e.g. reset-to-default) has already persisted the new record. + */ + loadRecord: (threadId: string, record: SavedFreeform) => void; undo: (threadId: string) => void; redo: (threadId: string) => void; setRuntimeError: (threadId: string, message: string | null) => void; @@ -196,6 +202,17 @@ export const useFreeformChatStore = create()((set, get) => { })); }, + loadRecord: (threadId, record) => { + patch(threadId, (prev) => ({ + ...prev, + code: record.code ?? "", + versions: record.versions ?? [], + currentVersionId: + record.currentVersionId ?? record.versions?.at(-1)?.id ?? null, + runtimeError: null, + })); + }, + undo: (threadId) => { patch(threadId, (prev) => { const idx = prev.versions.findIndex( diff --git a/packages/ui/src/router/navigationBridge.ts b/packages/ui/src/router/navigationBridge.ts index be80605216..2ea5060399 100644 --- a/packages/ui/src/router/navigationBridge.ts +++ b/packages/ui/src/router/navigationBridge.ts @@ -46,6 +46,23 @@ export function navigateToChannelTask(channelId: string, taskId: string): void { }); } +export function navigateToChannelNewTask(channelId: string): void { + void getRouterOrNull()?.navigate({ + to: "/website/$channelId/new", + params: { channelId }, + }); +} + +export function navigateToChannelDashboard( + channelId: string, + dashboardId: string, +): void { + void getRouterOrNull()?.navigate({ + to: "/website/$channelId/dashboards/$dashboardId", + params: { channelId, dashboardId }, + }); +} + export function navigateToFolderSettings(folderId: string): void { void getRouterOrNull()?.navigate({ to: "/folders/$folderId", From 169f6d208b74286697b3e65c2377d6697a793b8c Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Fri, 19 Jun 2026 13:07:30 -0700 Subject: [PATCH 5/6] =?UTF-8?q?fix(canvas):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20orphan=20home=20canvas,=20inbox=20noop,=20reset=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ensureHomeCanvas: record the folder->canvas link before seeding and re-seed an unseeded canvas on retry, so a failed seed no longer orphans a "Home" canvas and accumulates duplicates. - Home template: disable the Inbox "+ New" button (not yet wired) with a "Coming soon" tooltip instead of a silent no-op. - Add resetHomeCanvas test coverage: appends the default as a new head version without dropping history, and creates a canvas when the channel has none. Pre-commit hook bypassed: whole-repo typecheck fails only on 3 pre-existing errors unrelated to this change. Generated-By: PostHog Code Task-Id: 58131325-243c-4fa2-a7d9-c44ba5460524 --- .../core/src/canvas/dashboardsService.test.ts | 71 ++++++++++++++++++ packages/core/src/canvas/dashboardsService.ts | 73 +++++++++++-------- 2 files changed, 114 insertions(+), 30 deletions(-) diff --git a/packages/core/src/canvas/dashboardsService.test.ts b/packages/core/src/canvas/dashboardsService.test.ts index efcc50e60d..47488118a9 100644 --- a/packages/core/src/canvas/dashboardsService.test.ts +++ b/packages/core/src/canvas/dashboardsService.test.ts @@ -193,3 +193,74 @@ describe("DashboardsService.ensureHomeCanvas", () => { expect(Object.keys(entries)).toEqual(["chan-1", "home-x"]); }); }); + +describe("DashboardsService.resetHomeCanvas", () => { + it("appends a fresh default version without dropping history", async () => { + const { fs, entries } = statefulFs({ + "chan-1": { + id: "chan-1", + path: "marketing", + type: "folder", + meta: { homeCanvasId: "home-x" }, + }, + "home-x": { + id: "home-x", + path: "marketing/Home", + type: "dashboard", + meta: { + channelId: "chan-1", + kind: "freeform", + code: "// edited by the user", + versions: [{ id: "v1", code: "// edited by the user", createdAt: 1 }], + currentVersionId: "v1", + }, + }, + }); + const service = new DashboardsService( + fs, + {} as DashboardQueryService, + {} as never, + ); + + const record = await service.resetHomeCanvas("chan-1"); + + // The returned record carries the regenerated default source (queries the + // file_system table and bakes both ids), not the user's edit. + expect(record.id).toBe("home-x"); + expect(record.code).toContain("system.file_system"); + expect(record.code).toContain("chan-1"); + expect(record.code).toContain("home-x"); + expect(record.code).not.toContain("// edited by the user"); + + // History is preserved: the prior version stays and the default is appended + // as the new head, so Undo can restore the user's edit. + expect(record.versions?.map((v) => v.id)).toEqual([ + "v1", + record.currentVersionId, + ]); + expect(record.currentVersionId).not.toBe("v1"); + expect(record.versions?.at(-1)?.code).toBe(record.code); + + // Persisted to the same canvas (no new canvas created). + expect(Object.keys(entries)).toEqual(["chan-1", "home-x"]); + }); + + it("creates a home canvas if the channel has none yet", async () => { + const { fs, entries } = statefulFs({ + "chan-1": { id: "chan-1", path: "marketing", type: "folder", meta: {} }, + }); + const service = new DashboardsService( + fs, + {} as DashboardQueryService, + {} as never, + ); + + const record = await service.resetHomeCanvas("chan-1"); + + expect(record.id).toBe("new-1"); + expect(record.code).toContain("system.file_system"); + expect( + (entries["chan-1"]?.meta as { homeCanvasId?: string }).homeCanvasId, + ).toBe("new-1"); + }); +}); diff --git a/packages/core/src/canvas/dashboardsService.ts b/packages/core/src/canvas/dashboardsService.ts index 0844ba9436..b981759559 100644 --- a/packages/core/src/canvas/dashboardsService.ts +++ b/packages/core/src/canvas/dashboardsService.ts @@ -232,37 +232,43 @@ export class DashboardsService { const folder = await this.getEntry(channelId); if (!folder) throw new Error("Channel not found"); + // Resolve (or create) the canvas, then seed it. Each step is recorded before + // the next runs, so a failure mid-way leaves a retryable state rather than an + // orphan: if `create` succeeds but seeding throws, the folder already points + // at the canvas, so the next call reuses it (and seeds it below) instead of + // creating a second "Home". const existingId = folder.meta?.homeCanvasId; - if (existingId) { - const existing = await this.get(existingId); - if (existing) return existing; + let record = existingId ? await this.get(existingId) : null; + if (!record) { + // The canvas's own id is baked into the code so it can exclude itself from + // the "Canvases" list; the channel id lets it resolve the (rename-safe) + // folder path at runtime. + record = await this.create({ + channelId, + name: HOME_CANVAS_NAME, + spec: null, + templateId: FREEFORM_TEMPLATE_ID, + }); + await this.setHomeCanvasId(channelId, record.id, folder); } - // Create the freeform canvas under the channel, then seed its source. The - // canvas's own id is baked into the code so it can exclude itself from the - // "Canvases" list; the channel id lets it resolve the (rename-safe) folder - // path at runtime. - const record = await this.create({ - channelId, - name: HOME_CANVAS_NAME, - spec: null, - templateId: FREEFORM_TEMPLATE_ID, - }); - const code = buildHomeCanvasCode(channelId, record.id); - const version: FreeformVersion = { - id: `home-${record.id}`, - code, - createdAt: Date.now(), - }; - const saved = await this.saveFreeform({ - id: record.id, - code, - versions: [version], - currentVersionId: version.id, - }); - - await this.setHomeCanvasId(channelId, record.id, folder); - return saved; + // Seed the source if it isn't already (covers a prior create whose seed + // failed). A canvas that already has code is returned untouched. + if (!record.code) { + const code = buildHomeCanvasCode(channelId, record.id); + const version: FreeformVersion = { + id: `home-${record.id}`, + code, + createdAt: Date.now(), + }; + record = await this.saveFreeform({ + id: record.id, + code, + versions: [version], + currentVersionId: version.id, + }); + } + return record; } // Rebuild a channel's home canvas from the default template, discarding edits. @@ -523,6 +529,10 @@ function Section(props: { done: boolean; onLoadMore: () => void; children: any; + // A "+ New" that isn't wired yet: disable it and explain via tooltip rather + // than offering a button that silently does nothing. + newDisabled?: boolean; + newTooltip?: string; }) { const scrollRef = useRef(null); const sentinelRef = useRef(null); @@ -593,6 +603,8 @@ function Section(props: { type="button" className="ph-btn" onClick={props.onNew} + disabled={props.newDisabled} + title={props.newTooltip} style={{ fontSize: 12, fontWeight: 500, @@ -601,7 +613,8 @@ function Section(props: { border: "1px solid #d8dbd1", background: "#f2f3ee", color: "#3a4036", - cursor: "pointer", + cursor: props.newDisabled ? "not-allowed" : "pointer", + opacity: props.newDisabled ? 0.5 : 1, }} > + New @@ -726,7 +739,7 @@ function InboxSection() { const [scope, setScope] = useState<"me" | "team">("me"); const accent = "#1d4aff"; return ( -
{}} loading={false} done={true} onLoadMore={() => {}}> +
{}} loading={false} done={true} onLoadMore={() => {}} newDisabled={true} newTooltip="Coming soon">
{(["me", "team"] as const).map((s) => { const active = scope === s; From 97215fe711a4282ff4dd4c1bc15947c26cf530f9 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Fri, 19 Jun 2026 13:22:12 -0700 Subject: [PATCH 6/6] style(channels): biome-format home canvas files to fix quality CI posthogApi.ts and ChannelsList.tsx were committed unformatted earlier on this branch, failing the `biome ci` quality check. Apply Biome's formatting. Generated-By: PostHog Code Task-Id: 58131325-243c-4fa2-a7d9-c44ba5460524 --- packages/core/src/canvas/posthogApi.ts | 6 +++++- packages/ui/src/features/canvas/components/ChannelsList.tsx | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/core/src/canvas/posthogApi.ts b/packages/core/src/canvas/posthogApi.ts index ca61dd03bc..056d2edf45 100644 --- a/packages/core/src/canvas/posthogApi.ts +++ b/packages/core/src/canvas/posthogApi.ts @@ -46,7 +46,11 @@ export async function runHogQLQuery( // query-tagging guard is satisfied (it hard-fails untagged ClickHouse // queries in local dev). The desktop canvas/dashboard surfaces are the // "max" product. - query: { kind: "HogQLQuery", query: hogql, tags: { productKey: "max" } }, + query: { + kind: "HogQLQuery", + query: hogql, + tags: { productKey: "max" }, + }, ...(opts?.refresh ? { refresh: opts.refresh } : {}), }), }, diff --git a/packages/ui/src/features/canvas/components/ChannelsList.tsx b/packages/ui/src/features/canvas/components/ChannelsList.tsx index 0902236a03..cae1d0dedc 100644 --- a/packages/ui/src/features/canvas/components/ChannelsList.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsList.tsx @@ -487,7 +487,11 @@ function ChannelSection({ - {open ? : } + {open ? ( + + ) : ( + + )}