Skip to content

Commit 327587d

Browse files
committed
feat(canvas): edit canvases as scratch files via canvas_checkout/publish
Canvas generation previously required the agent to regenerate and emit the entire React source every turn. Add a canvas_checkout / canvas_publish local tool pair (available to both the Claude and Codex adapters) that moves the file I/O tool-side: checkout writes the live source to a scratch path and records the base version, the agent applies targeted edits with its native file tools, and publish reads the file from disk and appends a version — refusing when the canvas moved past the checkout base (concurrent edit or undo) instead of clobbering it. The generation prompt now routes through checkout → edit → publish and no longer embeds the current source. Storage stays wholesale full-file snapshots; a server-side base_version check on the desktop-fs PATCH remains a follow-up. Generated-By: PostHog Code Task-Id: 2b3d176e-3023-41d2-92e8-81eda7c12a8c
1 parent f73be5a commit 327587d

7 files changed

Lines changed: 566 additions & 22 deletions

File tree

packages/agent/src/adapters/local-tools/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { LocalTool, LocalToolCtx, LocalToolGateMeta } from "./registry";
2+
import { canvasCheckoutTool, canvasPublishTool } from "./tools/canvas";
23
import { cloneRepoTool } from "./tools/clone-repo";
34
import { listReposTool } from "./tools/list-repos";
45
import { signedCommitTool } from "./tools/signed-commit";
@@ -23,6 +24,8 @@ export const LOCAL_TOOLS: LocalTool[] = [
2324
listReposTool,
2425
cloneRepoTool,
2526
speakTool,
27+
canvasCheckoutTool,
28+
canvasPublishTool,
2629
];
2730

2831
/** Tools whose gate passes for the given context — the set to actually expose. */
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
canvasScratchDir,
4+
canvasScratchFile,
5+
composePublishedMeta,
6+
} from "./canvas";
7+
8+
describe("canvas scratch paths", () => {
9+
it("keys the scratch dir and file by canvas id, outside any workspace", () => {
10+
expect(canvasScratchDir("dash-1")).toBe("/tmp/posthog-canvas/dash-1");
11+
expect(canvasScratchFile("dash-1")).toBe(
12+
"/tmp/posthog-canvas/dash-1/canvas.tsx",
13+
);
14+
});
15+
});
16+
17+
describe("composePublishedMeta", () => {
18+
const v1 = { id: "v1", code: "one", createdAt: 1 };
19+
const v2 = { id: "v2", code: "two", createdAt: 2 };
20+
const v3 = { id: "v3", code: "three", createdAt: 3 };
21+
22+
it("appends a new head version when the base matches", () => {
23+
const result = composePublishedMeta({
24+
freshMeta: { code: "two", versions: [v1, v2], currentVersionId: "v2" },
25+
baseVersionId: "v2",
26+
code: "edited",
27+
prompt: "tweak the chart",
28+
now: 10,
29+
});
30+
expect(result.ok).toBe(true);
31+
if (!result.ok) return;
32+
expect(result.meta.code).toBe("edited");
33+
expect(result.meta.currentVersionId).toBe(result.versionId);
34+
expect(result.meta.versions).toHaveLength(3);
35+
expect(result.meta.versions?.at(-1)).toMatchObject({
36+
code: "edited",
37+
prompt: "tweak the chart",
38+
createdAt: 10,
39+
});
40+
expect(result.meta.updatedAt).toBe(10);
41+
});
42+
43+
it("preserves unrelated meta keys", () => {
44+
const result = composePublishedMeta({
45+
freshMeta: {
46+
code: "one",
47+
versions: [v1],
48+
currentVersionId: "v1",
49+
templateId: "freeform",
50+
pinnedAt: 123,
51+
},
52+
baseVersionId: "v1",
53+
code: "edited",
54+
now: 10,
55+
});
56+
expect(result.ok).toBe(true);
57+
if (!result.ok) return;
58+
expect(result.meta.templateId).toBe("freeform");
59+
expect(result.meta.pinnedAt).toBe(123);
60+
});
61+
62+
it("rejects when the canvas moved past the base (concurrent edit)", () => {
63+
const result = composePublishedMeta({
64+
freshMeta: {
65+
code: "three",
66+
versions: [v1, v2, v3],
67+
currentVersionId: "v3",
68+
},
69+
baseVersionId: "v2",
70+
code: "edited",
71+
now: 10,
72+
});
73+
expect(result.ok).toBe(false);
74+
});
75+
76+
it("rejects a based publish onto a canvas with no versions", () => {
77+
const result = composePublishedMeta({
78+
freshMeta: {},
79+
baseVersionId: "v1",
80+
code: "edited",
81+
now: 10,
82+
});
83+
expect(result.ok).toBe(false);
84+
});
85+
86+
it("truncates the redo tail when publishing from an undone version", () => {
87+
// The user undid to v1 (pointer mid-history), then the agent edited from
88+
// that checkout: the redo tail (v2, v3) is discarded — the same
89+
// linear-discard the client's undo/redo uses.
90+
const result = composePublishedMeta({
91+
freshMeta: {
92+
code: "one",
93+
versions: [v1, v2, v3],
94+
currentVersionId: "v1",
95+
},
96+
baseVersionId: "v1",
97+
code: "edited",
98+
now: 10,
99+
});
100+
expect(result.ok).toBe(true);
101+
if (!result.ok) return;
102+
expect(result.meta.versions?.map((v) => v.id)).toEqual([
103+
"v1",
104+
result.versionId,
105+
]);
106+
});
107+
108+
it("seeds an empty canvas as the first version (no base)", () => {
109+
const result = composePublishedMeta({
110+
freshMeta: { templateId: "freeform" },
111+
baseVersionId: undefined,
112+
code: "first build",
113+
now: 10,
114+
});
115+
expect(result.ok).toBe(true);
116+
if (!result.ok) return;
117+
expect(result.meta.versions).toHaveLength(1);
118+
expect(result.meta.currentVersionId).toBe(result.versionId);
119+
expect(result.meta.code).toBe("first build");
120+
});
121+
122+
it("rejects an un-based publish onto a canvas that gained versions", () => {
123+
// Checked out empty, but a concurrent first build published meanwhile.
124+
const result = composePublishedMeta({
125+
freshMeta: { code: "one", versions: [v1], currentVersionId: "v1" },
126+
baseVersionId: undefined,
127+
code: "edited",
128+
now: 10,
129+
});
130+
expect(result.ok).toBe(false);
131+
});
132+
});

0 commit comments

Comments
 (0)