agent-canvas 0.1.9 → 0.1.11

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.
Files changed (36) hide show
  1. package/.github/dark.png +0 -0
  2. package/.github/light.png +0 -0
  3. package/README.md +10 -0
  4. package/daemon/build.ts +1 -1
  5. package/daemon/client/AnnotationEditor.tsx +307 -0
  6. package/daemon/client/AnnotationProvider.tsx +15 -3
  7. package/daemon/client/AnnotationSidebar.tsx +54 -28
  8. package/daemon/client/App.tsx +64 -24
  9. package/daemon/client/CompareView.tsx +53 -22
  10. package/daemon/client/FileBrowser.tsx +2 -1
  11. package/daemon/client/FileViewer.tsx +41 -161
  12. package/daemon/client/PlanRenderer.tsx +30 -147
  13. package/daemon/client/Popover.tsx +168 -108
  14. package/daemon/client/ResponsePreview.tsx +7 -1
  15. package/daemon/client/RevisionSelect.tsx +8 -1
  16. package/daemon/client/annotationDom.ts +104 -0
  17. package/daemon/client/components/Mermaid.tsx +2 -2
  18. package/daemon/client/generateMarkdown.ts +8 -0
  19. package/daemon/client/highlightRange.ts +1 -22
  20. package/daemon/client/index.html +1 -1
  21. package/daemon/client/runtime.ts +4 -1
  22. package/daemon/client/styles.css +28 -2
  23. package/daemon/client/theme.css +1 -1
  24. package/daemon/client/useTextAnnotation.tsx +183 -0
  25. package/daemon/dist/client.css +1 -1
  26. package/daemon/dist/client.js +63 -87
  27. package/daemon/dist/components.js +54 -54
  28. package/daemon/dist/index.html +2 -2
  29. package/daemon/src/handlers/uploads.ts +62 -0
  30. package/daemon/src/paths.ts +4 -0
  31. package/daemon/src/server.ts +2 -0
  32. package/daemon/src/session.ts +39 -1
  33. package/package.json +1 -1
  34. package/skills/canvas/SKILL.md +15 -7
  35. package/skills/canvas/flows.md +1 -1
  36. package/hooks/session-start.sh +0 -13
