agent-canvas 0.1.14 → 0.1.15
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/commands/push.ts +26 -10
- package/bun.lock +3 -0
- package/daemon/client/AnnotationProvider.tsx +2 -2
- package/daemon/client/App.tsx +125 -47
- package/daemon/client/CompareView.tsx +100 -17
- package/daemon/client/OverviewView.tsx +326 -0
- package/daemon/client/PlanRenderer.tsx +13 -8
- package/daemon/client/RevisionSelect.tsx +16 -7
- package/daemon/client/components/Mermaid.tsx +3 -2
- package/daemon/client/generateMarkdown.ts +24 -5
- package/daemon/client/runtime.ts +9 -2
- package/daemon/client/useTextAnnotation.tsx +6 -2
- package/daemon/dist/client.css +1 -1
- package/daemon/dist/client.js +58 -58
- package/daemon/dist/components.js +23 -23
- package/daemon/dist/runtime.js +1 -1
- package/daemon/src/compiler.ts +100 -0
- package/daemon/src/handlers/api.ts +57 -26
- package/daemon/src/session.ts +140 -41
- package/daemon/src/watcher.ts +42 -30
- package/package.json +4 -3
- package/skills/canvas/SKILL.md +24 -19
- package/skills/canvas/flows.md +1 -1
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import React, { useState, useContext, useMemo, useRef, useEffect, useCallback } from "react";
|
|
2
|
+
import { SessionContext } from "#canvas/runtime";
|
|
3
|
+
import { PlanRenderer } from "./PlanRenderer";
|
|
4
|
+
import { ComparePanelRenderer } from "./CompareView";
|
|
5
|
+
import { ActiveViewContext, RevisionContext, type CanvasFileInfo, type RevisionInfo } from "./App";
|
|
6
|
+
import { extractBlockTree, matchBlocks, buildUnifiedDom } from "./unifiedDiff";
|
|
7
|
+
import { runDomDiff } from "./domDiff";
|
|
8
|
+
|
|
9
|
+
interface OverviewViewProps {
|
|
10
|
+
revision: number;
|
|
11
|
+
responseBanner?: React.ReactNode;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface FileChange {
|
|
15
|
+
filename: string;
|
|
16
|
+
status: "new" | "changed" | "unchanged";
|
|
17
|
+
diffStats?: { added: number; removed: number };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function categorizeChanges(
|
|
21
|
+
currentRev: RevisionInfo | undefined,
|
|
22
|
+
previousRev: RevisionInfo | undefined,
|
|
23
|
+
): FileChange[] {
|
|
24
|
+
if (!currentRev) return [];
|
|
25
|
+
|
|
26
|
+
return currentRev.canvasFiles.map(cf => {
|
|
27
|
+
if (!cf.diffStats) {
|
|
28
|
+
return { filename: cf.filename, status: "new" as const };
|
|
29
|
+
}
|
|
30
|
+
if (cf.diffStats.added === 0 && cf.diffStats.removed === 0) {
|
|
31
|
+
return { filename: cf.filename, status: "unchanged" as const, diffStats: cf.diffStats };
|
|
32
|
+
}
|
|
33
|
+
return { filename: cf.filename, status: "changed" as const, diffStats: cf.diffStats };
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getAffectedFiles(changes: FileChange[]): string[] {
|
|
38
|
+
return changes.filter(c => c.status !== "unchanged").map(c => c.filename);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function OverviewView({ revision, responseBanner }: OverviewViewProps) {
|
|
42
|
+
const { setActiveView } = useContext(ActiveViewContext);
|
|
43
|
+
const { revisions } = useContext(RevisionContext);
|
|
44
|
+
|
|
45
|
+
const currentRev = revisions.find(r => r.revision === revision);
|
|
46
|
+
const previousRev = revisions.find(r => r.revision === revision - 1);
|
|
47
|
+
const changes = useMemo(() => categorizeChanges(currentRev, previousRev), [currentRev, previousRev]);
|
|
48
|
+
|
|
49
|
+
const affected = changes.filter(c => c.status !== "unchanged");
|
|
50
|
+
const unchanged = changes.filter(c => c.status === "unchanged");
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className="max-w-[720px] mx-auto px-6 pt-12 pb-32">
|
|
54
|
+
{responseBanner}
|
|
55
|
+
|
|
56
|
+
{affected.map(change => (
|
|
57
|
+
<OverviewFileSection
|
|
58
|
+
key={change.filename}
|
|
59
|
+
change={change}
|
|
60
|
+
revision={revision}
|
|
61
|
+
previousRevision={previousRev?.revision}
|
|
62
|
+
onOpenTab={() => setActiveView({ type: "canvas", filename: change.filename })}
|
|
63
|
+
/>
|
|
64
|
+
))}
|
|
65
|
+
|
|
66
|
+
{unchanged.length > 0 && (
|
|
67
|
+
<div className="mt-8 border-t border-border-subtle pt-4">
|
|
68
|
+
<div className="text-[11px] font-body font-medium uppercase tracking-widest text-text-tertiary mb-3">
|
|
69
|
+
Unchanged
|
|
70
|
+
</div>
|
|
71
|
+
{unchanged.map(change => (
|
|
72
|
+
<UnchangedFileRow
|
|
73
|
+
key={change.filename}
|
|
74
|
+
change={change}
|
|
75
|
+
revision={revision}
|
|
76
|
+
onOpenTab={() => setActiveView({ type: "canvas", filename: change.filename })}
|
|
77
|
+
/>
|
|
78
|
+
))}
|
|
79
|
+
</div>
|
|
80
|
+
)}
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type ViewMode = "rendered" | "unified" | "side-by-side";
|
|
86
|
+
|
|
87
|
+
function ViewModeToggle({ mode, onChange, showDiff }: { mode: ViewMode; onChange: (m: ViewMode) => void; showDiff: boolean }) {
|
|
88
|
+
if (!showDiff) return null;
|
|
89
|
+
return (
|
|
90
|
+
<div className="flex items-center bg-bg-elevated rounded-md p-0.5">
|
|
91
|
+
{(["rendered", "unified", "side-by-side"] as ViewMode[]).map(m => (
|
|
92
|
+
<button
|
|
93
|
+
key={m}
|
|
94
|
+
onClick={() => onChange(m)}
|
|
95
|
+
className={`text-[10px] font-body px-2 py-0.5 rounded transition-colors ${
|
|
96
|
+
mode === m
|
|
97
|
+
? "bg-bg-surface text-text-primary font-medium shadow-sm"
|
|
98
|
+
: "text-text-tertiary hover:text-text-secondary"
|
|
99
|
+
}`}
|
|
100
|
+
>
|
|
101
|
+
{m === "rendered" ? "Full" : m === "unified" ? "Unified" : "Split"}
|
|
102
|
+
</button>
|
|
103
|
+
))}
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function OverviewFileSection({
|
|
109
|
+
change,
|
|
110
|
+
revision,
|
|
111
|
+
previousRevision,
|
|
112
|
+
onOpenTab,
|
|
113
|
+
}: {
|
|
114
|
+
change: FileChange;
|
|
115
|
+
revision: number;
|
|
116
|
+
previousRevision?: number;
|
|
117
|
+
onOpenTab: () => void;
|
|
118
|
+
}) {
|
|
119
|
+
const label = change.filename.replace(/\.jsx$/, "");
|
|
120
|
+
const isNew = change.status === "new";
|
|
121
|
+
const diffStats = change.diffStats;
|
|
122
|
+
const canDiff = change.status === "changed" && previousRevision != null;
|
|
123
|
+
const [viewMode, setViewMode] = useState<ViewMode>("rendered");
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<div className="mb-8">
|
|
127
|
+
{/* File header */}
|
|
128
|
+
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-border-subtle">
|
|
129
|
+
<span className={`text-[11px] font-mono px-1.5 py-0.5 rounded ${
|
|
130
|
+
isNew
|
|
131
|
+
? "bg-accent-green/10 text-accent-green border border-accent-green/20"
|
|
132
|
+
: "bg-accent-amber/10 text-accent-amber border border-accent-amber/20"
|
|
133
|
+
}`}>
|
|
134
|
+
{isNew ? "new" : "modified"}
|
|
135
|
+
</span>
|
|
136
|
+
<span className="text-[14px] font-body font-medium text-text-primary">{label}</span>
|
|
137
|
+
{diffStats && (diffStats.added > 0 || diffStats.removed > 0) && (
|
|
138
|
+
<span className="text-[11px] font-mono text-text-tertiary">
|
|
139
|
+
<span className="text-accent-green">+{diffStats.added}</span>
|
|
140
|
+
{" "}
|
|
141
|
+
<span className="text-accent-red">-{diffStats.removed}</span>
|
|
142
|
+
</span>
|
|
143
|
+
)}
|
|
144
|
+
<ViewModeToggle mode={viewMode} onChange={setViewMode} showDiff={canDiff} />
|
|
145
|
+
<button
|
|
146
|
+
onClick={onOpenTab}
|
|
147
|
+
className={`${canDiff ? "" : "ml-auto"} text-[11px] font-body font-medium text-accent-blue hover:text-accent-blue/80 transition-colors`}
|
|
148
|
+
>
|
|
149
|
+
Open in tab
|
|
150
|
+
</button>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
{/* Content based on view mode */}
|
|
154
|
+
{viewMode === "rendered" ? (
|
|
155
|
+
<div data-canvas-file={change.filename}>
|
|
156
|
+
<PlanRenderer revision={revision} filename={change.filename} />
|
|
157
|
+
</div>
|
|
158
|
+
) : canDiff && previousRevision != null ? (
|
|
159
|
+
<InlineDiff
|
|
160
|
+
key={`${viewMode}-${revision}-${previousRevision}`}
|
|
161
|
+
filename={change.filename}
|
|
162
|
+
leftRev={previousRevision}
|
|
163
|
+
rightRev={revision}
|
|
164
|
+
mode={viewMode}
|
|
165
|
+
/>
|
|
166
|
+
) : null}
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/* ── Inline diff for overview ── */
|
|
172
|
+
|
|
173
|
+
function InlineDiff({
|
|
174
|
+
filename,
|
|
175
|
+
leftRev,
|
|
176
|
+
rightRev,
|
|
177
|
+
mode,
|
|
178
|
+
}: {
|
|
179
|
+
filename: string;
|
|
180
|
+
leftRev: number;
|
|
181
|
+
rightRev: number;
|
|
182
|
+
mode: "unified" | "side-by-side";
|
|
183
|
+
}) {
|
|
184
|
+
const sessionId = useContext(SessionContext);
|
|
185
|
+
|
|
186
|
+
if (mode === "unified") {
|
|
187
|
+
return <InlineUnifiedDiff sessionId={sessionId} filename={filename} leftRev={leftRev} rightRev={rightRev} />;
|
|
188
|
+
}
|
|
189
|
+
return <InlineSideBySideDiff sessionId={sessionId} filename={filename} leftRev={leftRev} rightRev={rightRev} />;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function InlineUnifiedDiff({ sessionId, filename, leftRev, rightRev }: { sessionId: string; filename: string; leftRev: number; rightRev: number }) {
|
|
193
|
+
const oldRef = useRef<HTMLDivElement>(null);
|
|
194
|
+
const newRef = useRef<HTMLDivElement>(null);
|
|
195
|
+
const unifiedRef = useRef<HTMLDivElement>(null);
|
|
196
|
+
const [leftReady, setLeftReady] = useState(false);
|
|
197
|
+
const [rightReady, setRightReady] = useState(false);
|
|
198
|
+
const [done, setDone] = useState(false);
|
|
199
|
+
const [error, setError] = useState<string | null>(null);
|
|
200
|
+
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
if (!leftReady || !rightReady || done) return;
|
|
203
|
+
if (!oldRef.current || !newRef.current) return;
|
|
204
|
+
|
|
205
|
+
requestAnimationFrame(() => {
|
|
206
|
+
requestAnimationFrame(() => {
|
|
207
|
+
if (!oldRef.current || !newRef.current || !unifiedRef.current) return;
|
|
208
|
+
try {
|
|
209
|
+
const oldTree = extractBlockTree(oldRef.current);
|
|
210
|
+
const newTree = extractBlockTree(newRef.current);
|
|
211
|
+
const matches = matchBlocks(oldTree, newTree);
|
|
212
|
+
const { fragment } = buildUnifiedDom(matches);
|
|
213
|
+
unifiedRef.current.innerHTML = "";
|
|
214
|
+
unifiedRef.current.appendChild(fragment);
|
|
215
|
+
setDone(true);
|
|
216
|
+
} catch (e) {
|
|
217
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
}, [leftReady, rightReady, done]);
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<div>
|
|
225
|
+
{/* Offscreen panels for diff computation */}
|
|
226
|
+
{!done && !error && (
|
|
227
|
+
<div style={{ position: "absolute", left: -9999, visibility: "hidden" as const, pointerEvents: "none" as const, width: 720 }}>
|
|
228
|
+
<ComparePanelRenderer ref={oldRef} sessionId={sessionId} revision={leftRev} filename={filename} onReady={() => setLeftReady(true)} onError={(msg) => setError(msg)} />
|
|
229
|
+
<ComparePanelRenderer ref={newRef} sessionId={sessionId} revision={rightRev} filename={filename} onReady={() => setRightReady(true)} onError={(msg) => setError(msg)} />
|
|
230
|
+
</div>
|
|
231
|
+
)}
|
|
232
|
+
{!done && !error && (
|
|
233
|
+
<div className="flex items-center justify-center h-24 text-text-tertiary font-body text-[13px]">Computing diff...</div>
|
|
234
|
+
)}
|
|
235
|
+
{error && (
|
|
236
|
+
<div className="text-accent-red text-[13px] font-body p-3 bg-accent-red-muted rounded-lg">{error}</div>
|
|
237
|
+
)}
|
|
238
|
+
<div ref={unifiedRef} />
|
|
239
|
+
</div>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function InlineSideBySideDiff({ sessionId, filename, leftRev, rightRev }: { sessionId: string; filename: string; leftRev: number; rightRev: number }) {
|
|
244
|
+
const oldRef = useRef<HTMLDivElement>(null);
|
|
245
|
+
const newRef = useRef<HTMLDivElement>(null);
|
|
246
|
+
const [leftReady, setLeftReady] = useState(false);
|
|
247
|
+
const [rightReady, setRightReady] = useState(false);
|
|
248
|
+
const [done, setDone] = useState(false);
|
|
249
|
+
|
|
250
|
+
const handleLeftReady = useCallback(() => setLeftReady(true), []);
|
|
251
|
+
const handleRightReady = useCallback(() => setRightReady(true), []);
|
|
252
|
+
|
|
253
|
+
useEffect(() => {
|
|
254
|
+
if (!leftReady || !rightReady || done) return;
|
|
255
|
+
if (!oldRef.current || !newRef.current) return;
|
|
256
|
+
|
|
257
|
+
requestAnimationFrame(() => {
|
|
258
|
+
requestAnimationFrame(() => {
|
|
259
|
+
if (!oldRef.current || !newRef.current) return;
|
|
260
|
+
runDomDiff(oldRef.current, newRef.current);
|
|
261
|
+
setDone(true);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
}, [leftReady, rightReady, done]);
|
|
265
|
+
|
|
266
|
+
return (
|
|
267
|
+
<div className="flex gap-0 border-y border-border-subtle overflow-hidden w-screen relative left-1/2 -translate-x-1/2">
|
|
268
|
+
<div className="w-1/2 border-r border-border-subtle">
|
|
269
|
+
<div className="px-2 py-1 text-[10px] font-body font-medium text-text-tertiary bg-bg-elevated border-b border-border-subtle">
|
|
270
|
+
Rev {leftRev}
|
|
271
|
+
</div>
|
|
272
|
+
<div className="px-6 py-4 max-w-[600px] mx-auto">
|
|
273
|
+
<ComparePanelRenderer ref={oldRef} sessionId={sessionId} revision={leftRev} filename={filename} onReady={handleLeftReady} />
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
<div className="w-1/2">
|
|
277
|
+
<div className="px-2 py-1 text-[10px] font-body font-medium text-text-tertiary bg-bg-elevated border-b border-border-subtle">
|
|
278
|
+
Rev {rightRev}
|
|
279
|
+
</div>
|
|
280
|
+
<div className="px-6 py-4 max-w-[600px] mx-auto">
|
|
281
|
+
<ComparePanelRenderer ref={newRef} sessionId={sessionId} revision={rightRev} filename={filename} onReady={handleRightReady} />
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function UnchangedFileRow({
|
|
289
|
+
change,
|
|
290
|
+
revision,
|
|
291
|
+
onOpenTab,
|
|
292
|
+
}: {
|
|
293
|
+
change: FileChange;
|
|
294
|
+
revision: number;
|
|
295
|
+
onOpenTab: () => void;
|
|
296
|
+
}) {
|
|
297
|
+
const [expanded, setExpanded] = useState(false);
|
|
298
|
+
const label = change.filename.replace(/\.jsx$/, "");
|
|
299
|
+
|
|
300
|
+
return (
|
|
301
|
+
<div className="mb-2">
|
|
302
|
+
<div className="flex items-center gap-2 py-1.5">
|
|
303
|
+
<button
|
|
304
|
+
onClick={() => setExpanded(!expanded)}
|
|
305
|
+
className="text-text-tertiary hover:text-text-secondary transition-colors"
|
|
306
|
+
>
|
|
307
|
+
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" className={`transition-transform duration-150 ${expanded ? "rotate-90" : ""}`}>
|
|
308
|
+
<path d="M3.5 2L6.5 5L3.5 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
|
309
|
+
</svg>
|
|
310
|
+
</button>
|
|
311
|
+
<span className="text-[13px] font-body text-text-secondary">{label}</span>
|
|
312
|
+
<button
|
|
313
|
+
onClick={onOpenTab}
|
|
314
|
+
className="ml-auto text-[11px] font-body text-text-tertiary hover:text-accent-blue transition-colors"
|
|
315
|
+
>
|
|
316
|
+
Open in tab
|
|
317
|
+
</button>
|
|
318
|
+
</div>
|
|
319
|
+
{expanded && (
|
|
320
|
+
<div className="ml-5 mt-1 mb-3" data-canvas-file={change.filename}>
|
|
321
|
+
<PlanRenderer revision={revision} filename={change.filename} />
|
|
322
|
+
</div>
|
|
323
|
+
)}
|
|
324
|
+
</div>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React, { useEffect, useState, useRef, useContext, useMemo } from "react";
|
|
2
2
|
import { createPortal } from "react-dom";
|
|
3
|
-
import { SessionContext } from "#canvas/runtime";
|
|
3
|
+
import { SessionContext, CanvasFileCtx } from "#canvas/runtime";
|
|
4
4
|
import { useAnnotations } from "./AnnotationProvider";
|
|
5
5
|
import { extractContext } from "./annotationContext";
|
|
6
6
|
import { AnnotationCreatePopover, AnnotationEditPopover } from "./Popover";
|
|
@@ -14,15 +14,18 @@ const ANNOTATABLE_SELECTOR = "[data-md='item'], [data-md='section'], [data-md='t
|
|
|
14
14
|
|
|
15
15
|
interface PlanRendererProps {
|
|
16
16
|
revision: number;
|
|
17
|
+
filename: string;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
export function PlanRenderer({ revision }: PlanRendererProps) {
|
|
20
|
+
export function PlanRenderer({ revision, filename }: PlanRendererProps) {
|
|
20
21
|
const sessionId = useContext(SessionContext);
|
|
21
22
|
const [PlanComponent, setPlanComponent] = useState<React.ComponentType | null>(null);
|
|
22
23
|
const [error, setError] = useState<string | null>(null);
|
|
23
24
|
const [loading, setLoading] = useState(true);
|
|
24
25
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
25
|
-
const { annotations, addAnnotationWithId, removeAnnotation, updateAnnotation, addAnnotationImage, removeAnnotationImage, activeAnnotationId, setActiveAnnotationId } = useAnnotations();
|
|
26
|
+
const { annotations: allAnnotations, addAnnotationWithId, removeAnnotation, updateAnnotation, addAnnotationImage, removeAnnotationImage, activeAnnotationId, setActiveAnnotationId } = useAnnotations();
|
|
27
|
+
// Filter annotations to this canvas file
|
|
28
|
+
const annotations = useMemo(() => allAnnotations.filter(a => !a.filePath && (!a.canvasFile || a.canvasFile === filename)), [allAnnotations, filename]);
|
|
26
29
|
const [editingAnn, setEditingAnn] = useState<{ id: string; note: string } | null>(null);
|
|
27
30
|
|
|
28
31
|
// Block annotation hover state
|
|
@@ -37,10 +40,11 @@ export function PlanRenderer({ revision }: PlanRendererProps) {
|
|
|
37
40
|
setLoading(true);
|
|
38
41
|
setError(null);
|
|
39
42
|
setFocusedBlockIndex(null);
|
|
40
|
-
|
|
43
|
+
const jsName = filename.replace(/\.jsx$/, ".js");
|
|
44
|
+
import(`/api/session/${sessionId}/canvas/${encodeURIComponent(jsName)}?rev=${revision}&t=${Date.now()}`)
|
|
41
45
|
.then((mod) => { setPlanComponent(() => mod.default); setLoading(false); })
|
|
42
46
|
.catch((e) => { setError(e.message); setLoading(false); });
|
|
43
|
-
}, [sessionId, revision]);
|
|
47
|
+
}, [sessionId, revision, filename]);
|
|
44
48
|
|
|
45
49
|
const scrollContainer = document.getElementById("plan-scroll-container");
|
|
46
50
|
|
|
@@ -49,6 +53,7 @@ export function PlanRenderer({ revision }: PlanRendererProps) {
|
|
|
49
53
|
restoreKey: PlanComponent,
|
|
50
54
|
restoreAnnotations: annotations,
|
|
51
55
|
extractContext: (range) => extractContext(range, containerRef.current!),
|
|
56
|
+
canvasFile: filename,
|
|
52
57
|
scrollContainer,
|
|
53
58
|
});
|
|
54
59
|
|
|
@@ -257,7 +262,7 @@ export function PlanRenderer({ revision }: PlanRendererProps) {
|
|
|
257
262
|
}
|
|
258
263
|
|
|
259
264
|
return (
|
|
260
|
-
|
|
265
|
+
<CanvasFileCtx.Provider value={filename}>
|
|
261
266
|
<div ref={containerRef} className="plan-content plan-updated">
|
|
262
267
|
{planElement}
|
|
263
268
|
</div>
|
|
@@ -298,7 +303,7 @@ export function PlanRenderer({ revision }: PlanRendererProps) {
|
|
|
298
303
|
truncateAt={80}
|
|
299
304
|
onAdd={(note, images) => {
|
|
300
305
|
const id = generateAnnotationId();
|
|
301
|
-
addAnnotationWithId(id, blockPopover.snippet, note, undefined, undefined, images);
|
|
306
|
+
addAnnotationWithId(id, blockPopover.snippet, note, undefined, undefined, images, filename);
|
|
302
307
|
setBlockPopover(null);
|
|
303
308
|
}}
|
|
304
309
|
onCancel={() => setBlockPopover(null)}
|
|
@@ -328,7 +333,7 @@ export function PlanRenderer({ revision }: PlanRendererProps) {
|
|
|
328
333
|
/>
|
|
329
334
|
);
|
|
330
335
|
})()}
|
|
331
|
-
|
|
336
|
+
</CanvasFileCtx.Provider>
|
|
332
337
|
);
|
|
333
338
|
}
|
|
334
339
|
|
|
@@ -86,13 +86,22 @@ export function RevisionSelect({ value, onChange, accent = "default", className
|
|
|
86
86
|
)}
|
|
87
87
|
</span>
|
|
88
88
|
<span className="text-[10px] text-text-tertiary flex-shrink-0 flex items-center gap-1.5">
|
|
89
|
-
{
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
89
|
+
{(() => {
|
|
90
|
+
const stats = r.canvasFiles?.reduce((acc, cf) => {
|
|
91
|
+
if (cf.diffStats) { acc.added += cf.diffStats.added; acc.removed += cf.diffStats.removed; }
|
|
92
|
+
return acc;
|
|
93
|
+
}, { added: 0, removed: 0 });
|
|
94
|
+
if (stats && (stats.added > 0 || stats.removed > 0)) {
|
|
95
|
+
return (
|
|
96
|
+
<span className="font-mono">
|
|
97
|
+
<span className="text-accent-green">+{stats.added}</span>
|
|
98
|
+
{" "}
|
|
99
|
+
<span className="text-accent-red">-{stats.removed}</span>
|
|
100
|
+
</span>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
})()}
|
|
96
105
|
{r.revision === currentRevision ? "current" : r.hasFeedback ? "sent" : relativeTime(r.createdAt)}
|
|
97
106
|
</span>
|
|
98
107
|
</button>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React, { useEffect, useRef, useState, useCallback } from "react";
|
|
2
2
|
import ReactDOM from "react-dom";
|
|
3
|
-
import { useAnnotations, Annotation } from "#canvas/runtime";
|
|
3
|
+
import { useAnnotations, useCanvasFile, Annotation } from "#canvas/runtime";
|
|
4
4
|
import { AnnotationCreatePopover } from "../Popover";
|
|
5
5
|
|
|
6
6
|
interface MermaidProps {
|
|
@@ -159,6 +159,7 @@ export function Mermaid({ children }: MermaidProps) {
|
|
|
159
159
|
const [svgHtml, setSvgHtml] = useState("");
|
|
160
160
|
const source = typeof children === "string" ? children : String(children ?? "");
|
|
161
161
|
const { annotations, addAnnotationWithId, activeAnnotationId, setActiveAnnotationId } = useAnnotations();
|
|
162
|
+
const canvasFile = useCanvasFile();
|
|
162
163
|
const [nodePopover, setNodePopover] = useState<NodePopoverState | null>(null);
|
|
163
164
|
const { zoom, zoomIn, zoomOut, zoomReset, setZoom } = useZoom(1);
|
|
164
165
|
|
|
@@ -278,7 +279,7 @@ export function Mermaid({ children }: MermaidProps) {
|
|
|
278
279
|
}
|
|
279
280
|
onAdd={(note, images) => {
|
|
280
281
|
const id = `ann-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
281
|
-
addAnnotationWithId(id, `[Diagram ${nodePopover.prefix.toLowerCase()}] ${nodePopover.snippet}`, note, undefined, undefined, images);
|
|
282
|
+
addAnnotationWithId(id, `[Diagram ${nodePopover.prefix.toLowerCase()}] ${nodePopover.snippet}`, note, undefined, undefined, images, canvasFile || undefined);
|
|
282
283
|
setNodePopover(null);
|
|
283
284
|
}}
|
|
284
285
|
onCancel={() => setNodePopover(null)}
|
|
@@ -13,7 +13,7 @@ export function generateMarkdown(
|
|
|
13
13
|
): string {
|
|
14
14
|
const parts: string[] = [];
|
|
15
15
|
const responseAnns = annotations.filter((a) => a.filePath === RESPONSE_ANNOTATION_PATH);
|
|
16
|
-
const
|
|
16
|
+
const canvasAnns = annotations.filter((a) => !a.filePath);
|
|
17
17
|
const fileAnns = annotations.filter((a) => a.filePath && a.filePath !== RESPONSE_ANNOTATION_PATH);
|
|
18
18
|
const resps = responses ? Array.from(responses.values()).filter((r) => hasValue(r)) : [];
|
|
19
19
|
const fbEntries = feedbackEntries
|
|
@@ -43,12 +43,31 @@ export function generateMarkdown(
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
//
|
|
47
|
-
if (
|
|
46
|
+
// Canvas annotations grouped by canvas file, then hierarchy
|
|
47
|
+
if (canvasAnns.length > 0) {
|
|
48
48
|
parts.push("## Canvas annotations");
|
|
49
49
|
parts.push("");
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
|
|
51
|
+
// Group by canvasFile
|
|
52
|
+
const byCanvas = new Map<string, Annotation[]>();
|
|
53
|
+
for (const ann of canvasAnns) {
|
|
54
|
+
const key = ann.canvasFile || "";
|
|
55
|
+
if (!byCanvas.has(key)) byCanvas.set(key, []);
|
|
56
|
+
byCanvas.get(key)!.push(ann);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const canvasKeys = [...byCanvas.keys()].sort();
|
|
60
|
+
const multipleCanvases = canvasKeys.length > 1 || (canvasKeys.length === 1 && canvasKeys[0] !== "");
|
|
61
|
+
|
|
62
|
+
for (const canvasKey of canvasKeys) {
|
|
63
|
+
const anns = byCanvas.get(canvasKey)!;
|
|
64
|
+
if (multipleCanvases && canvasKey) {
|
|
65
|
+
parts.push(`### ${canvasKey.replace(/\.jsx$/, "")}`);
|
|
66
|
+
parts.push("");
|
|
67
|
+
}
|
|
68
|
+
const grouped = groupByHierarchy(anns);
|
|
69
|
+
parts.push(renderHierarchyGroup(grouped));
|
|
70
|
+
}
|
|
52
71
|
}
|
|
53
72
|
|
|
54
73
|
// File annotations grouped by path
|
package/daemon/client/runtime.ts
CHANGED
|
@@ -18,6 +18,7 @@ export interface Annotation {
|
|
|
18
18
|
note: string;
|
|
19
19
|
createdAt: string;
|
|
20
20
|
filePath?: string;
|
|
21
|
+
canvasFile?: string;
|
|
21
22
|
context?: AnnotationContext;
|
|
22
23
|
images?: string[];
|
|
23
24
|
}
|
|
@@ -42,7 +43,7 @@ export interface FeedbackEntry {
|
|
|
42
43
|
export interface AnnotationContextValue {
|
|
43
44
|
annotations: Annotation[];
|
|
44
45
|
addAnnotation: (snippet: string, note: string, filePath?: string) => void;
|
|
45
|
-
addAnnotationWithId: (id: string, snippet: string, note: string, filePath?: string, context?: AnnotationContext, images?: string[]) => void;
|
|
46
|
+
addAnnotationWithId: (id: string, snippet: string, note: string, filePath?: string, context?: AnnotationContext, images?: string[], canvasFile?: string) => void;
|
|
46
47
|
updateAnnotation: (id: string, note: string) => void;
|
|
47
48
|
removeAnnotation: (id: string) => void;
|
|
48
49
|
addAnnotationImage: (id: string, imagePath: string) => void;
|
|
@@ -84,8 +85,14 @@ export function useFeedback(
|
|
|
84
85
|
|
|
85
86
|
export { SessionContext } from "./SessionContext";
|
|
86
87
|
|
|
88
|
+
// CanvasFile context — tells components which canvas file they belong to
|
|
89
|
+
export const CanvasFileCtx = createContext<string>("");
|
|
90
|
+
export function useCanvasFile(): string {
|
|
91
|
+
return useContext(CanvasFileCtx);
|
|
92
|
+
}
|
|
93
|
+
|
|
87
94
|
// ActiveView navigation — allows components (e.g. FilePreview) to open files
|
|
88
|
-
export type ActiveView = { type: "
|
|
95
|
+
export type ActiveView = { type: "overview" } | { type: "canvas"; filename: string } | { type: "file"; path: string };
|
|
89
96
|
|
|
90
97
|
export const ActiveViewCtx = createContext<{
|
|
91
98
|
setActiveView: (v: ActiveView) => void;
|
|
@@ -16,12 +16,14 @@ interface UseTextAnnotationOptions {
|
|
|
16
16
|
extractContext: (range: Range) => AnnotationContext | undefined;
|
|
17
17
|
/** Optional filePath to associate with created annotations */
|
|
18
18
|
filePath?: string;
|
|
19
|
+
/** Optional canvasFile to associate with created annotations */
|
|
20
|
+
canvasFile?: string;
|
|
19
21
|
/** Scroll container for popover positioning */
|
|
20
22
|
scrollContainer?: HTMLElement | null;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
export function useTextAnnotation(options: UseTextAnnotationOptions) {
|
|
24
|
-
const { containerRef, restoreKey, restoreAnnotations, extractContext, filePath, scrollContainer } = options;
|
|
26
|
+
const { containerRef, restoreKey, restoreAnnotations, extractContext, filePath, canvasFile, scrollContainer } = options;
|
|
25
27
|
const { annotations: allAnnotations, addAnnotationWithId, removeAnnotation, updateAnnotation, addAnnotationImage, removeAnnotationImage, activeAnnotationId, setActiveAnnotationId } = useAnnotations();
|
|
26
28
|
|
|
27
29
|
const [editPopover, setEditPopover] = useState<{ anchorEl: HTMLElement; annId: string } | null>(null);
|
|
@@ -32,6 +34,8 @@ export function useTextAnnotation(options: UseTextAnnotationOptions) {
|
|
|
32
34
|
extractContextRef.current = extractContext;
|
|
33
35
|
const filePathRef = useRef(filePath);
|
|
34
36
|
filePathRef.current = filePath;
|
|
37
|
+
const canvasFileRef = useRef(canvasFile);
|
|
38
|
+
canvasFileRef.current = canvasFile;
|
|
35
39
|
const addAnnotationRef = useRef(addAnnotationWithId);
|
|
36
40
|
addAnnotationRef.current = addAnnotationWithId;
|
|
37
41
|
const activeAnnotationIdRef = useRef(activeAnnotationId);
|
|
@@ -167,7 +171,7 @@ export function useTextAnnotation(options: UseTextAnnotationOptions) {
|
|
|
167
171
|
onAdd={(note, images) => {
|
|
168
172
|
const id = generateAnnotationId();
|
|
169
173
|
renameMarkId(createPopover.tempId, id);
|
|
170
|
-
addAnnotationRef.current(id, createPopover.snippet, note, filePathRef.current, createPopover.ctx, images);
|
|
174
|
+
addAnnotationRef.current(id, createPopover.snippet, note, filePathRef.current, createPopover.ctx, images, canvasFileRef.current);
|
|
171
175
|
setCreatePopover(null);
|
|
172
176
|
}}
|
|
173
177
|
onCancel={() => {
|