diff --git a/packages/agent/src/adapters/local-tools/index.ts b/packages/agent/src/adapters/local-tools/index.ts index c2aab7139d..716bbb478e 100644 --- a/packages/agent/src/adapters/local-tools/index.ts +++ b/packages/agent/src/adapters/local-tools/index.ts @@ -1,4 +1,5 @@ import type { LocalTool, LocalToolCtx, LocalToolGateMeta } from "./registry"; +import { canvasCheckoutTool, canvasPublishTool } from "./tools/canvas"; import { cloneRepoTool } from "./tools/clone-repo"; import { listReposTool } from "./tools/list-repos"; import { signedCommitTool } from "./tools/signed-commit"; @@ -23,6 +24,8 @@ export const LOCAL_TOOLS: LocalTool[] = [ listReposTool, cloneRepoTool, speakTool, + canvasCheckoutTool, + canvasPublishTool, ]; /** Tools whose gate passes for the given context — the set to actually expose. */ diff --git a/packages/agent/src/adapters/local-tools/tools/canvas.test.ts b/packages/agent/src/adapters/local-tools/tools/canvas.test.ts new file mode 100644 index 0000000000..7cf32f9e2c --- /dev/null +++ b/packages/agent/src/adapters/local-tools/tools/canvas.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { canvasScratchDir, canvasScratchFile } from "./canvas"; + +// Version composition and the stale-base rejection live server-side in the +// desktop-fs canvas action (and are tested there); the tool's own logic is +// the scratch-file plumbing. +describe("canvas scratch paths", () => { + it("keys the scratch dir and file by canvas id, outside any workspace", () => { + expect(canvasScratchDir("dash-1")).toBe("/tmp/posthog-canvas/dash-1"); + expect(canvasScratchFile("dash-1")).toBe( + "/tmp/posthog-canvas/dash-1/canvas.tsx", + ); + }); +}); diff --git a/packages/agent/src/adapters/local-tools/tools/canvas.ts b/packages/agent/src/adapters/local-tools/tools/canvas.ts new file mode 100644 index 0000000000..d846678dfd --- /dev/null +++ b/packages/agent/src/adapters/local-tools/tools/canvas.ts @@ -0,0 +1,232 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import * as path from "node:path"; +import { z } from "zod"; +import { + DesktopCanvasVersionConflictError, + PostHogAPIClient, +} from "../../../posthog-api"; +import { resolveSandboxPosthogApi } from "../../../signed-commit-artefacts"; +import { defineLocalTool, type LocalToolResult } from "../registry"; + +/** + * Local tools for working on freeform canvases (desktop-fs `dashboard` rows) + * as scratch files, so the agent edits the source incrementally with its + * native file tools instead of regenerating (or transcribing) the whole file: + * + * - `canvas_checkout` fetches the canvas, writes `meta.code` to a + * deterministic scratch path tool-side (no model transcription), and stashes + * the fetched `currentVersionId` as the publish-time concurrency base. + * - `canvas_publish` reads the scratch file from disk (again, no + * transcription) and publishes through the desktop-fs canvas action, which + * owns version composition server-side. The stashed base rides along as + * `expected_current_version_id`, so a publish based on a stale read (a + * concurrent edit, or a user's undo) is rejected atomically with a version + * conflict instead of clobbering the newer head. + * + * Credentials come from the sandbox environment (see + * `resolveSandboxPosthogApi`), so this works identically from the Claude + * in-process server and the Codex stdio child. + */ + +// Deliberately outside any workspace: scratch files never show up in +// changed-file diff panels or `git status` (a canvas task can lazily attach a +// repo), and the canvas id keeps concurrent generations from colliding. +export const CANVAS_SCRATCH_ROOT = "/tmp/posthog-canvas"; + +export function canvasScratchDir(canvasId: string): string { + return path.join(CANVAS_SCRATCH_ROOT, canvasId); +} + +export function canvasScratchFile(canvasId: string): string { + return path.join(canvasScratchDir(canvasId), "canvas.tsx"); +} + +function baseVersionMarkerFile(canvasId: string): string { + return path.join(canvasScratchDir(canvasId), ".base-version.json"); +} + +interface CanvasMeta { + code?: string; + currentVersionId?: string; + [key: string]: unknown; +} + +interface CanvasFsEntry { + id: string; + path: string; + type?: string; + meta?: CanvasMeta | null; +} + +interface BaseVersionMarker { + versionId?: string; + fetchedAt: number; +} + +function createClient(): PostHogAPIClient | undefined { + const api = resolveSandboxPosthogApi(); + if (!api) return undefined; + return new PostHogAPIClient({ + apiUrl: api.apiUrl, + projectId: api.projectId, + getApiKey: () => api.apiKey, + }); +} + +async function fetchCanvasEntry(canvasId: string): Promise { + const client = createClient(); + if (!client) { + throw new Error("No PostHog credentials available in this session."); + } + const entry = await client.getDesktopFsEntry(canvasId); + if (!entry) { + throw new Error( + `Canvas ${canvasId} not found. Check the id — it should be a desktop-fs dashboard row id.`, + ); + } + if (entry.type !== "dashboard") { + throw new Error( + `Entry ${canvasId} is type "${entry.type}", not a canvas ("dashboard").`, + ); + } + return entry; +} + +function readMarker(canvasId: string): BaseVersionMarker | undefined { + try { + return JSON.parse( + readFileSync(baseVersionMarkerFile(canvasId), "utf8"), + ) as BaseVersionMarker; + } catch { + return undefined; + } +} + +function writeMarker(canvasId: string, marker: BaseVersionMarker): void { + mkdirSync(canvasScratchDir(canvasId), { recursive: true }); + writeFileSync(baseVersionMarkerFile(canvasId), JSON.stringify(marker)); +} + +function errorResult(text: string): LocalToolResult { + return { content: [{ type: "text", text }], isError: true }; +} + +const CONFLICT_MESSAGE = (canvasId: string) => + `version-conflict: the canvas changed since your checkout (a concurrent edit, or the user's undo). ` + + `Recover: call canvas_checkout with id "${canvasId}" again (it re-seeds the scratch file from the live source), ` + + `re-apply your edits to it, then call canvas_publish again.`; + +export const canvasCheckoutTool = defineLocalTool({ + name: "canvas_checkout", + description: + "Check out a PostHog canvas (a freeform React desktop-fs dashboard) for editing: fetches the live " + + "source, writes it to a local scratch file, and records the version your edits are based on. " + + "Returns the file path. Edit that file with your normal file-editing tools, then call " + + "canvas_publish to save. Always start canvas work with this tool.", + schema: { + id: z.string().describe("The canvas (desktop-fs dashboard row) id."), + }, + alwaysLoad: true, + isEnabled: () => resolveSandboxPosthogApi() !== undefined, + handler: async (_ctx, args): Promise => { + try { + const entry = await fetchCanvasEntry(args.id); + const file = canvasScratchFile(args.id); + const code = entry.meta?.code ?? ""; + mkdirSync(canvasScratchDir(args.id), { recursive: true }); + writeFileSync(file, code); + writeMarker(args.id, { + versionId: entry.meta?.currentVersionId, + fetchedAt: Date.now(), + }); + const lines = code ? code.split("\n").length : 0; + const text = code + ? `Checked out canvas "${entry.path}" to ${file} (${lines} lines, base version ${ + entry.meta?.currentVersionId ?? "none" + }).\nApply your changes by editing that file, then call canvas_publish with id "${args.id}".` + : `Canvas "${entry.path}" is empty. Author the complete single-file React app at ${file}, then call canvas_publish with id "${args.id}".`; + return { content: [{ type: "text", text }] }; + } catch (err) { + return errorResult( + `canvas_checkout failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + }, +}); + +export const canvasPublishTool = defineLocalTool({ + name: "canvas_publish", + description: + "Publish the checked-out canvas: reads the scratch file written by canvas_checkout and saves it as " + + "the canvas's new live version, guarded against the canvas having changed since checkout. Call " + + "exactly once when the edit is complete. On a version-conflict error, re-run canvas_checkout, " + + "re-apply your edits, and publish again.", + schema: { + id: z.string().describe("The canvas (desktop-fs dashboard row) id."), + prompt: z + .string() + .optional() + .describe( + "One short sentence describing the change, stored on the version history entry.", + ), + }, + alwaysLoad: true, + isEnabled: () => resolveSandboxPosthogApi() !== undefined, + handler: async (_ctx, args): Promise => { + let code: string; + try { + code = readFileSync(canvasScratchFile(args.id), "utf8"); + } catch { + return errorResult( + `canvas_publish failed: no scratch file for canvas "${args.id}". Call canvas_checkout first, edit the file it returns, then publish.`, + ); + } + if (!code.trim()) { + return errorResult( + `canvas_publish failed: the scratch file for canvas "${args.id}" is empty.`, + ); + } + const marker = readMarker(args.id); + if (!marker) { + return errorResult( + `canvas_publish failed: no checkout record for canvas "${args.id}". Call canvas_checkout first.`, + ); + } + const client = createClient(); + if (!client) { + return errorResult( + "canvas_publish failed: no PostHog credentials available in this session.", + ); + } + try { + const entry = await client.publishDesktopCanvas(args.id, { + code, + prompt: args.prompt, + expectedCurrentVersionId: marker.versionId ?? null, + }); + // Advance the base so a follow-up publish in the same session works + // without a re-checkout. + const newVersionId = entry.meta?.currentVersionId; + writeMarker(args.id, { versionId: newVersionId, fetchedAt: Date.now() }); + return { + content: [ + { + type: "text", + text: `Published canvas "${args.id}"${ + newVersionId ? ` (new version ${newVersionId})` : "" + }. The canvas is live; do not paste the code into chat.`, + }, + ], + }; + } catch (err) { + if (err instanceof DesktopCanvasVersionConflictError) { + return errorResult( + `canvas_publish failed: ${CONFLICT_MESSAGE(args.id)}`, + ); + } + return errorResult( + `canvas_publish failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + }, +}); diff --git a/packages/agent/src/posthog-api.ts b/packages/agent/src/posthog-api.ts index b3596fe424..9d5f425a66 100644 --- a/packages/agent/src/posthog-api.ts +++ b/packages/agent/src/posthog-api.ts @@ -63,6 +63,14 @@ export type TaskRunUpdate = Partial< state_remove_keys?: string[]; }; +/** A guarded canvas publish was based on a stale version (409 from the API). */ +export class DesktopCanvasVersionConflictError extends Error { + constructor(readonly currentVersionId: string | null) { + super("Canvas version conflict: the canvas changed since it was read."); + this.name = "DesktopCanvasVersionConflictError"; + } +} + export class PostHogAPIClient { private config: PostHogAPIConfig; @@ -338,6 +346,71 @@ export class PostHogAPIClient { .filter((artifact): artifact is TaskRunArtifact => !!artifact); } + /** + * Fetch a desktop file system entry (e.g. a canvas "dashboard" row) by id. + * Returns null on 404 so callers can produce a friendly not-found message. + */ + async getDesktopFsEntry(entryId: string): Promise { + const teamId = this.getTeamId(); + const response = await this.performRequestWithRetry( + `/api/projects/${teamId}/desktop_file_system/${encodeURIComponent(entryId)}/`, + ); + if (response.status === 404) { + return null; + } + if (!response.ok) { + throw new Error(`Failed to fetch desktop-fs entry (${response.status})`); + } + return response.json() as Promise; + } + + /** + * Publish a freeform canvas's source via the desktop-fs canvas action. The + * server owns version composition (appends the full-file snapshot, moves + * `currentVersionId`, truncates any redo tail) and rejects a publish whose + * `expectedCurrentVersionId` no longer matches the live head with a 409 — + * surfaced as DesktopCanvasVersionConflictError. Backends predating the + * guard ignore the field and publish unguarded, so this degrades gracefully. + */ + async publishDesktopCanvas( + entryId: string, + input: { + code: string; + prompt?: string; + /** The head version the code was based on; null when it was empty. */ + expectedCurrentVersionId: string | null; + }, + ): Promise { + const teamId = this.getTeamId(); + const body: Record = { + code: input.code, + expected_current_version_id: input.expectedCurrentVersionId, + }; + if (input.prompt) { + body.prompt = input.prompt; + } + const response = await this.performRequestWithRetry( + `/api/projects/${teamId}/desktop_file_system/${encodeURIComponent(entryId)}/canvas/`, + { method: "PATCH", body: JSON.stringify(body) }, + ); + if (response.status === 409) { + let currentVersionId: string | null = null; + try { + const parsed = (await response.json()) as { + current_version_id?: string | null; + }; + currentVersionId = parsed.current_version_id ?? null; + } catch { + // Conflict body unavailable — the status alone carries the signal. + } + throw new DesktopCanvasVersionConflictError(currentVersionId); + } + if (!response.ok) { + throw new Error(`Failed to publish canvas (${response.status})`); + } + return response.json() as Promise; + } + /** Signal reports the given task is associated with (via report task associations). */ async getSignalReportIdsForTask(taskId: string): Promise { const teamId = this.getTeamId(); diff --git a/packages/core/src/canvas/canvasTemplates.ts b/packages/core/src/canvas/canvasTemplates.ts index d75cfba72b..7eb864c803 100644 --- a/packages/core/src/canvas/canvasTemplates.ts +++ b/packages/core/src/canvas/canvasTemplates.ts @@ -28,10 +28,9 @@ const FREEFORM_WHITELIST_NAMES = FREEFORM_WHITELIST.map((e) => e.name).join( const FREEFORM_BASE = [ "You are PostHog Canvas, an agent that builds a freeform React app for the user's current PostHog project. The app runs in a sandboxed iframe.", "", - "OUTPUT FORMAT — every turn:", - "- Write a SHORT sentence of prose, then the COMPLETE app as ONE fenced code block tagged tsx (```tsx ... ```).", - "- FULL-FILE REWRITE: always output the entire file, even for a tiny change. Never output a partial file, a diff, or multiple code blocks.", + "OUTPUT FORMAT — the app is ONE complete file:", "- The file MUST `export default` a single React component that takes no props.", + "- Maintain it as a working file with your file-editing tools: seed it once, then apply changes as TARGETED edits. Do not regenerate the whole file for a small change, and do not paste the source into chat — a short sentence describing the change is enough.", "", "IMPORTS — allowed packages ONLY:", `- You may import ONLY from: ${FREEFORM_WHITELIST_NAMES}.`, diff --git a/packages/core/src/canvas/freeformSchemas.ts b/packages/core/src/canvas/freeformSchemas.ts index 8f683bfa33..be44d942d9 100644 --- a/packages/core/src/canvas/freeformSchemas.ts +++ b/packages/core/src/canvas/freeformSchemas.ts @@ -5,7 +5,8 @@ import { z } from "zod"; export const FREEFORM_TEMPLATE_ID = "freeform"; // A single point in a freeform canvas's edit history. Every agent turn appends -// one full-file snapshot (Q7: full-file rewrite); the user can revert to any of +// one full-file snapshot (the agent edits a local scratch copy incrementally, +// then publishes the finished file wholesale); the user can revert to any of // them and the `currentVersionId` pointer is what publishes. We keep whole-file // snapshots rather than diffs because canvases are small and a snapshot can // never fail to reconstruct. @@ -163,6 +164,51 @@ export type CanvasAnalyticsConfig = z.infer; export const canvasThemeSchema = z.enum(["light", "dark"]); export type CanvasTheme = z.infer; +// --------------------------------------------------------------------------- +// Annotation targets captured by the comment-mode overlay (structured canvas +// feedback). Deliberately LLM-locatable rather than machine-precise: the +// consumer is the generation agent locating the spot in source IT wrote, so +// the bounded text + stable attributes matter more than selector exactness. +// --------------------------------------------------------------------------- +export const canvasElementTargetSchema = z.object({ + type: z.literal("element"), + // Best-effort CSS selector for the rendered element (stable attributes + // preferred, nth-of-type fallback). + selector: z.string(), + tag: z.string(), + // Bounded innerText snippet — usually the strongest locator. + text: z.string(), + ariaLabel: z.string().nullable().optional(), + // The stable attributes present on the element (data-attr, id, role, …). + attributes: z.record(z.string(), z.string()), +}); +export type CanvasElementTarget = z.infer; + +export const canvasTextRangeTargetSchema = z.object({ + type: z.literal("text-range"), + // The selected text, bounded. + text: z.string(), + ancestorSelector: z.string(), + ancestorTag: z.string(), +}); +export type CanvasTextRangeTarget = z.infer; + +export const canvasAnnotationTargetSchema = z.discriminatedUnion("type", [ + canvasElementTargetSchema, + canvasTextRangeTargetSchema, +]); +export type CanvasAnnotationTarget = z.infer< + typeof canvasAnnotationTargetSchema +>; + +// A queued annotation the host asks the overlay to render as a numbered pin. +export const canvasAnnotationPinSchema = z.object({ + id: z.string(), + n: z.number(), + selector: z.string(), +}); +export type CanvasAnnotationPin = z.infer; + // host -> iframe export const hostToCanvasMessageSchema = z.discriminatedUnion("type", [ // First frame: hand the iframe its source + the run mode. The iframe does not @@ -198,6 +244,19 @@ export const hostToCanvasMessageSchema = z.discriminatedUnion("type", [ result: z.unknown().optional(), error: z.string().optional(), }), + // Toggle the annotation ("comment mode") overlay: crosshair cursor, hover + // highlight, and click-to-capture targets. + z.object({ + channel: z.literal(CANVAS_CHANNEL), + type: z.literal("set-annotation-mode"), + enabled: z.boolean(), + }), + // The queued annotations to render as numbered pins over their targets. + z.object({ + channel: z.literal(CANVAS_CHANNEL), + type: z.literal("annotation-pins"), + pins: z.array(canvasAnnotationPinSchema), + }), ]); export type HostToCanvasMessage = z.infer; @@ -253,5 +312,12 @@ export const canvasToHostMessageSchema = z.discriminatedUnion("type", [ type: z.literal("navigate"), nav: canvasNavIntentSchema, }), + // A comment-mode click captured an annotation target. The comment itself is + // typed host-side; only the target crosses the boundary. + z.object({ + channel: z.literal(CANVAS_CHANNEL), + type: z.literal("annotation-target"), + target: canvasAnnotationTargetSchema, + }), ]); export type CanvasToHostMessage = z.infer; diff --git a/packages/ui/src/features/canvas/AGENTS.md b/packages/ui/src/features/canvas/AGENTS.md index 0ea928dfbf..a21bc3a65a 100644 --- a/packages/ui/src/features/canvas/AGENTS.md +++ b/packages/ui/src/features/canvas/AGENTS.md @@ -75,10 +75,19 @@ The root `AGENTS.md` architecture rules still apply. documented as `DashboardFileMeta` in `dashboardSchemas.ts`. This keeps canvas and channel names in sync with the backend — the same surface that owns channels (top-level `folder` rows, see `hooks/useChannels.ts`). -- `meta` is **last-write-wins, unversioned** at the fs layer (no `base_version`). - Freeform autosaves the whole file each agent turn, so a concurrent edit from - another client can clobber. Acceptable for now; revisit with optimistic - concurrency if multi-client editing becomes real. +- `meta` is **last-write-wins, unversioned** at the fs layer (no `base_version`) + — except the **canvas publish action** (`PATCH …/:id/canvas/`), which owns + version composition server-side and accepts an optional + `expected_current_version_id`: a publish based on a stale version (a + concurrent edit or undo) is rejected 409 inside the row lock instead of + clobbering the newer head. The **agent publish path uses that guard**: + generation works the source as a local scratch file via the + `canvas_checkout` / `canvas_publish` local tools (`@posthog/agent`, + `adapters/local-tools/tools/canvas.ts`) — checkout records the fetched + `currentVersionId`, publish passes it as the expected version (backends + predating the field ignore it and publish unguarded). **User-side saves** + (`saveFreeform`) are still last-write-wins; adopt the same guard if + multi-client editing becomes real. ## Channel sidebar preloading diff --git a/packages/ui/src/features/canvas/freeform/CanvasFrameHost.tsx b/packages/ui/src/features/canvas/freeform/CanvasFrameHost.tsx index 56aaa920d9..45f6888cac 100644 --- a/packages/ui/src/features/canvas/freeform/CanvasFrameHost.tsx +++ b/packages/ui/src/features/canvas/freeform/CanvasFrameHost.tsx @@ -56,6 +56,9 @@ export function CanvasFrameHost() { onError={slot.inputs.onError} onRendered={slot.inputs.onRendered} onNavigate={slot.inputs.onNavigate} + annotationMode={slot.inputs.annotationMode} + annotationPins={slot.inputs.annotationPins} + onAnnotationTarget={slot.inputs.onAnnotationTarget} /> diff --git a/packages/ui/src/features/canvas/freeform/CanvasFramePlaceholder.tsx b/packages/ui/src/features/canvas/freeform/CanvasFramePlaceholder.tsx index fb56f90e44..b442739bfb 100644 --- a/packages/ui/src/features/canvas/freeform/CanvasFramePlaceholder.tsx +++ b/packages/ui/src/features/canvas/freeform/CanvasFramePlaceholder.tsx @@ -1,5 +1,7 @@ import type { CanvasAnalyticsConfig, + CanvasAnnotationPin, + CanvasAnnotationTarget, CanvasNavIntent, } from "@posthog/core/canvas/freeformSchemas"; import { useEffect, useLayoutEffect, useMemo, useRef } from "react"; @@ -19,6 +21,9 @@ export function CanvasFramePlaceholder({ onError, onRendered, onNavigate, + annotationMode, + annotationPins, + onAnnotationTarget, }: { dashboardId: string; code: string; @@ -27,6 +32,9 @@ export function CanvasFramePlaceholder({ onError?: (message: string, stack?: string) => void; onRendered?: () => void; onNavigate?: (intent: CanvasNavIntent) => void; + annotationMode?: boolean; + annotationPins?: CanvasAnnotationPin[]; + onAnnotationTarget?: (target: CanvasAnnotationTarget) => void; }) { const ref = useRef(null); const refreshKey = useCanvasRefreshNonce(`dashboard:${dashboardId}`); @@ -45,6 +53,9 @@ export function CanvasFramePlaceholder({ onError, onRendered, onNavigate, + annotationMode, + annotationPins, + onAnnotationTarget, }), [ code, @@ -54,6 +65,9 @@ export function CanvasFramePlaceholder({ onError, onRendered, onNavigate, + annotationMode, + annotationPins, + onAnnotationTarget, ], ); diff --git a/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx b/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx index 9a64218654..23578edb63 100644 --- a/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx +++ b/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx @@ -1,5 +1,7 @@ import { type CanvasAnalyticsConfig, + type CanvasAnnotationPin, + type CanvasAnnotationTarget, type CanvasNavIntent, type CanvasToHostMessage, canvasToHostMessageSchema, @@ -51,6 +53,12 @@ export interface FreeformCanvasProps { * reloads the frame via the existing reload path. */ refreshKey?: number; + /** Comment mode: the overlay captures element/text targets on click. */ + annotationMode?: boolean; + /** Queued annotations rendered as numbered pins over their targets. */ + annotationPins?: CanvasAnnotationPin[]; + /** Called when the overlay captures a target (the comment is typed host-side). */ + onAnnotationTarget?: (target: CanvasAnnotationTarget) => void; } // Renders a freeform-React canvas inside a null-origin sandboxed iframe and @@ -65,6 +73,9 @@ export function FreeformCanvas({ onNavigate, analytics, refreshKey = 0, + annotationMode = false, + annotationPins, + onAnnotationTarget, }: FreeformCanvasProps) { const iframeRef = useRef(null); // The canvas mirrors the host's light/dark theme. Passed via `init` (not the @@ -92,25 +103,32 @@ export function FreeformCanvas({ onError, onRendered, onNavigate, + onAnnotationTarget, code, mode, analytics, theme, + annotationMode, + annotationPins, }); latest.current = { onDataRequest, onError, onRendered, onNavigate, + onAnnotationTarget, code, mode, analytics, theme, + annotationMode, + annotationPins, }; const postInit = useCallback(() => { const p = latest.current; - iframeRef.current?.contentWindow?.postMessage( + const target = iframeRef.current?.contentWindow; + target?.postMessage( { channel: "posthog-canvas", type: "init", @@ -121,6 +139,24 @@ export function FreeformCanvas({ }, "*", ); + // A reload resets the overlay's state; re-sync comment mode + pins so they + // survive srcDoc changes exactly like the theme survives via init. + target?.postMessage( + { + channel: "posthog-canvas", + type: "set-annotation-mode", + enabled: p.annotationMode, + }, + "*", + ); + target?.postMessage( + { + channel: "posthog-canvas", + type: "annotation-pins", + pins: p.annotationPins ?? [], + }, + "*", + ); }, []); // The iframe reloads only when srcDoc changes (mode / analytics host); on @@ -182,6 +218,9 @@ export function FreeformCanvas({ // msg.nav is already allowlist-validated by safeParse below. latest.current.onNavigate?.(msg.nav); break; + case "annotation-target": + latest.current.onAnnotationTarget?.(msg.target); + break; } }; @@ -232,6 +271,30 @@ export function FreeformCanvas({ ); }, [theme]); + // Live comment-mode + pin updates (no remount; postInit re-syncs on reload). + useEffect(() => { + if (!readyRef.current) return; + iframeRef.current?.contentWindow?.postMessage( + { + channel: "posthog-canvas", + type: "set-annotation-mode", + enabled: annotationMode, + }, + "*", + ); + }, [annotationMode]); + useEffect(() => { + if (!readyRef.current) return; + iframeRef.current?.contentWindow?.postMessage( + { + channel: "posthog-canvas", + type: "annotation-pins", + pins: annotationPins ?? [], + }, + "*", + ); + }, [annotationPins]); + return (