@@ -4,7 +4,7 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>Agent Canvas</title>
7
- <script>document.documentElement.dataset.theme = localStorage.getItem('canvas-theme') || 'dark';</script>
7
+ <script>!function(){var p=localStorage.getItem('canvas-theme')||'auto';document.documentElement.dataset.theme=p==='auto'?(matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light'):p}()</script>
8
8
  <link rel="stylesheet" href="/assets/client.css" />
9
9
  <link rel="stylesheet" href="https://unpkg.com/@highlightjs/cdn-assets@11.11.1/styles/github-dark-dimmed.min.css" />
10
10
  <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
@@ -42,7 +42,7 @@
42
42
  --color-highlight-bg: rgba(255, 220, 100, 0.15);
43
43
  --color-highlight-border: rgba(255, 220, 100, 0.35);
44
44
  --color-highlight-annotation: rgba(255, 220, 100, 0.18);
45
- --color-highlight-active: rgba(255, 220, 100, 0.25);
45
+ --color-highlight-active: rgba(255, 220, 100, 0.35);
46
46
  --color-highlight-selected: rgba(255, 220, 100, 0.10);
47
47
 
48
48
  --color-badge-bg: rgba(196, 154, 58, 0.20);
@@ -0,0 +1,62 @@
1
+ import { writeFileSync } from "fs";
2
+ import { join } from "path";
3
+ import { UPLOADS_DIR } from "../paths";
4
+ import type { SessionManager } from "../session";
5
+ import { jsonResponse } from "./utils";
6
+ import type { Route } from "../router";
7
+
8
+ const IMAGE_MIME: Record<string, string> = {
9
+ png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg",
10
+ gif: "image/gif", svg: "image/svg+xml", webp: "image/webp",
11
+ bmp: "image/bmp", avif: "image/avif",
12
+ };
13
+
14
+ export function createUploadHandlers(sessionManager: SessionManager): Route[] {
15
+ async function handleUpload(req: Request, _url: URL, match: URLPatternResult): Promise<Response> {
16
+ const sessionId = match.pathname.groups.id!;
17
+ if (!sessionManager.get(sessionId)) {
18
+ return jsonResponse({ error: "Session not found" }, 404);
19
+ }
20
+
21
+ const formData = await req.formData();
22
+ const file = formData.get("image") as File | null;
23
+ if (!file) return jsonResponse({ error: "No image provided" }, 400);
24
+ if (!file.type.startsWith("image/")) return jsonResponse({ error: "Not an image" }, 400);
25
+ if (file.size > 10 * 1024 * 1024) return jsonResponse({ error: "Image too large (max 10MB)" }, 413);
26
+
27
+ const ext = file.name.split(".").pop()?.toLowerCase() || "png";
28
+ const filename = `${crypto.randomUUID()}.${ext}`;
29
+ const filepath = join(UPLOADS_DIR, filename);
30
+
31
+ const buffer = await file.arrayBuffer();
32
+ writeFileSync(filepath, Buffer.from(buffer));
33
+
34
+ return jsonResponse({ path: filepath, filename });
35
+ }
36
+
37
+ async function handleUploadServe(_req: Request, _url: URL, match: URLPatternResult): Promise<Response> {
38
+ const filename = match.pathname.groups.filename!;
39
+ if (filename.includes("/") || filename.includes("..")) {
40
+ return jsonResponse({ error: "Invalid filename" }, 400);
41
+ }
42
+
43
+ const filepath = join(UPLOADS_DIR, filename);
44
+ const file = Bun.file(filepath);
45
+ if (!(await file.exists())) return jsonResponse({ error: "Not found" }, 404);
46
+
47
+ const ext = filename.split(".").pop()?.toLowerCase() || "";
48
+ const mime = IMAGE_MIME[ext] || "application/octet-stream";
49
+
50
+ return new Response(file, {
51
+ headers: {
52
+ "Content-Type": mime,
53
+ "Cache-Control": "public, max-age=3600",
54
+ },
55
+ });
56
+ }
57
+
58
+ return [
59
+ { method: "POST", pattern: new URLPattern({ pathname: "/api/session/:id/upload" }), handler: handleUpload },
60
+ { method: "GET", pattern: new URLPattern({ pathname: "/api/uploads/:filename" }), handler: handleUploadServe },
61
+ ];
62
+ }
@@ -17,6 +17,10 @@ export const PID_FILE = join(TEMP_DIR, "daemon.pid");
17
17
  /** Temp dir for JSX compilation */
18
18
  export const COMPILE_TEMP_DIR = join(TEMP_DIR, "compile");
19
19
 
20
+ /** Uploaded annotation images */
21
+ export const UPLOADS_DIR = join(TEMP_DIR, "uploads");
22
+
20
23
  // Ensure dirs exist
21
24
  mkdirSync(SESSIONS_DIR, { recursive: true });
22
25
  mkdirSync(COMPILE_TEMP_DIR, { recursive: true });
26
+ mkdirSync(UPLOADS_DIR, { recursive: true });
@@ -8,6 +8,7 @@ import { jsonResponse, corsHeaders } from "./handlers/utils";
8
8
  import { createApiHandlers } from "./handlers/api";
9
9
  import { createFileHandlers } from "./handlers/files";
10
10
  import { createStaticHandlers } from "./handlers/static";
11
+ import { createUploadHandlers } from "./handlers/uploads";
11
12
 
12
13
  const PORT = parseInt(process.env.CANVAS_PORT || "19400", 10);
13
14
 
@@ -22,6 +23,7 @@ const wsManager = createWebSocketManager(sessionManager);
22
23
  const routes = [
23
24
  ...createApiHandlers({ sessionManager, broadcastPlanUpdate: wsManager.broadcastPlanUpdate, port: PORT }),
24
25
  ...createFileHandlers(sessionManager),
26
+ ...createUploadHandlers(sessionManager),
25
27
  ...createStaticHandlers(),
26
28
  ];
27
29
 
@@ -2,6 +2,11 @@ import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync
2
2
  import { join } from "path";
3
3
  import { SESSIONS_DIR } from "./paths";
4
4
 
5
+ export interface DiffStats {
6
+ added: number;
7
+ removed: number;
8
+ }
9
+
5
10
  export interface RevisionInfo {
6
11
  revision: number;
7
12
  label?: string;
@@ -10,6 +15,7 @@ export interface RevisionInfo {
10
15
  hasFeedback: boolean;
11
16
  feedbackConsumed: boolean;
12
17
  response?: string;
18
+ diffStats?: DiffStats;
13
19
  }
14
20
 
15
21
  export interface SessionData {
@@ -40,6 +46,27 @@ interface LegacyMeta {
40
46
 
41
47
  const STALE_TIMEOUT_MS = 24 * 60 * 60 * 1000; // 24 hours
42
48
 
49
+ function computeLineDiffStats(oldText: string, newText: string): DiffStats {
50
+ const oldLines = oldText.split("\n");
51
+ const newLines = newText.split("\n");
52
+ const m = oldLines.length, n = newLines.length;
53
+
54
+ // LCS length via two-row DP (O(n) space)
55
+ let prev = new Uint32Array(n + 1);
56
+ let curr = new Uint32Array(n + 1);
57
+ for (let i = 1; i <= m; i++) {
58
+ for (let j = 1; j <= n; j++) {
59
+ curr[j] = oldLines[i - 1] === newLines[j - 1]
60
+ ? prev[j - 1] + 1
61
+ : Math.max(prev[j], curr[j - 1]);
62
+ }
63
+ [prev, curr] = [curr, prev];
64
+ curr.fill(0);
65
+ }
66
+ const lcsLen = prev[n];
67
+ return { added: n - lcsLen, removed: m - lcsLen };
68
+ }
69
+
43
70
  export class SessionManager {
44
71
  private sessions = new Map<string, SessionData>();
45
72
 
@@ -162,7 +189,18 @@ export class SessionManager {
162
189
  const now = new Date().toISOString();
163
190
  const revision = existing ? existing.currentRevision + 1 : 1;
164
191
 
165
- const revInfo: RevisionInfo = { revision, createdAt: now, hasFeedback: false, feedbackConsumed: false, ...(label ? { label } : {}), ...(sourceFile ? { sourceFile } : {}), ...(response ? { response } : {}) };
192
+ let diffStats: DiffStats | undefined;
193
+ if (existing && sourceFile) {
194
+ const prev = [...existing.revisions]
195
+ .reverse()
196
+ .find((r) => r.sourceFile === sourceFile);
197
+ if (prev) {
198
+ const prevJsx = this.readRevisionJsx(id, prev.revision);
199
+ if (prevJsx) diffStats = computeLineDiffStats(prevJsx, jsx);
200
+ }
201
+ }
202
+
203
+ const revInfo: RevisionInfo = { revision, createdAt: now, hasFeedback: false, feedbackConsumed: false, ...(label ? { label } : {}), ...(sourceFile ? { sourceFile } : {}), ...(response ? { response } : {}), ...(diffStats ? { diffStats } : {}) };
166
204
  const revisions = existing ? [...existing.revisions, revInfo] : [revInfo];
167
205
 
168
206
  const session: SessionData = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-canvas",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Interactive visual canvas for Claude Code — rich annotatable documents in the browser",
5
5
  "bin": {
6
6
  "agent-canvas": "bin/agent-canvas.js"
@@ -57,32 +57,37 @@ This opens the canvas in the user's browser (first time) and exits immediately.
57
57
 
58
58
  ### Waiting for feedback
59
59
 
60
- After pushing, wait for the user to review and submit:
60
+ After pushing, start the watch command **in the background** using the Bash tool's `run_in_background` parameter:
61
61
 
62
62
  ```bash
63
+ # Use Bash tool with run_in_background: true
63
64
  bunx agent-canvas watch --session ${CLAUDE_SESSION_ID}
64
65
  ```
65
66
 
66
- This **blocks until the user submits feedback**, then prints feedback markdown to stdout. If feedback was already submitted but not yet consumed, it returns immediately with the existing feedback.
67
+ This runs the watch process in the background. You will be **automatically notified** when the user submits feedback — the output will contain the feedback markdown. Do NOT poll, sleep, or proactively check on it. Just stop and wait for the notification.
67
68
 
68
- **Timeout handling**: The watch command may time out if the user takes a while to review. If it times out, don't retry automatically — wait for the user to tell you they've submitted feedback, then run the watch command again (or use `fetch`) to retrieve it.
69
+ If feedback was already submitted but not yet consumed, the command returns immediately with the existing feedback.
70
+
71
+ **Important**: After starting the background watch, do not continue with other work unless instructed. Wait for the notification that feedback has arrived before proceeding.
69
72
 
70
73
  ### Checking for feedback without blocking
71
74
 
72
- Use `fetch` to check for feedback without waiting:
75
+ Use `fetch` to retrieve feedback if the user tells you they've already submitted:
73
76
 
74
77
  ```bash
75
78
  bunx agent-canvas fetch --session ${CLAUDE_SESSION_ID}
76
79
  ```
77
80
 
78
- Returns immediately — prints feedback to stdout if available, otherwise produces no output. Use this to retry after a timeout or when the user tells you they've submitted.
81
+ Returns immediately — prints feedback to stdout if available, otherwise produces no output. Use this when the user tells you they've submitted feedback (e.g. after a reconnect or if the background watch was lost).
79
82
 
80
83
  ### Iterating
81
84
 
82
- Use the **Edit** tool to modify the existing JSX based on feedback — targeted edits, not full rewrites. Then push and wait again:
85
+ Use the **Edit** tool to modify the existing JSX based on feedback — targeted edits, not full rewrites. Then push and watch again:
83
86
 
84
87
  ```bash
85
88
  bunx agent-canvas push .claude/agent-canvas/${CLAUDE_SESSION_ID}/plan.jsx --session ${CLAUDE_SESSION_ID} --label "Implementation Plan (revised)"
89
+
90
+ # Use Bash tool with run_in_background: true
86
91
  bunx agent-canvas watch --session ${CLAUDE_SESSION_ID}
87
92
  ```
88
93
 
@@ -109,12 +114,15 @@ Maintain separate files for different phases:
109
114
 
110
115
  ```bash
111
116
  bunx agent-canvas push .claude/agent-canvas/${CLAUDE_SESSION_ID}/discovery.jsx --session ${CLAUDE_SESSION_ID} --label "Discovery"
117
+ # watch with run_in_background: true, wait for notification
112
118
  bunx agent-canvas watch --session ${CLAUDE_SESSION_ID}
113
119
  # ... process feedback ...
114
120
  bunx agent-canvas push .claude/agent-canvas/${CLAUDE_SESSION_ID}/requirements.jsx --session ${CLAUDE_SESSION_ID} --label "Requirements"
121
+ # watch with run_in_background: true, wait for notification
115
122
  bunx agent-canvas watch --session ${CLAUDE_SESSION_ID}
116
123
  # ... process feedback ...
117
124
  bunx agent-canvas push .claude/agent-canvas/${CLAUDE_SESSION_ID}/plan.jsx --session ${CLAUDE_SESSION_ID} --label "Implementation Plan"
125
+ # watch with run_in_background: true, wait for notification
118
126
  bunx agent-canvas watch --session ${CLAUDE_SESSION_ID}
119
127
  ```
120
128
 
@@ -156,7 +164,7 @@ User wants to make a decision?
156
164
  2. **Announce** briefly: "I'll start with discovery, then create a detailed plan."
157
165
  3. **Write canvas JSX** with the Write tool to `.claude/agent-canvas/${CLAUDE_SESSION_ID}/<name>.jsx`
158
166
  4. **Push + tell the user**: Push the canvas and show the `browserUrl` from the output so the user can open it
159
- 5. **IMMEDIATELY watch** — run `bunx agent-canvas watch --session ${CLAUDE_SESSION_ID}` right after push, in the same response. Never stop after pushing. The push→watch sequence is atomic: no push without a watch. The watch blocks until the user submits feedback.
167
+ 5. **IMMEDIATELY watch in background** — run `bunx agent-canvas watch --session ${CLAUDE_SESSION_ID}` with `run_in_background: true` right after push. Never stop after pushing. The push→watch sequence is atomic: no push without a watch. You will be notified when feedback arrives — do not poll or sleep.
160
168
  6. **Read feedback** — check for annotations, answers, added context files, approval
161
169
  7. **Edit with the Edit tool and re-push + watch**, or advance to next phase
162
170
  8. **After implementation**, push a summary canvas
@@ -2,7 +2,7 @@
2
2
 
3
3
  Each flow is a sequence of canvas rounds. Phases can be skipped or combined based on context.
4
4
 
5
- **Tooling reminder**: Write all `.jsx` files using the **Write** tool to `.claude/agent-canvas/${CLAUDE_SESSION_ID}/`. Edit them with the **Edit** tool. Push with `bunx agent-canvas push .claude/agent-canvas/${CLAUDE_SESSION_ID}/<file>.jsx --session ${CLAUDE_SESSION_ID} --label "<Label>"`, then `bunx agent-canvas watch --session ${CLAUDE_SESSION_ID}` to block for feedback. The JSX blocks below show what to write — they are the file content, not bash commands.
5
+ **Tooling reminder**: Write all `.jsx` files using the **Write** tool to `.claude/agent-canvas/${CLAUDE_SESSION_ID}/`. Edit them with the **Edit** tool. Push with `bunx agent-canvas push .claude/agent-canvas/${CLAUDE_SESSION_ID}/<file>.jsx --session ${CLAUDE_SESSION_ID} --label "<Label>"`, then run `bunx agent-canvas watch --session ${CLAUDE_SESSION_ID}` with `run_in_background: true` — you'll be notified when the user submits feedback. The JSX blocks below show what to write — they are the file content, not bash commands.
6
6
 
7
7
  ---
8
8
 
@@ -1,13 +0,0 @@
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
-
5
- if [ -n "$CLAUDE_ENV_FILE" ]; then
6
- # Extract session ID from CLAUDE_ENV_FILE path:
7
- # /home/.../.claude/session-env/<SESSION_ID>/sessionstart-hook-0.sh
8
- SESSION_ID=$(basename "$(dirname "$CLAUDE_ENV_FILE")")
9
- CWD="${CLAUDE_PROJECT_DIR:-$(pwd)}"
10
-
11
- echo "export CANVAS_SESSION_ID=$SESSION_ID" >> "$CLAUDE_ENV_FILE"
12
- echo "export CANVAS_PROJECT_ROOT=$CWD" >> "$CLAUDE_ENV_FILE"
13
- fi