Skip to content

feat(channels): home canvas — host navigation + reset to default#2786

Draft
raquelmsmith wants to merge 6 commits into
mainfrom
posthog-code/channel-home-canvas
Draft

feat(channels): home canvas — host navigation + reset to default#2786
raquelmsmith wants to merge 6 commits into
mainfrom
posthog-code/channel-home-canvas

Conversation

@raquelmsmith

Copy link
Copy Markdown
Member

What

Adds a per-channel home canvas: a freeform React board (rendered in a null-origin sandbox iframe) that lists the channel's canvases and tasks. This PR builds it up to be interactive.

Home canvas (earlier commits)

  • Auto-create a home canvas per channel; lazily backfill older channels.
  • Read Canvases / Tasks / Inbox lists from the system.file_system HogQL table, tagged to the desktop surface.
  • Horizontal section layout with PostHog styling.

Canvas → app navigation (this work)

  • New allowlisted navigate postMessage variant on the canvas↔host protocol. Its payload is a nested discriminated union — task | new-task | canvas | new-canvas — which is the security allowlist: no free-form path, and channelId is host-supplied, so an untrusted canvas can only route within its own channel.
  • window.ph.navigate.{toTask,toNewTask,toCanvas,toNewCanvas} shim inside the iframe; FreeformCanvas forwards the validated intent via a channel-agnostic onNavigate prop; the channel-aware view maps it to navigationBridge.
  • Wired the seeded template: clicking a task/canvas row or a "+ New" button now routes. Tasks navigate by ref (the real task id), not the file-system row id.

Reset to default

  • "Reset to default" button 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 — non-destructive and recoverable via Undo.

Security

  • The intent union is the only navigation surface; the host drops anything that doesn't parse.
  • channelId is never iframe-supplied; IDs flow into router params (escaped), never concatenated into paths.
  • FreeformCanvas stays channel-agnostic so the allowlist mapping can't be bypassed.

Notes

  • The home canvas is seeded once per channel — channels created before a change keep their old template until Reset to default (or a fresh channel).
  • Tasks only appear once filed to the channel (desktop FS row under the channel folder).

Testing

  • @posthog/core, @posthog/host-router, @posthog/ui typecheck clean for these changes.
  • ⚠️ Whole-repo typecheck currently fails on 3 pre-existing errors unrelated to this PR (WebsiteLayout.tsx loading prop; InteractiveFileDiff.tsx fileGap).
  • Manual: open a channel home canvas → click task/canvas rows and "+ New" buttons route correctly; edit the home canvas → "Reset to default" restores the template and Undo brings edits back.

Created with PostHog Code

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
…og 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
…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
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
@github-actions

github-actions Bot commented Jun 19, 2026

Copy link
Copy Markdown

React Doctor found no issues in the changed files. 🎉

Reviewed by React Doctor for commit 97215fe.

@greptile-apps

greptile-apps Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
packages/core/src/canvas/dashboardsService.ts:245-265
**Orphan canvas on partial failure**

If `this.saveFreeform` throws (e.g. a transient 5xx), a canvas has already been created by `this.create` but `setHomeCanvasId` is never called. The next call to `ensureHomeCanvas` re-enters the branch (because `folder.meta?.homeCanvasId` is still unset) and creates a second canvas, and so on — each failed attempt leaves an unreachable "Home" canvas in the user's file system that never gets cleaned up.

The fix is to call `setHomeCanvasId` immediately after `create`, so idempotency kicks in on retry rather than creating another orphan.

### Issue 2 of 3
packages/core/src/canvas/dashboardsService.test.ts:83-166
**`resetHomeCanvas` has no test coverage**

Three cases exercise `ensureHomeCanvas` (create-and-seed, valid TSX, idempotency), but `resetHomeCanvas` — the user-visible "Reset to default" button — has none. At a minimum, a test verifying the new version is appended (not overwriting history) and that the returned record carries the reset code would protect the undo flow.

