agent-canvas 0.1.42 → 0.1.43

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.
@@ -45,4 +45,34 @@ describe("waitForFeedback", () => {
45
45
 
46
46
  await expect(result).rejects.toThrow("WebSocket closed before feedback was submitted.");
47
47
  });
48
+
49
+ test("reports a browser render error without reconnecting", async () => {
50
+ const sockets: FakeSocket[] = [];
51
+ const result = waitForFeedback("session", {
52
+ createSocket: () => {
53
+ const socket = new FakeSocket();
54
+ sockets.push(socket);
55
+ queueMicrotask(() => socket.onmessage?.(new MessageEvent("message", {
56
+ data: JSON.stringify({
57
+ type: "render-error",
58
+ error: {
59
+ revision: 3,
60
+ filename: "architecture.jsx",
61
+ message: "Table: required prop `headers` must be an array",
62
+ componentStack: "\n at Table",
63
+ },
64
+ }),
65
+ })));
66
+ return socket;
67
+ },
68
+ timeoutMs: 1_000,
69
+ reconnectAttempts: 2,
70
+ reconnectDelayMs: 0,
71
+ });
72
+
73
+ await expect(result).rejects.toThrow(
74
+ "Canvas render failed in architecture.jsx (revision 3):\nTable: required prop `headers` must be an array",
75
+ );
76
+ expect(sockets).toHaveLength(1);
77
+ });
48
78
  });
@@ -47,6 +47,30 @@ interface FeedbackWaitOptions {
47
47
  reconnectDelayMs?: number;
48
48
  }
49
49
 
