Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .claude/skills/canvas-templates/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ A canvas's `kind` (set at create time, persisted in file meta) decides everythin
| --- | --- | --- | --- | --- |
| **json-render** | `"json-render"` | JSONL patches against a component catalog | `ViewRenderer` (Quill component tree) | `state.queries` HogQL, re-run by `dashboardsService` on refresh |
| **freeform / React** | `"freeform"` | a single-file React app | sandboxed `<iframe>` (`FreeformCanvas`) | the `ph.*` shim → host → PostHog |
| **HTML document** | templateId `"html"` | a complete standalone HTML page | sandboxed `<iframe>` (`HtmlArtifactFrame`, no warm pool) | none — static; the agent bakes MCP-queried values in at generation time |

The HTML tier (`HTML_TEMPLATE_ID` / `isHtmlTemplate` in
`packages/core/src/canvas/htmlCanvasSchemas.ts`) is the artifact tier for
documents (reports, specs, one-pagers): no `ph` shim, a locked-down CSP
(`packages/ui/src/features/canvas/html/htmlSandbox.ts` — no network egress),
and an injected annotation shim (`annotationShim.ts`) that powers anchored
teammate comments (text-quote / element / page anchors, stored on PostHog's
comments API via `CanvasCommentsService`, scope `code_canvas`).

