agent-canvas 0.1.42 → 0.1.44

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,3 +42,16 @@ test("runbook secret input compiles", async () => {
42
42
  const result = await compilePlan(`<SecretInput id="service-token" label="Service token" env="SERVICE_TOKEN" required />`);
43
43
  expect(result.ok).toBe(true);
44
44
  });
45
+
46
+ test("missing required component prop fails before the browser renders", async () => {
47
+ const result = await compilePlan(`<Table columns={["Name"]} rows={[["Canvas"]]} />`);
48
+
49
+ expect(result.ok).toBe(false);
50
+ if (result.ok) return;
51
+ expect(result.error).toBe("Runtime error: Table: required prop `headers` must be an array");
52
+ });
53
+
54
+ test("component prop validation accepts valid table props", async () => {
55
+ const result = await compilePlan(`<Table headers={["Name"]} rows={[["Canvas"]]} />`);
56
+ expect(result.ok).toBe(true);
57
+ });
@@ -1,7 +1,7 @@
1
1
  import { writeFileSync, unlinkSync, readFileSync } from "fs";
2
2
  import { join } from "path";
3
3
  import { randomUUID } from "crypto";
4
- import { h, Fragment } from "preact";
4
+ import { h, Fragment, type ComponentChildren } from "preact";
5
5
  import renderToString from "preact-render-to-string";
6
6
  import { parse as parseMermaid } from "mermaid-parser-bundle";
7
7
  import { COMPILE_TEMP_DIR } from "./paths";
@@ -33,17 +33,51 @@ function mockJsxDEV(type: any, props: any, key?: any) {
33
33
  return h(type, { ...rest, key }, children);
34
34
  }
35
35
 
36
+ interface StubProps {
37
+ children?: ComponentChildren;
38
+ [key: string]: unknown;
39
+ }
40
+
36
41
  /** Passthrough component — just renders its children */
37
- const Stub = ({ children }: any) => h(Fragment, null, children);
42
+ const Stub = ({ children }: StubProps) => h(Fragment, null, children);
43
+
44
+ type RequiredPropKind = "array" | "string";
45
+
46
+ function validatingStub(component: string, requiredProps: Record<string, RequiredPropKind>) {
47
+ return (props: StubProps) => {
48
+ for (const [prop, kind] of Object.entries(requiredProps)) {
49
+ const value = props[prop];
50
+ const valid = kind === "array" ? Array.isArray(value) : typeof value === kind;
51
+ if (!valid) {
52
+ throw new Error(`${component}: required prop \`${prop}\` must be ${kind === "array" ? "an array" : `a ${kind}`}`);
53
+ }
54
+ }
55
+ return h(Fragment, null, props.children);
56
+ };
57
+ }
38
58
 
39
59
  const STUB_COMPONENTS: Record<string, any> = {};
40
60
  for (const name of [
41
- "Section", "Item", "Task", "FilePreview", "CodeBlock", "Callout",
42
- "Table", "Priority", "Checklist", "Note", "Diff",
43
- "Choice", "MultiChoice", "UserInput", "RangeInput", "SecretInput", "ImageView", "Markdown",
61
+ "CodeBlock", "Callout", "Note", "Markdown",
44
62
  ]) {
45
63
  STUB_COMPONENTS[name] = Stub;
46
64
  }
65
+ Object.assign(STUB_COMPONENTS, {
66
+ Section: validatingStub("Section", { title: "string" }),
67
+ Item: validatingStub("Item", { id: "string", label: "string" }),
68
+ Task: validatingStub("Task", { id: "string", label: "string" }),
69
+ FilePreview: validatingStub("FilePreview", { path: "string" }),
70
+ Table: validatingStub("Table", { headers: "array", rows: "array" }),
71
+ Priority: validatingStub("Priority", { level: "string" }),
72
+ Checklist: validatingStub("Checklist", { items: "array" }),
73
+ Diff: validatingStub("Diff", { before: "string", after: "string" }),
74
+ Choice: validatingStub("Choice", { id: "string", label: "string", options: "array" }),
75
+ MultiChoice: validatingStub("MultiChoice", { id: "string", label: "string", options: "array" }),
76
+ UserInput: validatingStub("UserInput", { id: "string", label: "string" }),
77
+ RangeInput: validatingStub("RangeInput", { id: "string", label: "string" }),
78
+ SecretInput: validatingStub("SecretInput", { id: "string", label: "string", env: "string" }),
79
+ ImageView: validatingStub("ImageView", { src: "string" }),
80
+ });
47
81
  // Mermaid stub collects diagram sources for post-render validation
48
82
  let collectedMermaidSources: string[] = [];