50
+ interface CanvasRenderError {
51
+ revision: number;
52
+ filename: string;
53
+ message: string;
54
+ stack?: string;
55
+ componentStack?: string;
56
+ }
57
+
58
+ function isCanvasRenderError(value: unknown): value is CanvasRenderError {
59
+ return typeof value === "object" && value !== null
60
+ && "revision" in value && Number.isInteger(value.revision)
61
+ && "filename" in value && typeof value.filename === "string"
62
+ && "message" in value && typeof value.message === "string"
63
+ && (!("stack" in value) || value.stack === undefined || typeof value.stack === "string")
64
+ && (!("componentStack" in value) || value.componentStack === undefined || typeof value.componentStack === "string");
65
+ }
66
+
67
+ function formatCanvasRenderError(error: CanvasRenderError): string {
68
+ const details = [`Canvas render failed in ${error.filename} (revision ${error.revision}):`, error.message];
69
+ if (error.componentStack?.trim()) details.push(`Component stack:${error.componentStack}`);
70
+ if (error.stack?.trim()) details.push(`Browser stack:\n${error.stack}`);
71
+ return details.join("\n");
72
+ }
73
+
50
74
  class FeedbackWaitError extends Error {
51
75
  constructor(message: string, readonly retryable: boolean) {
52
76
  super(message);
@@ -81,6 +105,9 @@ function waitForFeedbackConnection(
81
105
  if (data.type === "submit") {
82
106
  finish(data.feedback);
83
107
  }
108
+ if (data.type === "render-error" && isCanvasRenderError(data.error)) {
109
+ finish(new FeedbackWaitError(formatCanvasRenderError(data.error), false));
110
+ }
84
111
  } catch {}
85
112
  };
86
113
 
@@ -76,6 +76,11 @@ In Codex, a wait may yield before feedback arrives. Continue waiting on the same
76
76
  process handle; do not start another watcher. Keep user-facing progress updates
77
77
  brief and no more than 60 seconds apart while waiting.
78
78
 
79
+ If the watcher exits with `Canvas render failed`, treat it like a compilation
80
+ error: fix the reported canvas file, push the corrected revision, and start a
81
+ new watcher. The browser reports these failures automatically; do not ask the
82
+ user to transcribe console output.
83
+
79
84
  **Important**: The push → watch sequence is atomic. Never push without starting
80
85
  the host-specific watcher immediately afterward. Do not continue with unrelated
81
86
  work while waiting for feedback.
@@ -22,6 +22,7 @@ import { ReviewerIdentityDialog } from "./ReviewerIdentityDialog";
22
22
  import type { Annotation } from "#canvas/runtime";
23
23
  import { MODE, fetchMeta, FS_AVAILABLE, WS_AVAILABLE, submitSharedFeedback, getReviewerIdentity, setReviewerIdentity } from "./clientApi";
24
24
  import { carryUnsubmittedDraft, clearPersistedDraft } from "./annotationDraft";
25
+ import { RenderErrorContext, type CanvasRenderError } from "./RenderErrorContext";
25
26
 
26
27
  export type ActiveView = { type: "overview" } | { type: "canvas"; filename: string } | { type: "file"; path: string };
27
28
 
@@ -232,6 +233,8 @@ function App() {
232
233
  const [activeView, setActiveViewRaw] = useState<ActiveView>({ type: "overview" });
233
234
  const [openFiles, setOpenFiles] = useState<string[]>([]);
234
235
  const wsRef = useRef<WebSocket | null>(null);
236
+ const pendingRenderErrorsRef = useRef<CanvasRenderError[]>([]);
237
+ const reportedRenderErrorsRef = useRef(new Set<string>());
235
238
 
236
239
  const [projectRoot, setProjectRoot] = useState<string | undefined>();
237
240
  const [mobileSidebar, setMobileSidebar] = useState(false);
@@ -247,6 +250,21 @@ function App() {
247
250
  const [reviewerDialogOpen, setReviewerDialogOpen] = useState(false);
248
251
  const [pendingFeedback, setPendingFeedback] = useState<string | null>(null);
249
252
  const [toast, setToast] = useState<{ kind: "error" | "success"; message: string } | null>(null);
253
+
254
+ const reportRenderError = useCallback((error: CanvasRenderError) => {
255
+ if (isSharedMode) return;
256
+ const signature = `${error.revision}\0${error.filename}\0${error.message}`;
257
+ if (reportedRenderErrorsRef.current.has(signature)) return;
258
+ reportedRenderErrorsRef.current.add(signature);
259
+
260
+ const payload = JSON.stringify({ type: "render-error", error });
261
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
262
+ wsRef.current.send(payload);
263
+ } else {
264
+ pendingRenderErrorsRef.current.push(error);
265
+ }
266
+ }, [isSharedMode]);
267
+
250
268
  useEffect(() => {
251
269
  if (!toast) return;
252
270
  const t = setTimeout(() => setToast(null), 4000);
@@ -378,7 +396,13 @@ function App() {
378
396
  const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
379
397
  const ws = new WebSocket(`${protocol}//${window.location.host}/ws/session/${sessionId}`);
380
398
  wsRef.current = ws;
381
- ws.onopen = () => setConnected(true);
399
+ ws.onopen = () => {
400
+ setConnected(true);
401
+ for (const error of pendingRenderErrorsRef.current) {
402
+ ws.send(JSON.stringify({ type: "render-error", error }));
403
+ }
404
+ pendingRenderErrorsRef.current = [];
405
+ };
382
406
  ws.onclose = () => { setConnected(false); reconnectTimer = setTimeout(connect, 2000); };
383
407
  ws.onmessage = (event) => {
384
408
  try {
@@ -492,14 +516,15 @@ function App() {
492
516
 
493
517
  return (
494
518
  <SessionContext.Provider value={sessionId}>
495
- <RevisionContext.Provider value={{ currentRevision, selectedRevision, revisions, setSelectedRevision, isReadOnly, compareRevision, setCompareRevision, agentWatching }}>
496
- <AnnotationProvider
497
- key={`${sessionId}:${selectedRevision}`}
498
- sessionId={sessionId}
499
- revision={selectedRevision}
500
- isReadOnly={isReadOnly}
501
- remoteAnnotations={remoteAnnotationsByRev.get(selectedRevision)}
502
- >
519
+ <RenderErrorContext.Provider value={reportRenderError}>
520
+ <RevisionContext.Provider value={{ currentRevision, selectedRevision, revisions, setSelectedRevision, isReadOnly, compareRevision, setCompareRevision, agentWatching }}>
521
+ <AnnotationProvider
522
+ key={`${sessionId}:${selectedRevision}`}
523
+ sessionId={sessionId}
524
+ revision={selectedRevision}
525
+ isReadOnly={isReadOnly}
526
+ remoteAnnotations={remoteAnnotationsByRev.get(selectedRevision)}
527
+ >
503
528
  <ActiveViewContext.Provider value={{ activeView, setActiveView, openFiles, closeFile, canvasFiles }}>
504
529
  <ActiveViewCtx.Provider value={{ setActiveView }}>
505
530
  <div className="min-h-screen bg-bg-base">
@@ -673,8 +698,9 @@ function App() {
673
698
  </div>
674
699
  </ActiveViewCtx.Provider>
675
700
  </ActiveViewContext.Provider>
676
- </AnnotationProvider>
677
- </RevisionContext.Provider>
701
+ </AnnotationProvider>
702
+ </RevisionContext.Provider>
703
+ </RenderErrorContext.Provider>
678
704
  </SessionContext.Provider>
679
705
  );
680
706
  }
@@ -7,6 +7,7 @@ import { AnnotationCreatePopover, AnnotationEditPopover } from "./Popover";
7
7
  import { generateAnnotationId } from "./utils";
8
8
  import { useTextAnnotation } from "./useTextAnnotation";
9
9
  import { loadCanvasModule } from "./clientApi";
10
+ import { RenderErrorContext, type CanvasRenderError } from "./RenderErrorContext";
10
11
 
11
12
  /** All navigable blocks (keyboard arrows) */
12
13
  const BLOCK_SELECTOR = "[data-md='item'], [data-md='section'], [data-md='table'] tbody tr, [data-md='callout'], [data-md='note'], [data-md='checklist-item'], [data-md='choice-option'], [data-md='multichoice-option'], [data-md='userinput'], [data-md='rangeinput'], [data-md='image']";
@@ -18,8 +19,53 @@ interface PlanRendererProps {
18
19
  filename: string;
19
20
  }
20
21
 
22
+ interface CanvasErrorBoundaryProps {
23
+ revision: number;
24
+ filename: string;
25
+ reportError: (error: CanvasRenderError) => void;
26
+ children: React.ReactNode;
27
+ }
28
+
29
+ interface CanvasErrorBoundaryState {
30
+ error: Error | null;
31
+ }
32
+
33
+ class CanvasErrorBoundary extends React.Component<CanvasErrorBoundaryProps, CanvasErrorBoundaryState> {
34
+ state: CanvasErrorBoundaryState = { error: null };
35
+
36
+ static getDerivedStateFromError(error: Error): CanvasErrorBoundaryState {
37
+ return { error };
38
+ }
39
+
40
+ componentDidCatch(error: Error, info: React.ErrorInfo) {
41
+ this.props.reportError({
42
+ revision: this.props.revision,
43
+ filename: this.props.filename,
44
+ message: error.message,
45
+ ...(error.stack ? { stack: error.stack } : {}),
46
+ ...(info.componentStack ? { componentStack: info.componentStack } : {}),
47
+ });
48
+ }
49
+
50
+ render() {
51
+ if (!this.state.error) return this.props.children;
52
+ return <CanvasErrorDisplay message={this.state.error.message} />;
53
+ }
54
+ }
55
+
56
+ function CanvasErrorDisplay({ message }: { message: string }) {
57
+ return (
58
+ <div className="bg-accent-red-muted rounded-lg p-5">
59
+ <h3 className="font-body font-semibold text-accent-red mb-2">Canvas render failed</h3>
60
+ <pre className="text-code font-mono text-text-secondary whitespace-pre-wrap">{message}</pre>
61
+ <p className="text-meta font-body text-text-tertiary mt-3">The error was sent back to the canvas author.</p>
62
+ </div>
63
+ );
64
+ }
65
+
21
66
  export function PlanRenderer({ revision, filename }: PlanRendererProps) {
22
67
  const sessionId = useContext(SessionContext);
68
+ const reportError = useContext(RenderErrorContext);
23
69
  const [PlanComponent, setPlanComponent] = useState<React.ComponentType | null>(null);
24
70
  const [error, setError] = useState<string | null>(null);
25
71
  const [loading, setLoading] = useState(true);
@@ -44,8 +90,18 @@ export function PlanRenderer({ revision, filename }: PlanRendererProps) {
44
90
  void sessionId; // sessionId is implied by the URL in clientApi (shared or local)
45
91
  loadCanvasModule(filename, revision)
46
92
  .then((mod) => { setPlanComponent(() => mod.default); setLoading(false); })
47
- .catch((e) => { setError(e.message); setLoading(false); });
48
- }, [sessionId, revision, filename]);
93
+ .catch((cause: unknown) => {
94
+ const moduleError = cause instanceof Error ? cause : new Error(String(cause));
95
+ setError(moduleError.message);
96
+ setLoading(false);
97
+ reportError({
98
+ revision,
99
+ filename,
100
+ message: moduleError.message,
101
+ ...(moduleError.stack ? { stack: moduleError.stack } : {}),
102
+ });
103
+ });
104
+ }, [sessionId, revision, filename, reportError]);
49
105
 
50
106
  const scrollContainer = document.getElementById("plan-scroll-container");
51
107
 
@@ -250,12 +306,7 @@ export function PlanRenderer({ revision, filename }: PlanRendererProps) {
250
306
  }
251
307
 
252
308
  if (error) {
253
- return (
254
- <div className="bg-accent-red-muted rounded-lg p-5">
255
- <h3 className="font-body font-semibold text-accent-red mb-2">Compilation Error</h3>
256
- <pre className="text-code font-mono text-text-secondary whitespace-pre-wrap">{error}</pre>
257
- </div>
258
- );
309
+ return <CanvasErrorDisplay message={error} />;
259
310
  }
260
311
 
261
312
  if (!PlanComponent) {
@@ -265,7 +316,14 @@ export function PlanRenderer({ revision, filename }: PlanRendererProps) {
265
316
  return (
266
317
  <CanvasFileCtx.Provider value={filename}>
267
318
  <div ref={containerRef} className="plan-content plan-updated">
268
- {planElement}
319
+ <CanvasErrorBoundary
320
+ key={`${revision}:${filename}`}
321
+ revision={revision}
322
+ filename={filename}
323
+ reportError={reportError}
324
+ >
325
+ {planElement}
326
+ </CanvasErrorBoundary>
269
327
  </div>
270
328
  {editingAnn && (
271
329
  <EditAnnotationModal
@@ -554,4 +612,3 @@ function EditAnnotationModal({ note, onSave, onCancel }: { note: string; onSave:
554
612
  </div>
555
613
  );
556
614
  }
557
-
@@ -0,0 +1,11 @@
1
+ import { createContext } from "react";
2
+
3
+ export interface CanvasRenderError {
4
+ revision: number;
5
+ filename: string;
6
+ message: string;
7
+ stack?: string;
8
+ componentStack?: string;
9
+ }
10
+
11
+ export const RenderErrorContext = createContext<(error: CanvasRenderError) => void>(() => {});