Which template maps to which tier: `REACT_TIER_TEMPLATE_IDS` in
`packages/core/src/canvas/freeformSchemas.ts`. Today `dashboard`, `web-analytics`,
Expand Down
2 changes: 2 additions & 0 deletions apps/code/src/renderer/desktop-contributions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { agentChatCoreModule } from "@posthog/core/agent-chat/agentChat.module";
import { autoresearchCoreModule } from "@posthog/core/autoresearch/autoresearch.module";
import { canvasCommentsCoreModule } from "@posthog/core/canvas/canvasComments.module";
import { taskThreadCoreModule } from "@posthog/core/canvas/taskThread.module";
import { inboxCoreModule } from "@posthog/core/inbox/inbox.module";
import { githubConnectModule } from "@posthog/core/integrations/githubConnect.module";
Expand Down Expand Up @@ -36,6 +37,7 @@ export function registerDesktopContributions(): void {
authUiModule,
autoresearchCoreModule,
billingUiModule,
canvasCommentsCoreModule,
taskThreadCoreModule,
browserTabsUiModule,
cloneUiModule,
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/web-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
type IAuthTokenCipher,
} from "@posthog/core/auth/identifiers";
import { canvasCoreModule } from "@posthog/core/canvas/canvas.module";
import { canvasCommentsCoreModule } from "@posthog/core/canvas/canvasComments.module";
import { taskThreadCoreModule } from "@posthog/core/canvas/taskThread.module";
import type { CloudTaskService } from "@posthog/core/cloud-task/cloud-task";
import { cloudTaskModule } from "@posthog/core/cloud-task/cloud-task.module";
Expand Down Expand Up @@ -468,6 +469,7 @@ container.bind(CLOUD_TASK_AUTH).toDynamicValue((ctx) => ({
// API), so the web host binds them by loading the same core module desktop does;
// the web host router forwards its canvas routers to these.
container.load(canvasCoreModule);
container.load(canvasCommentsCoreModule);
container.load(taskThreadCoreModule);

// SessionService is built from host-agnostic deps (host tRPC client + UI
Expand Down
61 changes: 61 additions & 0 deletions packages/api-client/src/posthog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2666,6 +2666,67 @@ export class PostHogAPIClient {
return (await response.json()) as TaskThreadMessage;
}

// --- Comments (discussions) ----------------------------------------------
// PostHog's generic comments surface: a comment attaches to any resource via
// a free-form `scope` + `item_id` pair, carries an arbitrary JSON
// `item_context` (we store comment anchors there — see canvas comments in
// core), and threads via `source_comment`. DELETE is disabled server-side
// (405); deletion is a soft PATCH `deleted: true`.

async listComments(
scope: string,
itemId: string,
): Promise<Schemas.Comment[]> {
const COMMENTS_MAX_PAGES = 20;
const teamId = await this.getTeamId();
const all: Schemas.Comment[] = [];
let cursor: string | undefined;
for (let i = 0; i < COMMENTS_MAX_PAGES; i++) {
const page = await this.api.get("/api/projects/{project_id}/comments/", {
path: { project_id: teamId.toString() },
query: { scope, item_id: itemId, ...(cursor ? { cursor } : {}) },
});
all.push(...page.results);
if (!page.next) return all;
// `next` is a full URL; the typed endpoint paginates by `cursor` param.
cursor = new URL(page.next).searchParams.get("cursor") ?? undefined;
if (!cursor) return all;
}
log.warn(
`listComments hit MAX_PAGES (${COMMENTS_MAX_PAGES}); returning partial results`,
{ returned: all.length },
);
return all;
}

async createComment(input: {
content: string;
scope: string;
item_id: string;
item_context?: Record<string, unknown>;
source_comment?: string;
}): Promise<Schemas.Comment> {
const teamId = await this.getTeamId();
// The generated Comment type demands server-stamped fields (id, created_by,
// version, …) and types the JSON `item_context` as `null`, so the write
// shape needs the same cast createTask uses.
return await this.api.post("/api/projects/{project_id}/comments/", {
path: { project_id: teamId.toString() },
body: input as unknown as Schemas.Comment,
});
}

async patchComment(
id: string,
patch: { deleted?: boolean; content?: string },
): Promise<Schemas.Comment> {
const teamId = await this.getTeamId();
return await this.api.patch("/api/projects/{project_id}/comments/{id}/", {
path: { project_id: teamId.toString(), id },
body: patch as Schemas.PatchedComment,
});
}

// Everyone in the current organization — the pool of taggable teammates for
// thread @-mentions. Membership churn is slow, so callers cache aggressively.
async listOrganizationMembers(): Promise<OrganizationMemberBasic[]> {
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/canvas/canvasComments.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { ContainerModule } from "inversify";
import {
CANVAS_COMMENTS_SERVICE,
CanvasCommentsService,
} from "./canvasCommentsService";

export const canvasCommentsCoreModule = new ContainerModule(({ bind }) => {
bind(CanvasCommentsService).toSelf().inSingletonScope();
bind(CANVAS_COMMENTS_SERVICE).toService(CanvasCommentsService);
});
44 changes: 44 additions & 0 deletions packages/core/src/canvas/canvasCommentsSchemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { z } from "zod";
import { commentAnchorSchema } from "./htmlCanvasSchemas";

// The comments scope for canvas artifacts on PostHog's generic comments API
// (`scope` is a free-form discriminator there). One constant so every reader
// and writer agrees; `item_id` is the canvas's file-system row id.
export const CANVAS_COMMENTS_SCOPE = "code_canvas";

// What we store in a comment's `item_context` JSON. Roots carry the anchor
// (where in the document the comment points); replies carry no anchor — they
// inherit their root's. `canvasVersionId` records which canvas version the
// anchor was made against, for diagnostics and a future "view at that version".
export const canvasCommentContextSchema = z.object({
version: z.literal(1),
anchor: commentAnchorSchema.optional(),
canvasVersionId: z.string().optional(),
});
export type CanvasCommentContext = z.infer<typeof canvasCommentContextSchema>;

// A canvas comment as the app consumes it — parsed from the API row, with the
// anchor already extracted from `item_context` (null = page-level, a reply, or
// an unparseable/foreign context; the panel treats null as unanchored).
export const canvasCommentSchema = z.object({
id: z.string(),
content: z.string(),
createdAt: z.number(),
createdBy: z.object({
uuid: z.string(),
name: z.string(),
email: z.string(),
}),
anchor: commentAnchorSchema.nullable(),
sourceCommentId: z.string().nullable(),
});
export type CanvasComment = z.infer<typeof canvasCommentSchema>;

// A root comment with its replies. `index` is the 1-based pin number painted
// in the document and shown beside the thread in the panel.
export const canvasCommentThreadSchema = z.object({
root: canvasCommentSchema,
replies: z.array(canvasCommentSchema),
index: z.number().int(),
});
export type CanvasCommentThread = z.infer<typeof canvasCommentThreadSchema>;
187 changes: 187 additions & 0 deletions packages/core/src/canvas/canvasCommentsService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import type { Schemas } from "@posthog/api-client/generated";
import type { PostHogAPIClient } from "@posthog/api-client/posthog-client";
import {
CanvasCommentsService,
groupThreads,
parseCanvasComment,
} from "@posthog/core/canvas/canvasCommentsService";
import type { CanvasComment } from "@posthog/core/canvas/canvasCommentsSchemas";
import { describe, expect, it, vi } from "vitest";

function apiComment(overrides: Partial<Schemas.Comment> = {}): Schemas.Comment {
return {
id: "c1",
created_by: {
id: 1,
uuid: "u-1",
first_name: "Ada",
last_name: "Lovelace",
email: "ada@example.com",
hedgehog_config: null,
} as Schemas.Comment["created_by"],
version: 0,
created_at: "2026-07-01T10:00:00Z",
content: "Looks off",
scope: "code_canvas",
item_id: "dash-1",
...overrides,
};
}

describe("parseCanvasComment", () => {
it("maps an anchored root comment", () => {
const parsed = parseCanvasComment(
apiComment({
item_context: {
version: 1,
anchor: { type: "text", quote: "18%", prefix: "grew ", suffix: " q" },
} as unknown as Schemas.Comment["item_context"],
}),
);
expect(parsed).toMatchObject({
id: "c1",
content: "Looks off",
createdBy: {
uuid: "u-1",
name: "Ada Lovelace",
email: "ada@example.com",
},
anchor: { type: "text", quote: "18%", prefix: "grew ", suffix: " q" },
sourceCommentId: null,
});
expect(parsed?.createdAt).toBe(Date.parse("2026-07-01T10:00:00Z"));
});

it("drops deleted rows", () => {
expect(parseCanvasComment(apiComment({ deleted: true }))).toBeNull();
});

it("degrades an unparseable item_context to a null anchor", () => {
const parsed = parseCanvasComment(
apiComment({
item_context: {
version: 99,
anchor: { type: "wat" },
} as unknown as Schemas.Comment["item_context"],
}),
);
expect(parsed?.anchor).toBeNull();
});

it("falls back to the email when the name is empty", () => {
const parsed = parseCanvasComment(
apiComment({
created_by: {
id: 2,
uuid: "u-2",
first_name: "",
email: "no-name@example.com",
hedgehog_config: null,
} as Schemas.Comment["created_by"],
}),
);
expect(parsed?.createdBy.name).toBe("no-name@example.com");
});
});

describe("groupThreads", () => {
function comment(overrides: Partial<CanvasComment>): CanvasComment {
return {
id: "x",
content: "",
createdAt: 0,
createdBy: { uuid: "u", name: "U", email: "u@example.com" },
anchor: null,
sourceCommentId: null,
...overrides,
};
}

it("groups replies under roots, oldest-first, with 1-based indexes", () => {
const threads = groupThreads([
comment({ id: "b", createdAt: 2 }),
comment({ id: "a", createdAt: 1 }),
comment({ id: "r2", createdAt: 4, sourceCommentId: "a" }),
comment({ id: "r1", createdAt: 3, sourceCommentId: "a" }),
]);
expect(threads.map((t) => t.root.id)).toEqual(["a", "b"]);
expect(threads.map((t) => t.index)).toEqual([1, 2]);
expect(threads[0]?.replies.map((r) => r.id)).toEqual(["r1", "r2"]);
expect(threads[1]?.replies).toEqual([]);
});

it("promotes a reply whose root is missing to its own root", () => {
const threads = groupThreads([
comment({ id: "orphan", createdAt: 5, sourceCommentId: "gone" }),
]);
expect(threads).toHaveLength(1);
expect(threads[0]?.root.id).toBe("orphan");
});
});

describe("CanvasCommentsService", () => {
it("lists, filters deleted, and threads comments", async () => {
const client = {
listComments: vi.fn().mockResolvedValue([
apiComment({ id: "root", created_at: "2026-07-01T10:00:00Z" }),
apiComment({
id: "reply",
created_at: "2026-07-01T11:00:00Z",
source_comment: "root",
}),
apiComment({ id: "zombie", deleted: true }),
]),
} as unknown as PostHogAPIClient;
const threads = await new CanvasCommentsService().listThreads(
client,
"dash-1",
);
expect(client.listComments).toHaveBeenCalledWith("code_canvas", "dash-1");
expect(threads).toHaveLength(1);
expect(threads[0]?.root.id).toBe("root");
expect(threads[0]?.replies.map((r) => r.id)).toEqual(["reply"]);
});

it("creates roots with the anchor context and replies without one", async () => {
const createComment = vi.fn().mockResolvedValue(apiComment());
const patchComment = vi.fn().mockResolvedValue(apiComment());
const client = {
createComment,
patchComment,
} as unknown as PostHogAPIClient;
const service = new CanvasCommentsService();

await service.addComment(client, {
dashboardId: "dash-1",
content: "hm",
anchor: { type: "page" },
canvasVersionId: "v9",
});
expect(createComment).toHaveBeenCalledWith({
content: "hm",
scope: "code_canvas",
item_id: "dash-1",
item_context: {
version: 1,
anchor: { type: "page" },
canvasVersionId: "v9",
},
});

await service.addReply(client, {
dashboardId: "dash-1",
content: "agreed",
rootId: "root-1",
});
expect(createComment).toHaveBeenCalledWith({
content: "agreed",
scope: "code_canvas",
item_id: "dash-1",
item_context: { version: 1 },
source_comment: "root-1",
});

await service.remove(client, "c-9");
expect(patchComment).toHaveBeenCalledWith("c-9", { deleted: true });
});
});
Loading
Loading