feat(channels): home canvas — host navigation + reset to default#2786
feat(channels): home canvas — host navigation + reset to default#2786raquelmsmith wants to merge 6 commits into
Conversation
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
|
React Doctor found no issues in the changed files. 🎉 Reviewed by React Doctor for commit |
Prompt To Fix All With AIFix 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 |
| 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; |
There was a problem hiding this 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.
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.| 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(); | ||
| }); | ||
|
|
There was a problem hiding this 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.
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={() => {}}> |
There was a problem hiding this 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.
| <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
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)
system.file_systemHogQL table, tagged to the desktop surface.Canvas → app navigation (this work)
navigatepostMessage 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, andchannelIdis host-supplied, so an untrusted canvas can only route within its own channel.window.ph.navigate.{toTask,toNewTask,toCanvas,toNewCanvas}shim inside the iframe;FreeformCanvasforwards the validated intent via a channel-agnosticonNavigateprop; the channel-aware view maps it tonavigationBridge.ref(the real task id), not the file-system row id.Reset to default
Security
channelIdis never iframe-supplied; IDs flow into router params (escaped), never concatenated into paths.FreeformCanvasstays channel-agnostic so the allowlist mapping can't be bypassed.Notes
Testing
@posthog/core,@posthog/host-router,@posthog/uitypecheck clean for these changes.WebsiteLayout.tsxloadingprop;InteractiveFileDiff.tsxfileGap).Created with PostHog Code