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.
- 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/AnnotationProvider.tsx +32 -28
- package/daemon/client/App.tsx +43 -12
- package/daemon/client/PlanRenderer.tsx +67 -10
- package/daemon/client/RenderErrorContext.ts +11 -0
- package/daemon/client/annotationDraft.test.ts +41 -2
- package/daemon/client/annotationDraft.ts +27 -7
- package/daemon/dist/client.js +40 -40
- 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 +40 -40
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.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React, { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
|
2
2
|
import { AnnotationCtx } from "#canvas/runtime";
|
|
3
3
|
import type { Annotation, AnnotationContext, PlanResponse, FeedbackEntry, AnnotationContextValue } from "#canvas/runtime";
|
|
4
|
-
import { clearPersistedDraft } from "./annotationDraft";
|
|
4
|
+
import { annotationDraftKey, clearPersistedDraft, type AnnotationDraftPhase } from "./annotationDraft";
|
|
5
5
|
import { generateAnnotationId } from "./utils";
|
|
6
6
|
|
|
7
7
|
// Re-export types for convenience
|
|
@@ -12,6 +12,7 @@ interface AnnotationProviderProps {
|
|
|
12
12
|
sessionId: string;
|
|
13
13
|
revision: number;
|
|
14
14
|
isReadOnly: boolean;
|
|
15
|
+
draftPhase: AnnotationDraftPhase;
|
|
15
16
|
/** Remote annotations fetched from shared views. Merged read-only into
|
|
16
17
|
* the annotation list so they render alongside the author's own. */
|
|
17
18
|
remoteAnnotations?: Annotation[];
|
|
@@ -25,13 +26,9 @@ interface PersistedState {
|
|
|
25
26
|
feedbackEntries?: [string, FeedbackEntry][];
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
function
|
|
29
|
-
return `canvas:${sessionId}:rev:${revision}`;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function loadPersisted(sessionId: string, revision: number): PersistedState | null {
|
|
29
|
+
function loadPersisted(sessionId: string, revision: number, phase: AnnotationDraftPhase): PersistedState | null {
|
|
33
30
|
try {
|
|
34
|
-
const raw = localStorage.getItem(
|
|
31
|
+
const raw = localStorage.getItem(annotationDraftKey(sessionId, revision, phase));
|
|
35
32
|
if (!raw) return null;
|
|
36
33
|
return JSON.parse(raw);
|
|
37
34
|
} catch {
|
|
@@ -39,15 +36,15 @@ function loadPersisted(sessionId: string, revision: number): PersistedState | nu
|
|
|
39
36
|
}
|
|
40
37
|
}
|
|
41
38
|
|
|
42
|
-
function savePersisted(sessionId: string, revision: number, state: PersistedState) {
|
|
39
|
+
function savePersisted(sessionId: string, revision: number, phase: AnnotationDraftPhase, state: PersistedState) {
|
|
43
40
|
try {
|
|
44
|
-
localStorage.setItem(
|
|
41
|
+
localStorage.setItem(annotationDraftKey(sessionId, revision, phase), JSON.stringify(state));
|
|
45
42
|
} catch {}
|
|
46
43
|
}
|
|
47
44
|
|
|
48
|
-
export function AnnotationProvider({ sessionId, revision, isReadOnly, remoteAnnotations, children }: AnnotationProviderProps) {
|
|
45
|
+
export function AnnotationProvider({ sessionId, revision, isReadOnly, draftPhase, remoteAnnotations, children }: AnnotationProviderProps) {
|
|
49
46
|
const [localAnnotations, setAnnotations] = useState<Annotation[]>(() => {
|
|
50
|
-
const saved = loadPersisted(sessionId, revision);
|
|
47
|
+
const saved = loadPersisted(sessionId, revision, draftPhase);
|
|
51
48
|
return (saved?.annotations ?? []).map((a) => ({ ...a, source: a.source ?? "local" as const }));
|
|
52
49
|
});
|
|
53
50
|
|
|
@@ -61,35 +58,38 @@ export function AnnotationProvider({ sessionId, revision, isReadOnly, remoteAnno
|
|
|
61
58
|
return [...localAnnotations, ...filtered.map((a) => ({ ...a, source: "remote" as const }))];
|
|
62
59
|
}, [localAnnotations, remoteAnnotations]);
|
|
63
60
|
const [generalNote, setGeneralNote] = useState(() => {
|
|
64
|
-
const saved = loadPersisted(sessionId, revision);
|
|
61
|
+
const saved = loadPersisted(sessionId, revision, draftPhase);
|
|
65
62
|
return saved?.generalNote ?? "";
|
|
66
63
|
});
|
|
67
64
|
const [activeAnnotationId, setActiveAnnotationId] = useState<string | null>(null);
|
|
68
65
|
const [responses, setResponses] = useState<Map<string, PlanResponse>>(() => {
|
|
69
|
-
const saved = loadPersisted(sessionId, revision);
|
|
66
|
+
const saved = loadPersisted(sessionId, revision, draftPhase);
|
|
70
67
|
return saved?.responses ? new Map(saved.responses) : new Map();
|
|
71
68
|
});
|
|
72
69
|
const [feedbackEntries, setFeedbackEntries] = useState<Map<string, FeedbackEntry>>(() => {
|
|
73
|
-
const saved = loadPersisted(sessionId, revision);
|
|
70
|
+
const saved = loadPersisted(sessionId, revision, draftPhase);
|
|
74
71
|
return saved?.feedbackEntries ? new Map(saved.feedbackEntries) : new Map();
|
|
75
72
|
});
|
|
76
73
|
|
|
77
|
-
//
|
|
74
|
+
// Post-feedback drafts save immediately so a new revision can carry the latest edit.
|
|
78
75
|
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
79
76
|
useEffect(() => {
|
|
80
|
-
if (isReadOnly) return;
|
|
77
|
+
if (isReadOnly && draftPhase !== "next") return;
|
|
81
78
|
if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
79
|
+
const state = {
|
|
80
|
+
// Persist only local annotations — remote ones are re-fetched on load.
|
|
81
|
+
annotations: localAnnotations,
|
|
82
|
+
generalNote,
|
|
83
|
+
responses: Array.from(responses.entries()),
|
|
84
|
+
feedbackEntries: Array.from(feedbackEntries.entries()),
|
|
85
|
+
};
|
|
86
|
+
if (draftPhase === "next") {
|
|
87
|
+
savePersisted(sessionId, revision, draftPhase, state);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
persistTimerRef.current = setTimeout(() => savePersisted(sessionId, revision, draftPhase, state), 300);
|
|
91
91
|
return () => { if (persistTimerRef.current) clearTimeout(persistTimerRef.current); };
|
|
92
|
-
}, [localAnnotations, generalNote, responses, feedbackEntries, sessionId, revision, isReadOnly]);
|
|
92
|
+
}, [localAnnotations, generalNote, responses, feedbackEntries, sessionId, revision, isReadOnly, draftPhase]);
|
|
93
93
|
|
|
94
94
|
const addAnnotationWithId = useCallback((id: string, snippet: string, note: string, filePath?: string, context?: AnnotationContext, images?: string[], canvasFile?: string) => {
|
|
95
95
|
setAnnotations((prev) => [...prev, { id, snippet, note, createdAt: new Date().toISOString(), filePath, context, ...(images?.length ? { images } : {}), ...(canvasFile ? { canvasFile } : {}) }]);
|
|
@@ -159,8 +159,12 @@ export function AnnotationProvider({ sessionId, revision, isReadOnly, remoteAnno
|
|
|
159
159
|
setActiveAnnotationId(null);
|
|
160
160
|
setResponses(new Map());
|
|
161
161
|
setFeedbackEntries(new Map());
|
|
162
|
-
|
|
163
|
-
|
|
162
|
+
if (draftPhase === "next") {
|
|
163
|
+
localStorage.removeItem(annotationDraftKey(sessionId, revision, draftPhase));
|
|
164
|
+
} else {
|
|
165
|
+
clearPersistedDraft(localStorage, sessionId, revision);
|
|
166
|
+
}
|
|
167
|
+
}, [sessionId, revision, draftPhase]);
|
|
164
168
|
|
|
165
169
|
return (
|
|
166
170
|
<AnnotationCtx.Provider
|
package/daemon/client/App.tsx
CHANGED
|
@@ -21,7 +21,8 @@ import { ShareDialog, ShareButton, type ShareEntry } from "./ShareDialog";
|
|
|
21
21
|
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
|
-
import { carryUnsubmittedDraft, clearPersistedDraft } from "./annotationDraft";
|
|
24
|
+
import { carryUnsubmittedDraft, clearPersistedDraft, type AnnotationDraftPhase } 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);
|
|
@@ -279,6 +297,10 @@ function App() {
|
|
|
279
297
|
|
|
280
298
|
const selectedRevInfo = revisions.find((r) => r.revision === selectedRevision);
|
|
281
299
|
const isReadOnly = selectedRevision !== currentRevision || !!selectedRevInfo?.hasFeedback || sharedFeedbackSubmitted;
|
|
300
|
+
// Keep feedback added after submission separate from the already-sent draft.
|
|
301
|
+
const draftPhase: AnnotationDraftPhase = selectedRevision === currentRevision && (!!selectedRevInfo?.hasFeedback || sharedFeedbackSubmitted)
|
|
302
|
+
? "next"
|
|
303
|
+
: "current";
|
|
282
304
|
|
|
283
305
|
const setActiveView = useCallback((v: ActiveView) => {
|
|
284
306
|
// Save current scroll position before switching
|
|
@@ -378,7 +400,13 @@ function App() {
|
|
|
378
400
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
379
401
|
const ws = new WebSocket(`${protocol}//${window.location.host}/ws/session/${sessionId}`);
|
|
380
402
|
wsRef.current = ws;
|
|
381
|
-
ws.onopen = () =>
|
|
403
|
+
ws.onopen = () => {
|
|
404
|
+
setConnected(true);
|
|
405
|
+
for (const error of pendingRenderErrorsRef.current) {
|
|
406
|
+
ws.send(JSON.stringify({ type: "render-error", error }));
|
|
407
|
+
}
|
|
408
|
+
pendingRenderErrorsRef.current = [];
|
|
409
|
+
};
|
|
382
410
|
ws.onclose = () => { setConnected(false); reconnectTimer = setTimeout(connect, 2000); };
|
|
383
411
|
ws.onmessage = (event) => {
|
|
384
412
|
try {
|
|
@@ -492,14 +520,16 @@ function App() {
|
|
|
492
520
|
|
|
493
521
|
return (
|
|
494
522
|
<SessionContext.Provider value={sessionId}>
|
|
495
|
-
<
|
|
496
|
-
<
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
523
|
+
<RenderErrorContext.Provider value={reportRenderError}>
|
|
524
|
+
<RevisionContext.Provider value={{ currentRevision, selectedRevision, revisions, setSelectedRevision, isReadOnly, compareRevision, setCompareRevision, agentWatching }}>
|
|
525
|
+
<AnnotationProvider
|
|
526
|
+
key={`${sessionId}:${selectedRevision}:${draftPhase}`}
|
|
527
|
+
sessionId={sessionId}
|
|
528
|
+
revision={selectedRevision}
|
|
529
|
+
isReadOnly={isReadOnly}
|
|
530
|
+
draftPhase={draftPhase}
|
|
531
|
+
remoteAnnotations={remoteAnnotationsByRev.get(selectedRevision)}
|
|
532
|
+
>
|
|
503
533
|
<ActiveViewContext.Provider value={{ activeView, setActiveView, openFiles, closeFile, canvasFiles }}>
|
|
504
534
|
<ActiveViewCtx.Provider value={{ setActiveView }}>
|
|
505
535
|
<div className="min-h-screen bg-bg-base">
|
|
@@ -673,8 +703,9 @@ function App() {
|
|
|
673
703
|
</div>
|
|
674
704
|
</ActiveViewCtx.Provider>
|
|
675
705
|
</ActiveViewContext.Provider>
|
|
676
|
-
|
|
677
|
-
|
|
706
|
+
</AnnotationProvider>
|
|
707
|
+
</RevisionContext.Provider>
|
|
708
|
+
</RenderErrorContext.Provider>
|
|
678
709
|
</SessionContext.Provider>
|
|
679
710
|
);
|
|
680
711
|
}
|
|
@@ -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>(() => {});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { carryUnsubmittedDraft, clearPersistedDraft } from "./annotationDraft";
|
|
2
|
+
import { annotationDraftKey, carryUnsubmittedDraft, clearPersistedDraft } from "./annotationDraft";
|
|
3
3
|
|
|
4
4
|
class MemoryStorage {
|
|
5
5
|
private readonly values = new Map<string, string>();
|
|
@@ -18,7 +18,8 @@ class MemoryStorage {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
const sessionId = "session";
|
|
21
|
-
const key = (revision: number) =>
|
|
21
|
+
const key = (revision: number) => annotationDraftKey(sessionId, revision);
|
|
22
|
+
const nextKey = (revision: number) => annotationDraftKey(sessionId, revision, "next");
|
|
22
23
|
|
|
23
24
|
describe("carryUnsubmittedDraft", () => {
|
|
24
25
|
test("clears a submitted draft and its carry marker", () => {
|
|
@@ -78,6 +79,44 @@ describe("carryUnsubmittedDraft", () => {
|
|
|
78
79
|
expect(storage.getItem(key(3))).toBeNull();
|
|
79
80
|
});
|
|
80
81
|
|
|
82
|
+
test("carries a post-feedback draft into the next revision", () => {
|
|
83
|
+
const storage = new MemoryStorage();
|
|
84
|
+
storage.setItem(key(2), JSON.stringify({
|
|
85
|
+
annotations: [{ id: "submitted-annotation" }],
|
|
86
|
+
generalNote: "Submitted feedback",
|
|
87
|
+
responses: [],
|
|
88
|
+
}));
|
|
89
|
+
storage.setItem(nextKey(2), JSON.stringify({
|
|
90
|
+
annotations: [{ id: "next-annotation" }],
|
|
91
|
+
generalNote: "",
|
|
92
|
+
responses: [],
|
|
93
|
+
feedbackEntries: [["generated", { markdown: "stale generated content" }]],
|
|
94
|
+
}));
|
|
95
|
+
|
|
96
|
+
expect(carryUnsubmittedDraft(storage, sessionId, 3, [
|
|
97
|
+
{ revision: 2, hasFeedback: true },
|
|
98
|
+
{ revision: 3, hasFeedback: false },
|
|
99
|
+
])).toBe(2);
|
|
100
|
+
expect(JSON.parse(storage.getItem(key(3)) ?? "")).toEqual({
|
|
101
|
+
annotations: [{ id: "next-annotation" }],
|
|
102
|
+
generalNote: "",
|
|
103
|
+
responses: [],
|
|
104
|
+
feedbackEntries: [],
|
|
105
|
+
});
|
|
106
|
+
expect(storage.getItem(nextKey(2))).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("clearing submitted feedback preserves its next-revision draft", () => {
|
|
110
|
+
const storage = new MemoryStorage();
|
|
111
|
+
storage.setItem(key(2), JSON.stringify({ annotations: [{ id: "submitted" }] }));
|
|
112
|
+
storage.setItem(nextKey(2), JSON.stringify({ annotations: [{ id: "next" }] }));
|
|
113
|
+
|
|
114
|
+
clearPersistedDraft(storage, sessionId, 2);
|
|
115
|
+
|
|
116
|
+
expect(storage.getItem(key(2))).toBeNull();
|
|
117
|
+
expect(storage.getItem(nextKey(2))).not.toBeNull();
|
|
118
|
+
});
|
|
119
|
+
|
|
81
120
|
test("does not overwrite meaningful target data", () => {
|
|
82
121
|
const storage = new MemoryStorage();
|
|
83
122
|
storage.setItem(key(2), JSON.stringify({
|
|
@@ -6,18 +6,26 @@ interface RevisionState {
|
|
|
6
6
|
interface AnnotationStorage {
|
|
7
7
|
getItem(key: string): string | null;
|
|
8
8
|
setItem(key: string, value: string): void;
|
|
9
|
+
removeItem(key: string): void;
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
interface RemovableAnnotationStorage {
|
|
12
13
|
removeItem(key: string): void;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
export type AnnotationDraftPhase = "current" | "next";
|
|
17
|
+
|
|
18
|
+
export function annotationDraftKey(
|
|
19
|
+
sessionId: string,
|
|
20
|
+
revision: number,
|
|
21
|
+
phase: AnnotationDraftPhase = "current",
|
|
22
|
+
): string {
|
|
23
|
+
const base = `canvas:${sessionId}:rev:${revision}`;
|
|
24
|
+
return phase === "next" ? `${base}:next-draft` : base;
|
|
17
25
|
}
|
|
18
26
|
|
|
19
27
|
function handledKey(sessionId: string, revision: number): string {
|
|
20
|
-
return `${
|
|
28
|
+
return `${annotationDraftKey(sessionId, revision)}:draft-handled`;
|
|
21
29
|
}
|
|
22
30
|
|
|
23
31
|
export function clearPersistedDraft(
|
|
@@ -26,7 +34,7 @@ export function clearPersistedDraft(
|
|
|
26
34
|
revision: number,
|
|
27
35
|
): void {
|
|
28
36
|
try {
|
|
29
|
-
storage.removeItem(
|
|
37
|
+
storage.removeItem(annotationDraftKey(sessionId, revision));
|
|
30
38
|
storage.removeItem(handledKey(sessionId, revision));
|
|
31
39
|
} catch {}
|
|
32
40
|
}
|
|
@@ -77,7 +85,7 @@ export function carryUnsubmittedDraft(
|
|
|
77
85
|
revisions: RevisionState[],
|
|
78
86
|
): number | null {
|
|
79
87
|
try {
|
|
80
|
-
const targetKey =
|
|
88
|
+
const targetKey = annotationDraftKey(sessionId, targetRevision);
|
|
81
89
|
if (hasDraftContent(parseState(storage.getItem(targetKey)))) {
|
|
82
90
|
storage.setItem(handledKey(sessionId, targetRevision), "1");
|
|
83
91
|
return null;
|
|
@@ -89,9 +97,21 @@ export function carryUnsubmittedDraft(
|
|
|
89
97
|
.sort((left, right) => right.revision - left.revision);
|
|
90
98
|
|
|
91
99
|
for (const revision of previous) {
|
|
92
|
-
if (revision.hasFeedback)
|
|
100
|
+
if (revision.hasFeedback) {
|
|
101
|
+
const sourceKey = annotationDraftKey(sessionId, revision.revision, "next");
|
|
102
|
+
const source = parseState(storage.getItem(sourceKey));
|
|
103
|
+
if (!hasDraftContent(source)) return null;
|
|
104
|
+
|
|
105
|
+
storage.setItem(targetKey, JSON.stringify({
|
|
106
|
+
...source,
|
|
107
|
+
feedbackEntries: [],
|
|
108
|
+
}));
|
|
109
|
+
storage.setItem(handledKey(sessionId, targetRevision), "1");
|
|
110
|
+
storage.removeItem(sourceKey);
|
|
111
|
+
return revision.revision;
|
|
112
|
+
}
|
|
93
113
|
|
|
94
|
-
const source = parseState(storage.getItem(
|
|
114
|
+
const source = parseState(storage.getItem(annotationDraftKey(sessionId, revision.revision)));
|
|
95
115
|
if (!hasDraftContent(source)) {
|
|
96
116
|
if (storage.getItem(handledKey(sessionId, revision.revision)) !== null) return null;
|
|
97
117
|
continue;
|