agent-canvas 0.1.8 → 0.1.10

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.
@@ -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.8",
3
+ "version": "0.1.10",
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"
@@ -155,12 +155,11 @@ User wants to make a decision?
155
155
  1. **Determine flow** from user intent
156
156
  2. **Announce** briefly: "I'll start with discovery, then create a detailed plan."
157
157
  3. **Write canvas JSX** with the Write tool to `.claude/agent-canvas/${CLAUDE_SESSION_ID}/<name>.jsx`
158
- 4. **Push**: `bunx agent-canvas push .claude/agent-canvas/${CLAUDE_SESSION_ID}/<name>.jsx --session ${CLAUDE_SESSION_ID} --label "<Label>"`
159
- 5. **Tell the user** the canvas is ready for review (they see it in their browser)
160
- 6. **Wait**: `bunx agent-canvas watch --session ${CLAUDE_SESSION_ID}` — this blocks until the user submits feedback
161
- 7. **Read feedback** — check for annotations, answers, added context files, approval
162
- 8. **Edit with the Edit tool and re-push + wait**, or advance to next phase
163
- 9. **After implementation**, push a summary canvas
158
+ 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.
160
+ 6. **Read feedback** — check for annotations, answers, added context files, approval
161
+ 7. **Edit with the Edit tool and re-push + watch**, or advance to next phase
162
+ 8. **After implementation**, push a summary canvas
164
163
 
165
164
  ## Interview Integration
166
165
 
@@ -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