### Issue 3 of 3
packages/core/src/canvas/dashboardsService.ts:729
The `InboxSection` "+ New" button calls an empty handler with no visual feedback. Per project convention, a noop affordance should be disabled with a tooltip explaining it is not yet wired, rather than silently doing nothing.

```suggestion
    <Section title="Inbox" accent={accent} onNew={() => {}} loading={false} done={true} onLoadMore={() => {}} newDisabled={true} newTooltip="Coming soon">
```

Reviews (1): Last reviewed commit: "feat(canvas): let home canvas navigate t..." | Re-trigger Greptile

Comment on lines +245 to +265
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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Orphan canvas on partial failure

If this.saveFreeform throws (e.g. a transient 5xx), a canvas has already been created by this.create but setHomeCanvasId is never called. The next call to ensureHomeCanvas re-enters the branch (because folder.meta?.homeCanvasId is still unset) and creates a second canvas, and so on — each failed attempt leaves an unreachable "Home" canvas in the user's file system that never gets cleaned up.

The fix is to call setHomeCanvasId immediately after create, so idempotency kicks in on retry rather than creating another orphan.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/core/src/canvas/dashboardsService.ts
Line: 245-265

Comment:
**Orphan canvas on partial failure**

If `this.saveFreeform` throws (e.g. a transient 5xx), a canvas has already been created by `this.create` but `setHomeCanvasId` is never called. The next call to `ensureHomeCanvas` re-enters the branch (because `folder.meta?.homeCanvasId` is still unset) and creates a second canvas, and so on — each failed attempt leaves an unreachable "Home" canvas in the user's file system that never gets cleaned up.

The fix is to call `setHomeCanvasId` immediately after `create`, so idempotency kicks in on retry rather than creating another orphan.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +83 to +166
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();
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 resetHomeCanvas has no test coverage

Three cases exercise ensureHomeCanvas (create-and-seed, valid TSX, idempotency), but resetHomeCanvas — the user-visible "Reset to default" button — has none. At a minimum, a test verifying the new version is appended (not overwriting history) and that the returned record carries the reset code would protect the undo flow.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/core/src/canvas/dashboardsService.test.ts
Line: 83-166

Comment:
**`resetHomeCanvas` has no test coverage**

Three cases exercise `ensureHomeCanvas` (create-and-seed, valid TSX, idempotency), but `resetHomeCanvas` — the user-visible "Reset to default" button — has none. At a minimum, a test verifying the new version is appended (not overwriting history) and that the returned record carries the reset code would protect the undo flow.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

const [scope, setScope] = useState<"me" | "team">("me");
const accent = "#1d4aff";
return (
<Section title="Inbox" accent={accent} onNew={() => {}} loading={false} done={true} onLoadMore={() => {}}>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The InboxSection "+ New" button calls an empty handler with no visual feedback. Per project convention, a noop affordance should be disabled with a tooltip explaining it is not yet wired, rather than silently doing nothing.

Suggested change
<Section title="Inbox" accent={accent} onNew={() => {}} loading={false} done={true} onLoadMore={() => {}}>
<Section title="Inbox" accent={accent} onNew={() => {}} loading={false} done={true} onLoadMore={() => {}} newDisabled={true} newTooltip="Coming soon">

Rule Used: When disabling UI components based on certain cond... (source)

Learned From
PostHog/posthog#32600

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/core/src/canvas/dashboardsService.ts
Line: 729

Comment:
The `InboxSection` "+ New" button calls an empty handler with no visual feedback. Per project convention, a noop affordance should be disabled with a tooltip explaining it is not yet wired, rather than silently doing nothing.

```suggestion
    <Section title="Inbox" accent={accent} onNew={() => {}} loading={false} done={true} onLoadMore={() => {}} newDisabled={true} newTooltip="Coming soon">
```

**Rule Used:** When disabling UI components based on certain cond... ([source](https://app.greptile.com/posthog-org-19734/-/custom-context?memory=788b3d9d-2e6e-41e7-a14c-553ff0b3d06c))

**Learned From**
[PostHog/posthog#32600](https://github.com/PostHog/posthog/pull/32600)

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

…ests

- 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
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant