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.
- package/bin/lib/helpers.test.ts +30 -0
- package/bin/lib/helpers.ts +27 -0
- package/bin/lib/instructions/overview.md +5 -0
- package/daemon/client/App.tsx +37 -11
- package/daemon/client/PlanRenderer.tsx +67 -10
- package/daemon/client/RenderErrorContext.ts +11 -0
- package/daemon/dist/client.js +39 -39
- package/daemon/src/compiler.test.ts +13 -0
- package/daemon/src/compiler.ts +39 -5
- package/daemon/src/websocket.test.ts +99 -0
- package/daemon/src/websocket.ts +64 -10
- package/package.json +1 -1
- package/workers/canvas-share/public/assets/client.js +39 -39
package/bin/lib/helpers.test.ts
CHANGED
|
@@ -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
|
});
|
package/bin/lib/helpers.ts
CHANGED
|
@@ -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.
|
package/daemon/client/App.tsx
CHANGED
|
@@ -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 = () =>
|
|
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
|
-
<
|
|
496
|
-
<
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
|
|
677
|
-
|
|
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((
|
|
48
|
-
|
|
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
|
-
|
|
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>(() => {});
|