agent-canvas 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/agent-canvas.js +14 -0
- package/bin/agent-canvas.ts +377 -0
- package/daemon/build.ts +185 -0
- package/daemon/client/AnnotationProvider.tsx +185 -0
- package/daemon/client/AnnotationSidebar.tsx +314 -0
- package/daemon/client/App.tsx +611 -0
- package/daemon/client/CompareView.tsx +333 -0
- package/daemon/client/FileBrowser.tsx +188 -0
- package/daemon/client/FileIcon.tsx +76 -0
- package/daemon/client/FileViewer.tsx +207 -0
- package/daemon/client/PlanRenderer.tsx +665 -0
- package/daemon/client/Popover.tsx +285 -0
- package/daemon/client/ResponsePreview.tsx +260 -0
- package/daemon/client/RevisionSelect.tsx +97 -0
- package/daemon/client/SessionContext.tsx +3 -0
- package/daemon/client/SessionSwitcher.tsx +72 -0
- package/daemon/client/annotationContext.ts +114 -0
- package/daemon/client/components/Callout.tsx +25 -0
- package/daemon/client/components/Checklist.tsx +37 -0
- package/daemon/client/components/Choice.tsx +164 -0
- package/daemon/client/components/CodeBlock.tsx +35 -0
- package/daemon/client/components/Diff.tsx +65 -0
- package/daemon/client/components/FilePreview.tsx +88 -0
- package/daemon/client/components/Image.tsx +45 -0
- package/daemon/client/components/Mermaid.tsx +277 -0
- package/daemon/client/components/Note.tsx +13 -0
- package/daemon/client/components/Priority.tsx +19 -0
- package/daemon/client/components/ResponseNote.tsx +24 -0
- package/daemon/client/components/Section.tsx +32 -0
- package/daemon/client/components/Table.tsx +35 -0
- package/daemon/client/components/Task.tsx +75 -0
- package/daemon/client/components/UserInput.tsx +180 -0
- package/daemon/client/components/index.ts +15 -0
- package/daemon/client/domDiff.ts +336 -0
- package/daemon/client/exportMarkdown.ts +267 -0
- package/daemon/client/generateMarkdown.ts +194 -0
- package/daemon/client/highlightRange.ts +233 -0
- package/daemon/client/index.html +26 -0
- package/daemon/client/lcs.ts +19 -0
- package/daemon/client/popoverPosition.ts +31 -0
- package/daemon/client/runtime.ts +89 -0
- package/daemon/client/styles.css +306 -0
- package/daemon/client/theme.css +66 -0
- package/daemon/client/unifiedDiff.ts +604 -0
- package/daemon/dist/client.css +2 -0
- package/daemon/dist/client.js +68 -0
- package/daemon/dist/components.js +5 -0
- package/daemon/dist/index.html +108 -0
- package/daemon/dist/jsx-dev-runtime-shim.js +17 -0
- package/daemon/dist/jsx-runtime-shim.js +17 -0
- package/daemon/dist/react-dom-client-shim.js +4 -0
- package/daemon/dist/react-dom-shim.js +4 -0
- package/daemon/dist/react-shim.js +4 -0
- package/daemon/dist/runtime.js +1 -0
- package/daemon/dist/theme.css +66 -0
- package/daemon/src/compiler.ts +92 -0
- package/daemon/src/paths.ts +22 -0
- package/daemon/src/server.ts +448 -0
- package/daemon/src/session.ts +274 -0
- package/daemon/src/watcher.ts +59 -0
- package/hooks/session-start.sh +11 -0
- package/package.json +39 -0
- package/skills/canvas/SKILL.md +174 -0
- package/skills/canvas/components.md +349 -0
- package/skills/canvas/flows.md +406 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync, renameSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { SESSIONS_DIR } from "./paths";
|
|
4
|
+
|
|
5
|
+
export interface RevisionInfo {
|
|
6
|
+
revision: number;
|
|
7
|
+
label?: string;
|
|
8
|
+
sourceFile?: string;
|
|
9
|
+
createdAt: string;
|
|
10
|
+
hasFeedback: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SessionData {
|
|
14
|
+
id: string;
|
|
15
|
+
projectRoot: string;
|
|
16
|
+
jsx: string;
|
|
17
|
+
currentRevision: number;
|
|
18
|
+
revisions: RevisionInfo[];
|
|
19
|
+
createdAt: string;
|
|
20
|
+
updatedAt: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface SessionMeta {
|
|
24
|
+
projectRoot: string;
|
|
25
|
+
createdAt: string;
|
|
26
|
+
updatedAt: string;
|
|
27
|
+
currentRevision: number;
|
|
28
|
+
revisions: RevisionInfo[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Legacy format
|
|
32
|
+
interface LegacyMeta {
|
|
33
|
+
projectRoot: string;
|
|
34
|
+
createdAt: string;
|
|
35
|
+
updatedAt: string;
|
|
36
|
+
version: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const STALE_TIMEOUT_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
40
|
+
|
|
41
|
+
export class SessionManager {
|
|
42
|
+
private sessions = new Map<string, SessionData>();
|
|
43
|
+
|
|
44
|
+
constructor() {
|
|
45
|
+
mkdirSync(SESSIONS_DIR, { recursive: true });
|
|
46
|
+
this.loadFromDisk();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private sessionDir(id: string): string {
|
|
50
|
+
return join(SESSIONS_DIR, id);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private revisionDir(id: string, rev: number): string {
|
|
54
|
+
return join(this.sessionDir(id), "revisions", String(rev));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private loadFromDisk() {
|
|
58
|
+
if (!existsSync(SESSIONS_DIR)) return;
|
|
59
|
+
for (const name of readdirSync(SESSIONS_DIR, { withFileTypes: true })) {
|
|
60
|
+
if (!name.isDirectory()) continue;
|
|
61
|
+
const dir = join(SESSIONS_DIR, name.name);
|
|
62
|
+
const metaPath = join(dir, "meta.json");
|
|
63
|
+
if (!existsSync(metaPath)) continue;
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const raw = JSON.parse(readFileSync(metaPath, "utf-8"));
|
|
67
|
+
|
|
68
|
+
// Detect and migrate legacy format
|
|
69
|
+
if ("version" in raw && !("currentRevision" in raw)) {
|
|
70
|
+
this.migrateLegacy(name.name, raw as LegacyMeta);
|
|
71
|
+
// Re-read after migration
|
|
72
|
+
const migrated: SessionMeta = JSON.parse(readFileSync(metaPath, "utf-8"));
|
|
73
|
+
const jsx = this.readRevisionJsx(name.name, migrated.currentRevision);
|
|
74
|
+
if (!jsx) continue;
|
|
75
|
+
this.sessions.set(name.name, {
|
|
76
|
+
id: name.name,
|
|
77
|
+
projectRoot: migrated.projectRoot,
|
|
78
|
+
jsx,
|
|
79
|
+
currentRevision: migrated.currentRevision,
|
|
80
|
+
revisions: migrated.revisions,
|
|
81
|
+
createdAt: migrated.createdAt,
|
|
82
|
+
updatedAt: migrated.updatedAt,
|
|
83
|
+
});
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const meta = raw as SessionMeta;
|
|
88
|
+
const jsx = this.readRevisionJsx(name.name, meta.currentRevision);
|
|
89
|
+
if (!jsx) continue;
|
|
90
|
+
|
|
91
|
+
this.sessions.set(name.name, {
|
|
92
|
+
id: name.name,
|
|
93
|
+
projectRoot: meta.projectRoot,
|
|
94
|
+
jsx,
|
|
95
|
+
currentRevision: meta.currentRevision,
|
|
96
|
+
revisions: meta.revisions,
|
|
97
|
+
createdAt: meta.createdAt,
|
|
98
|
+
updatedAt: meta.updatedAt,
|
|
99
|
+
});
|
|
100
|
+
} catch {}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private migrateLegacy(id: string, legacy: LegacyMeta) {
|
|
105
|
+
const dir = this.sessionDir(id);
|
|
106
|
+
const flatJsx = join(dir, "plan.jsx");
|
|
107
|
+
const flatCompiled = join(dir, "plan.compiled.js");
|
|
108
|
+
const historyDir = join(dir, "history");
|
|
109
|
+
|
|
110
|
+
// Collect all historical revisions + current
|
|
111
|
+
const revisions: RevisionInfo[] = [];
|
|
112
|
+
|
|
113
|
+
// Migrate history files
|
|
114
|
+
if (existsSync(historyDir)) {
|
|
115
|
+
const files = readdirSync(historyDir).filter((f) => f.endsWith(".jsx")).sort();
|
|
116
|
+
for (const file of files) {
|
|
117
|
+
const num = parseInt(file.replace(".jsx", ""), 10);
|
|
118
|
+
if (isNaN(num)) continue;
|
|
119
|
+
const revDir = this.revisionDir(id, num);
|
|
120
|
+
mkdirSync(revDir, { recursive: true });
|
|
121
|
+
renameSync(join(historyDir, file), join(revDir, "plan.jsx"));
|
|
122
|
+
revisions.push({ revision: num, createdAt: legacy.createdAt, hasFeedback: false });
|
|
123
|
+
}
|
|
124
|
+
rmSync(historyDir, { recursive: true, force: true });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Migrate current version
|
|
128
|
+
const currentRev = legacy.version;
|
|
129
|
+
const revDir = this.revisionDir(id, currentRev);
|
|
130
|
+
mkdirSync(revDir, { recursive: true });
|
|
131
|
+
if (existsSync(flatJsx)) {
|
|
132
|
+
renameSync(flatJsx, join(revDir, "plan.jsx"));
|
|
133
|
+
}
|
|
134
|
+
if (existsSync(flatCompiled)) {
|
|
135
|
+
renameSync(flatCompiled, join(revDir, "plan.compiled.js"));
|
|
136
|
+
}
|
|
137
|
+
revisions.push({ revision: currentRev, createdAt: legacy.updatedAt, hasFeedback: false });
|
|
138
|
+
|
|
139
|
+
// Write new meta
|
|
140
|
+
const meta: SessionMeta = {
|
|
141
|
+
projectRoot: legacy.projectRoot,
|
|
142
|
+
createdAt: legacy.createdAt,
|
|
143
|
+
updatedAt: legacy.updatedAt,
|
|
144
|
+
currentRevision: currentRev,
|
|
145
|
+
revisions,
|
|
146
|
+
};
|
|
147
|
+
writeFileSync(join(dir, "meta.json"), JSON.stringify(meta, null, 2));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private readRevisionJsx(id: string, rev: number): string | null {
|
|
151
|
+
try {
|
|
152
|
+
return readFileSync(join(this.revisionDir(id, rev), "plan.jsx"), "utf-8");
|
|
153
|
+
} catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
upsert(id: string, jsx: string, projectRoot: string, label?: string, sourceFile?: string): SessionData {
|
|
159
|
+
const existing = this.sessions.get(id);
|
|
160
|
+
const now = new Date().toISOString();
|
|
161
|
+
const revision = existing ? existing.currentRevision + 1 : 1;
|
|
162
|
+
|
|
163
|
+
const revInfo: RevisionInfo = { revision, createdAt: now, hasFeedback: false, ...(label ? { label } : {}), ...(sourceFile ? { sourceFile } : {}) };
|
|
164
|
+
const revisions = existing ? [...existing.revisions, revInfo] : [revInfo];
|
|
165
|
+
|
|
166
|
+
const session: SessionData = {
|
|
167
|
+
id,
|
|
168
|
+
projectRoot,
|
|
169
|
+
jsx,
|
|
170
|
+
currentRevision: revision,
|
|
171
|
+
revisions,
|
|
172
|
+
createdAt: existing?.createdAt ?? now,
|
|
173
|
+
updatedAt: now,
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
this.sessions.set(id, session);
|
|
177
|
+
this.persistToDisk(session);
|
|
178
|
+
return session;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private persistToDisk(session: SessionData) {
|
|
182
|
+
const dir = this.sessionDir(session.id);
|
|
183
|
+
const revDir = this.revisionDir(session.id, session.currentRevision);
|
|
184
|
+
mkdirSync(revDir, { recursive: true });
|
|
185
|
+
|
|
186
|
+
// Write revision JSX
|
|
187
|
+
writeFileSync(join(revDir, "plan.jsx"), session.jsx);
|
|
188
|
+
|
|
189
|
+
// Write meta
|
|
190
|
+
const meta: SessionMeta = {
|
|
191
|
+
projectRoot: session.projectRoot,
|
|
192
|
+
createdAt: session.createdAt,
|
|
193
|
+
updatedAt: session.updatedAt,
|
|
194
|
+
currentRevision: session.currentRevision,
|
|
195
|
+
revisions: session.revisions,
|
|
196
|
+
};
|
|
197
|
+
writeFileSync(join(dir, "meta.json"), JSON.stringify(meta, null, 2));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
get(id: string): SessionData | undefined {
|
|
201
|
+
return this.sessions.get(id);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
list(): SessionData[] {
|
|
205
|
+
return Array.from(this.sessions.values());
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
remove(id: string) {
|
|
209
|
+
this.sessions.delete(id);
|
|
210
|
+
rmSync(this.sessionDir(id), { recursive: true, force: true });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
saveCompiled(id: string, js: string, rev?: number) {
|
|
214
|
+
const session = this.sessions.get(id);
|
|
215
|
+
const revision = rev ?? session?.currentRevision;
|
|
216
|
+
if (!revision) return;
|
|
217
|
+
const revDir = this.revisionDir(id, revision);
|
|
218
|
+
mkdirSync(revDir, { recursive: true });
|
|
219
|
+
writeFileSync(join(revDir, "plan.compiled.js"), js);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
getCompiled(id: string, rev?: number): string | null {
|
|
223
|
+
const session = this.sessions.get(id);
|
|
224
|
+
const revision = rev ?? session?.currentRevision;
|
|
225
|
+
if (!revision) return null;
|
|
226
|
+
try {
|
|
227
|
+
return readFileSync(join(this.revisionDir(id, revision), "plan.compiled.js"), "utf-8");
|
|
228
|
+
} catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
saveFeedback(id: string, rev: number, markdown: string) {
|
|
234
|
+
const revDir = this.revisionDir(id, rev);
|
|
235
|
+
mkdirSync(revDir, { recursive: true });
|
|
236
|
+
writeFileSync(join(revDir, "feedback.md"), markdown);
|
|
237
|
+
|
|
238
|
+
// Update in-memory and on-disk meta
|
|
239
|
+
const session = this.sessions.get(id);
|
|
240
|
+
if (session) {
|
|
241
|
+
const ri = session.revisions.find((r) => r.revision === rev);
|
|
242
|
+
if (ri) ri.hasFeedback = true;
|
|
243
|
+
const meta: SessionMeta = {
|
|
244
|
+
projectRoot: session.projectRoot,
|
|
245
|
+
createdAt: session.createdAt,
|
|
246
|
+
updatedAt: session.updatedAt,
|
|
247
|
+
currentRevision: session.currentRevision,
|
|
248
|
+
revisions: session.revisions,
|
|
249
|
+
};
|
|
250
|
+
writeFileSync(join(this.sessionDir(id), "meta.json"), JSON.stringify(meta, null, 2));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
getFeedback(id: string, rev: number): string | null {
|
|
255
|
+
try {
|
|
256
|
+
return readFileSync(join(this.revisionDir(id, rev), "feedback.md"), "utf-8");
|
|
257
|
+
} catch {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
getRevisionJsxPath(id: string, rev: number): string {
|
|
263
|
+
return join(this.revisionDir(id, rev), "plan.jsx");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
cleanupStale(maxAge = STALE_TIMEOUT_MS) {
|
|
267
|
+
const now = Date.now();
|
|
268
|
+
for (const [id, session] of this.sessions) {
|
|
269
|
+
if (now - new Date(session.updatedAt).getTime() > maxAge) {
|
|
270
|
+
this.remove(id);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { watch, existsSync, readFileSync } from "fs";
|
|
2
|
+
import { compilePlan } from "./compiler";
|
|
3
|
+
import type { SessionManager } from "./session";
|
|
4
|
+
|
|
5
|
+
type BroadcastFn = (sessionId: string) => void;
|
|
6
|
+
|
|
7
|
+
const watchers = new Map<string, ReturnType<typeof watch>>();
|
|
8
|
+
|
|
9
|
+
export function watchSession(
|
|
10
|
+
sessionId: string,
|
|
11
|
+
sessionManager: SessionManager,
|
|
12
|
+
broadcast: BroadcastFn,
|
|
13
|
+
) {
|
|
14
|
+
// Close existing watcher for this session (revision may have changed)
|
|
15
|
+
unwatchSession(sessionId);
|
|
16
|
+
|
|
17
|
+
const session = sessionManager.get(sessionId);
|
|
18
|
+
if (!session) return;
|
|
19
|
+
|
|
20
|
+
const planPath = sessionManager.getRevisionJsxPath(sessionId, session.currentRevision);
|
|
21
|
+
if (!existsSync(planPath)) return;
|
|
22
|
+
|
|
23
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
24
|
+
const rev = session.currentRevision;
|
|
25
|
+
|
|
26
|
+
const watcher = watch(planPath, async (event) => {
|
|
27
|
+
if (event !== "change") return;
|
|
28
|
+
|
|
29
|
+
// Debounce — Claude Code may do multiple rapid edits
|
|
30
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
31
|
+
debounceTimer = setTimeout(async () => {
|
|
32
|
+
try {
|
|
33
|
+
const jsx = readFileSync(planPath, "utf-8");
|
|
34
|
+
const result = await compilePlan(jsx, session.projectRoot);
|
|
35
|
+
if (result.ok) {
|
|
36
|
+
sessionManager.saveCompiled(sessionId, result.js, rev);
|
|
37
|
+
broadcast(sessionId);
|
|
38
|
+
}
|
|
39
|
+
} catch {}
|
|
40
|
+
}, 200);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
watchers.set(sessionId, watcher);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function unwatchSession(sessionId: string) {
|
|
47
|
+
const watcher = watchers.get(sessionId);
|
|
48
|
+
if (watcher) {
|
|
49
|
+
watcher.close();
|
|
50
|
+
watchers.delete(sessionId);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function unwatchAll() {
|
|
55
|
+
for (const [id, watcher] of watchers) {
|
|
56
|
+
watcher.close();
|
|
57
|
+
}
|
|
58
|
+
watchers.clear();
|
|
59
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Injects CANVAS_SESSION_ID and CANVAS_PROJECT_ROOT into Claude Code environment.
|
|
3
|
+
# Called by Claude Code's SessionStart hook.
|
|
4
|
+
INPUT=$(cat /dev/stdin)
|
|
5
|
+
SESSION_ID=$(echo "$INPUT" | grep -o '"session_id":"[^"]*"' | cut -d'"' -f4)
|
|
6
|
+
CWD=$(echo "$INPUT" | grep -o '"cwd":"[^"]*"' | cut -d'"' -f4)
|
|
7
|
+
|
|
8
|
+
if [ -n "$SESSION_ID" ] && [ -n "$CLAUDE_ENV_FILE" ]; then
|
|
9
|
+
echo "export CANVAS_SESSION_ID=$SESSION_ID" >> "$CLAUDE_ENV_FILE"
|
|
10
|
+
echo "export CANVAS_PROJECT_ROOT=$CWD" >> "$CLAUDE_ENV_FILE"
|
|
11
|
+
fi
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agent-canvas",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Interactive visual canvas for Claude Code — rich annotatable documents in the browser",
|
|
5
|
+
"bin": {
|
|
6
|
+
"agent-canvas": "bin/agent-canvas.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"bin/",
|
|
10
|
+
"daemon/src/",
|
|
11
|
+
"daemon/client/",
|
|
12
|
+
"daemon/dist/",
|
|
13
|
+
"daemon/build.ts",
|
|
14
|
+
"daemon/package.json",
|
|
15
|
+
"skills/",
|
|
16
|
+
"hooks/"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "bun daemon/build.ts",
|
|
20
|
+
"prepublishOnly": "bun daemon/build.ts"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"react": "^18.3.1",
|
|
24
|
+
"react-dom": "^18.3.1"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@tailwindcss/cli": "4",
|
|
28
|
+
"@types/react": "^18.3.12",
|
|
29
|
+
"@types/react-dom": "^18.3.1",
|
|
30
|
+
"tailwindcss": "4"
|
|
31
|
+
},
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"keywords": [
|
|
34
|
+
"claude-code",
|
|
35
|
+
"canvas",
|
|
36
|
+
"interactive",
|
|
37
|
+
"annotations"
|
|
38
|
+
]
|
|
39
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: canvas
|
|
3
|
+
description: >
|
|
4
|
+
Interactive visual canvas for structured communication between agent and user.
|
|
5
|
+
Opens a rich annotatable document in the user's browser where the user reviews,
|
|
6
|
+
comments, answers questions, and submits feedback.
|
|
7
|
+
|
|
8
|
+
Supports planning, architecture reviews, code reviews, discovery interviews,
|
|
9
|
+
implementation summaries, proposals, decision documents, and explanations.
|
|
10
|
+
disable-model-invocation: true
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# Canvas — Interactive Visual Documents
|
|
15
|
+
|
|
16
|
+
Canvas opens a rich, annotatable document in the user's browser. You write JSX, the user reviews and annotates it like Google Docs, and feedback comes back as markdown. Use it whenever structured visual communication beats inline chat.
|
|
17
|
+
|
|
18
|
+
**Read the flow reference before starting**: Check `flows.md` to pick the right flow for your task. Each flow has a specific sequence of canvases with templates.
|
|
19
|
+
|
|
20
|
+
**Read the component reference as needed**: Check `components.md` for the full component API.
|
|
21
|
+
|
|
22
|
+
## Core Mechanics
|
|
23
|
+
|
|
24
|
+
### Writing a Canvas
|
|
25
|
+
|
|
26
|
+
Use the **Write** tool to create a `.jsx` file in `.claude/canvas/`:
|
|
27
|
+
|
|
28
|
+
```jsx
|
|
29
|
+
// Write to: .claude/canvas/plan.jsx
|
|
30
|
+
|
|
31
|
+
<Section title="Authentication Redesign">
|
|
32
|
+
A proposal to replace session-based auth with JWT tokens.
|
|
33
|
+
|
|
34
|
+
<Item label="Current state" badge="context" badgeVariant="info">
|
|
35
|
+
The app uses express-session with Redis store.
|
|
36
|
+
<FilePreview path="src/auth/session.ts" lines={[1, 45]} />
|
|
37
|
+
</Item>
|
|
38
|
+
|
|
39
|
+
<Item label="Proposed change" badge="proposal">
|
|
40
|
+
Switch to stateless JWT with refresh token rotation.
|
|
41
|
+
<Callout type="warning">This will invalidate all existing sessions.</Callout>
|
|
42
|
+
</Item>
|
|
43
|
+
</Section>
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Components are auto-available — no imports needed. The file is a JSX fragment (just tags) or a full module with `export default`.
|
|
47
|
+
|
|
48
|
+
### Pushing
|
|
49
|
+
|
|
50
|
+
After writing the file, push it to open the canvas in the browser:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
bunx agent-canvas push .claude/canvas/plan.jsx --label "Implementation Plan"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
This opens the canvas in the user's browser (first time) and exits immediately. The round label defaults to the filename if `--label` is omitted.
|
|
57
|
+
|
|
58
|
+
### Waiting for feedback
|
|
59
|
+
|
|
60
|
+
After pushing, wait for the user to review and submit:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
bunx agent-canvas wait
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
This **blocks until the user submits feedback**, then prints feedback markdown to stdout. If feedback was already submitted (e.g. retrying after a disconnect), it returns immediately with the existing feedback.
|
|
67
|
+
|
|
68
|
+
### Iterating
|
|
69
|
+
|
|
70
|
+
Use the **Edit** tool to modify the existing JSX based on feedback — targeted edits, not full rewrites. Then push and wait again:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
bunx agent-canvas push .claude/canvas/plan.jsx --label "Implementation Plan (revised)"
|
|
74
|
+
bunx agent-canvas wait
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Each push creates a new **round** — the user sees previous rounds and their feedback in the UI.
|
|
78
|
+
|
|
79
|
+
### Multiple Canvases
|
|
80
|
+
|
|
81
|
+
Maintain separate files for different phases:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
bunx agent-canvas push .claude/canvas/discovery.jsx --label "Discovery"
|
|
85
|
+
bunx agent-canvas wait
|
|
86
|
+
# ... process feedback ...
|
|
87
|
+
bunx agent-canvas push .claude/canvas/requirements.jsx --label "Requirements"
|
|
88
|
+
bunx agent-canvas wait
|
|
89
|
+
# ... process feedback ...
|
|
90
|
+
bunx agent-canvas push .claude/canvas/plan.jsx --label "Implementation Plan"
|
|
91
|
+
bunx agent-canvas wait
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### File Location
|
|
95
|
+
|
|
96
|
+
All canvas files go in `.claude/canvas/` within the project root. Add `.claude/canvas/` to `.gitignore` — these are ephemeral working files.
|
|
97
|
+
|
|
98
|
+
## Choosing a Flow
|
|
99
|
+
|
|
100
|
+
Determine which flow fits before starting. See `flows.md` for full details with templates.
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
User wants something built/changed?
|
|
104
|
+
├─ Vague request ("add auth", "refactor the API")
|
|
105
|
+
│ └─ FEATURE flow: discovery → requirements → plan → [implement] → summary
|
|
106
|
+
├─ Clear request with specifics
|
|
107
|
+
│ └─ PLAN flow: plan → [implement] → summary
|
|
108
|
+
├─ Complete instructions, just wants execution
|
|
109
|
+
│ └─ Skip canvas, implement directly, optionally push summary
|
|
110
|
+
|
|
111
|
+
User wants to understand something?
|
|
112
|
+
├─ "How does X work?", "Explain the auth system"
|
|
113
|
+
│ └─ EXPLAIN flow: single explanatory canvas
|
|
114
|
+
|
|
115
|
+
User wants review/audit?
|
|
116
|
+
├─ "Review this code", "Check for issues"
|
|
117
|
+
│ └─ REVIEW flow: findings canvas with categorized issues
|
|
118
|
+
|
|
119
|
+
User wants to make a decision?
|
|
120
|
+
├─ "Should we use X or Y?", "What database?"
|
|
121
|
+
│ └─ DECISION flow: options with tradeoffs, recommendation
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
**Adapt dynamically**: flows are guidelines, not rigid pipelines. Pivot if needed, skip if the user says so.
|
|
125
|
+
|
|
126
|
+
## Flow Execution Pattern
|
|
127
|
+
|
|
128
|
+
1. **Determine flow** from user intent
|
|
129
|
+
2. **Announce** briefly: "I'll start with discovery, then create a detailed plan."
|
|
130
|
+
3. **Write canvas JSX** with the Write tool to `.claude/canvas/<name>.jsx`
|
|
131
|
+
4. **Push**: `bunx agent-canvas push .claude/canvas/<name>.jsx --label "<Label>"`
|
|
132
|
+
5. **Tell the user** the canvas is ready for review (they see it in their browser)
|
|
133
|
+
6. **Wait**: `bunx agent-canvas wait` — this blocks until the user submits feedback
|
|
134
|
+
7. **Read feedback** — check for annotations, answers, added context files, approval
|
|
135
|
+
8. **Edit with the Edit tool and re-push + wait**, or advance to next phase
|
|
136
|
+
9. **After implementation**, push a summary canvas
|
|
137
|
+
|
|
138
|
+
## Interview Integration
|
|
139
|
+
|
|
140
|
+
For discovery phases, use iterative canvases or offer a checklist:
|
|
141
|
+
|
|
142
|
+
```jsx
|
|
143
|
+
<Section title="Before we start">
|
|
144
|
+
Which areas should we discuss in depth?
|
|
145
|
+
|
|
146
|
+
<MultiChoice id="interview-areas" label="Select areas to cover" required
|
|
147
|
+
options={[
|
|
148
|
+
"Backend architecture & data model",
|
|
149
|
+
"API design & contracts",
|
|
150
|
+
"Frontend / UI behavior",
|
|
151
|
+
"Edge cases & error handling",
|
|
152
|
+
"Performance & scaling",
|
|
153
|
+
"Security considerations",
|
|
154
|
+
"Testing strategy",
|
|
155
|
+
"Migration / deployment"
|
|
156
|
+
]} />
|
|
157
|
+
|
|
158
|
+
<UserInput id="anything-else" label="Anything else I should know upfront?" />
|
|
159
|
+
</Section>
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Then interview deeply in each selected area across subsequent rounds. Or ask in chat first: "Would you prefer a thorough interview before I plan, or should I make assumptions and you correct in review?"
|
|
163
|
+
|
|
164
|
+
## Important Rules
|
|
165
|
+
|
|
166
|
+
- **Write** canvas files to `.claude/canvas/` using the Write tool. Never use bash heredocs or cat.
|
|
167
|
+
- **Edit** canvas files using the Edit tool. Never rewrite entire files — make targeted edits.
|
|
168
|
+
- JSX can be a fragment (just tags) or a full module with `export default function Canvas()`.
|
|
169
|
+
- Every `<Item>` and interactive component needs a unique `id`.
|
|
170
|
+
- `<FilePreview path="...">` paths are relative to project root.
|
|
171
|
+
- For inline styling use `style={{ ... }}` — Tailwind is NOT available in canvas JSX.
|
|
172
|
+
- Read any files the user added to context (listed under "Added context" in feedback) with the Read tool before the next iteration.
|
|
173
|
+
- Always push a summary canvas after implementation, even if brief.
|
|
174
|
+
- When the user approves, confirm what you'll do next before proceeding.
|