49
83
  STUB_COMPONENTS.Mermaid = ({ children }: any) => {
@@ -0,0 +1,99 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { SessionManager } from "./session";
6
+ import { createWebSocketManager, type CanvasSocket, type WSData } from "./websocket";
7
+
8
+ const testDirectories: string[] = [];
9
+
10
+ afterEach(() => {
11
+ for (const directory of testDirectories.splice(0)) {
12
+ rmSync(directory, { recursive: true, force: true });
13
+ }
14
+ });
15
+
16
+ class FakeSocket implements CanvasSocket {
17
+ readonly sent: string[] = [];
18
+ closed = false;
19
+
20
+ constructor(readonly data: WSData) {}
21
+
22
+ send(message: string) {
23
+ this.sent.push(message);
24
+ }
25
+
26
+ close() {
27
+ this.closed = true;
28
+ }
29
+
30
+ ping() {}
31
+ }
32
+
33
+ function createSessionManager(): SessionManager {
34
+ const directory = mkdtempSync(join(tmpdir(), "agent-canvas-websocket-test-"));
35
+ testDirectories.push(directory);
36
+ const manager = new SessionManager(directory);
37
+ manager.upsert(
38
+ "session",
39
+ new Map([["architecture.jsx", "<Section title=\"Architecture\" />"]]),
40
+ directory,
41
+ );
42
+ return manager;
43
+ }
44
+
45
+ describe("canvas render errors", () => {
46
+ test("delivers an error that arrived before the CLI watcher connected", () => {
47
+ const sessionManager = createSessionManager();
48
+ const websocket = createWebSocketManager(sessionManager);
49
+ const browser = new FakeSocket({ type: "browser", sessionId: "session" });
50
+ websocket.handlers.open(browser);
51
+
52
+ websocket.handlers.message(browser, JSON.stringify({
53
+ type: "render-error",
54
+ error: {
55
+ revision: 1,
56
+ filename: "architecture.jsx",
57
+ message: "Cannot read properties of undefined (reading 'map')",
58
+ },
59
+ }));
60
+
61
+ const watcher = new FakeSocket({ type: "wait", sessionId: "session" });
62
+ websocket.handlers.open(watcher);
63
+
64
+ expect(watcher.sent).toHaveLength(1);
65
+ expect(JSON.parse(watcher.sent[0])).toEqual({
66
+ type: "render-error",
67
+ error: {
68
+ revision: 1,
69
+ filename: "architecture.jsx",
70
+ message: "Cannot read properties of undefined (reading 'map')",
71
+ },
72
+ });
73
+ expect(watcher.closed).toBe(true);
74
+ expect(sessionManager.get("session")?.revisions[0].hasFeedback).toBe(false);
75
+ });
76
+
77
+ test("drops a pending error when a new revision is pushed", () => {
78
+ const sessionManager = createSessionManager();
79
+ const websocket = createWebSocketManager(sessionManager);
80
+ const browser = new FakeSocket({ type: "browser", sessionId: "session" });
81
+ websocket.handlers.open(browser);
82
+ websocket.handlers.message(browser, JSON.stringify({
83
+ type: "render-error",
84
+ error: { revision: 1, filename: "architecture.jsx", message: "Broken revision" },
85
+ }));
86
+
87
+ sessionManager.upsert(
88
+ "session",
89
+ new Map([["architecture.jsx", "<Section title=\"Fixed\" />"]]),
90
+ "/project",
91
+ );
92
+ websocket.broadcastPlanUpdate("session");
93
+
94
+ const watcher = new FakeSocket({ type: "wait", sessionId: "session" });
95
+ websocket.handlers.open(watcher);
96
+ expect(watcher.sent).toEqual([]);
97
+ expect(watcher.closed).toBe(false);
98
+ });
99
+ });
@@ -1,13 +1,38 @@
1
- import type { ServerWebSocket } from "bun";
2
1
  import type { SessionManager, RemoteFeedbackEntry } from "./session";
3
2
 
4
3
  export type WSData = { type: "browser" | "wait"; sessionId: string };
5
4
 
5
+ export interface CanvasSocket {
6
+ data: WSData;
7
+ send(message: string): unknown;
8
+ close(): void;
9
+ ping(): unknown;
10
+ }
11
+
12
+ interface CanvasRenderError {
13
+ revision: number;
14
+ filename: string;
15
+ message: string;
16
+ stack?: string;
17
+ componentStack?: string;
18
+ }
19
+
20
+ function isCanvasRenderError(value: unknown): value is CanvasRenderError {
21
+ return typeof value === "object" && value !== null
22
+ && "revision" in value && Number.isInteger(value.revision)
23
+ && "filename" in value && typeof value.filename === "string"
24
+ && "message" in value && typeof value.message === "string"
25
+ && (!("stack" in value) || value.stack === undefined || typeof value.stack === "string")
26
+ && (!("componentStack" in value) || value.componentStack === undefined || typeof value.componentStack === "string");
27
+ }
28
+
6
29
  export function createWebSocketManager(sessionManager: SessionManager) {
7
- const browserSockets = new Map<string, Set<ServerWebSocket<WSData>>>();
8
- const waitSockets = new Map<string, Set<ServerWebSocket<WSData>>>();
30
+ const browserSockets = new Map<string, Set<CanvasSocket>>();
31
+ const waitSockets = new Map<string, Set<CanvasSocket>>();
32
+ const pendingRenderErrors = new Map<string, CanvasRenderError>();
9
33
 
10
34
  function broadcastPlanUpdate(sessionId: string) {
35
+ pendingRenderErrors.delete(sessionId);
11
36
  const sockets = browserSockets.get(sessionId);
12
37
  if (!sockets) return;
13
38
  const session = sessionManager.get(sessionId);
@@ -59,13 +84,29 @@ export function createWebSocketManager(sessionManager: SessionManager) {
59
84
  }
60
85
  }
61
86
 
87
+ function deliverPendingRenderError(sessionId: string): boolean {
88
+ const error = pendingRenderErrors.get(sessionId);
89
+ const waiters = waitSockets.get(sessionId);
90
+ if (!error || !waiters || waiters.size === 0) return false;
91
+
92
+ pendingRenderErrors.delete(sessionId);
93
+ waitSockets.delete(sessionId);
94
+ const payload = JSON.stringify({ type: "render-error", error });
95
+ for (const waiter of waiters) {
96
+ waiter.send(payload);
97
+ waiter.close();
98
+ }
99
+ broadcastWatcherStatus(sessionId);
100
+ return true;
101
+ }
102
+
62
103
  // Ping wait sockets periodically to detect dead connections.
63
104
  // We can't rely on ws.close() triggering the close handler for dead sockets,
64
105
  // so we manually remove dead sockets and broadcast status.
65
106
  const PING_INTERVAL = 5_000;
66
- const pongReceived = new WeakSet<ServerWebSocket<WSData>>();
107
+ const pongReceived = new WeakSet<CanvasSocket>();
67
108
 
68
- function removeWaitSocket(ws: ServerWebSocket<WSData>, sessionId: string) {
109
+ function removeWaitSocket(ws: CanvasSocket, sessionId: string) {
69
110
  const sockets = waitSockets.get(sessionId);
70
111
  if (sockets) {
71
112
  sockets.delete(ws);
@@ -92,7 +133,7 @@ export function createWebSocketManager(sessionManager: SessionManager) {
92
133
  }, PING_INTERVAL);
93
134
 
94
135
  const handlers = {
95
- open(ws: ServerWebSocket<WSData>) {
136
+ open(ws: CanvasSocket) {
96
137
  const { type, sessionId } = ws.data;
97
138
  const map = type === "browser" ? browserSockets : waitSockets;
98
139
  if (!map.has(sessionId)) map.set(sessionId, new Set());
@@ -104,10 +145,12 @@ export function createWebSocketManager(sessionManager: SessionManager) {
104
145
  } else {
105
146
  // CLI waiter connected — notify browsers
106
147
  pongReceived.add(ws); // Give it a free pass on first interval
107
- broadcastWatcherStatus(sessionId);
148
+ if (!deliverPendingRenderError(sessionId)) {
149
+ broadcastWatcherStatus(sessionId);
150
+ }
108
151
  }
109
152
  },
110
- message(ws: ServerWebSocket<WSData>, message: string | Buffer) {
153
+ message(ws: CanvasSocket, message: string | Buffer) {
111
154
  const { type, sessionId } = ws.data;
112
155
  if (type === "browser") {
113
156
  try {
@@ -133,15 +176,26 @@ export function createWebSocketManager(sessionManager: SessionManager) {
133
176
  }
134
177
  }
135
178
  }
179
+ if (data.type === "render-error" && isCanvasRenderError(data.error)) {
180
+ const session = sessionManager.get(sessionId);
181
+ if (
182
+ session
183
+ && data.error.revision === session.currentRevision
184
+ && session.canvasFiles.includes(data.error.filename)
185
+ ) {
186
+ pendingRenderErrors.set(sessionId, data.error);
187
+ deliverPendingRenderError(sessionId);
188
+ }
189
+ }
136
190
  } catch {}
137
191
  }
138
192
  },
139
- pong(ws: ServerWebSocket<WSData>) {
193
+ pong(ws: CanvasSocket) {
140
194
  if (ws.data.type === "wait") {
141
195
  pongReceived.add(ws);
142
196
  }
143
197
  },
144
- close(ws: ServerWebSocket<WSData>) {
198
+ close(ws: CanvasSocket) {
145
199
  const { type, sessionId } = ws.data;
146
200
  const map = type === "browser" ? browserSockets : waitSockets;
147
201
  map.get(sessionId)?.delete(ws);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-canvas",
3
- "version": "0.1.42",
3
+ "version": "0.1.44",
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"