diff --git a/packages/core/src/canvas/dashboardSchemas.ts b/packages/core/src/canvas/dashboardSchemas.ts index bf6c1f1ac..0a40a4f17 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 cb5190ef1..47488118a 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,193 @@ 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 file_system system table and bakes both ids. + const meta = entries["new-1"]?.meta as { code?: string }; + expect(meta.code).toContain("system.file_system"); + 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"]); + }); +}); + +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 7cd9248bc..b98175955 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,102 @@ 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"); + + // 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; + 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); + } + + // 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. + // 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( + 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 +415,408 @@ 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.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. +// Each list shows a page at a time and loads more as its own box is scrolled. +// 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 { + 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.file_system 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.file_system" + + " 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 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; + 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); + + 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; onClick?: () => void }) { + return ( +
{ + if (props.onClick && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + props.onClick(); + } + }} + style={{ + padding: "8px 10px", + borderRadius: 8, + fontSize: 13, + color: "#3a4036", + display: "flex", + justifyContent: "space-between", + gap: 8, + cursor: props.onClick ? "pointer" : "default", + }} + > + + {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 ( +
ph.navigate?.toNewCanvas()} + loading={loading} + done={done} + onLoadMore={loadMore} + > + {rows.length === 0 && done ? : null} + {rows.map((r) => ( + ph.navigate?.toCanvas(r.id)} /> + ))} +
+ ); +} + +function TasksSection() { + const { rows, loadMore, loading, done } = useChannelRows("task"); + return ( +
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} + /> + ))} +
+ ); +} + +// 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"); + const accent = "#1d4aff"; + return ( +
{}} loading={false} done={true} onLoadMore={() => {}} newDisabled={true} newTooltip="Coming soon"> +
+ {(["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 ( +
+ +
+ + + +
+
+ ); +} +`; +} + // 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/freeformSchemas.ts b/packages/core/src/canvas/freeformSchemas.ts index 059230da8..c07b8b544 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/posthogApi.ts b/packages/core/src/canvas/posthogApi.ts index 6c8959f15..056d2edf4 100644 --- a/packages/core/src/canvas/posthogApi.ts +++ b/packages/core/src/canvas/posthogApi.ts @@ -42,7 +42,15 @@ 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 } : {}), }), }, diff --git a/packages/core/src/canvas/services.ts b/packages/core/src/canvas/services.ts index a22f2061b..dd0061edc 100644 --- a/packages/core/src/canvas/services.ts +++ b/packages/core/src/canvas/services.ts @@ -73,6 +73,8 @@ export interface IDashboardsService { versions: FreeformVersion[]; 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 f4b475bbc..0b1922357 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,22 @@ 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), + ), + 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/ChannelsList.tsx b/packages/ui/src/features/canvas/components/ChannelsList.tsx index 8839bbe36..cae1d0ded 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,49 @@ 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 d65e2be1e..c0014797b 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/components/WebsiteDashboard.tsx b/packages/ui/src/features/canvas/components/WebsiteDashboard.tsx index 48a1bb20f..acd0dc676 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 f871b7fa7..e90efa8fb 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 6390d515e..d083cf3bd 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 a83fd5b14..8bf7fc381 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/hooks/useChannels.ts b/packages/ui/src/features/canvas/hooks/useChannels.ts index 350bcbbab..ba2e78945 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 d1751d825..efbab5c7d 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, diff --git a/packages/ui/src/features/canvas/stores/freeformChatStore.ts b/packages/ui/src/features/canvas/stores/freeformChatStore.ts index 79ce957da..fe2454b7f 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 be8060521..2ea506039 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",